21
1
Fork 0
mirror of https://github.com/Evolix/chexpire.git synced 2024-05-05 02:05:09 +02:00

Merge pull request #12 from Evolix/notifier

Notification system
This commit is contained in:
Colin Darie 2018-06-05 10:29:26 +02:00 committed by GitHub
commit 822da5c752
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
75 changed files with 1565 additions and 118 deletions

6
.gitignore vendored
View file

@ -35,3 +35,9 @@ yarn-debug.log*
.yarn-integrity
/config/deploy/config.yml
# SimpleCov coverage output
/coverage
# OS file
.DS_Store

View file

@ -19,9 +19,9 @@ before_install:
install:
- bundle install
- yarn install
- rails db:setup
- rails db:create db:migrate
script:
- bundle exec rubocop
- bundle exec rails test
- bundle exec rails test:system
- bundle exec rails test NO_COVERAGE=1
- bundle exec rails test:system NO_COVERAGE=1

View file

@ -39,6 +39,9 @@ gem 'bcrypt', '~> 3.1.7'
gem 'open4'
gem 'naught'
gem 'octicons'
# Reduces boot times through caching; required in config/boot.rb
gem 'bootsnap', '>= 1.1.0', require: false
@ -47,6 +50,8 @@ group :development, :test do
gem 'binding_of_caller'
gem 'pry-byebug'
gem 'pry-rails'
gem "factory_bot_rails"
end
group :development do
@ -61,7 +66,6 @@ group :development do
gem 'annotate', require: false
gem 'letter_opener_web'
gem "guard"
gem "guard-minitest"
@ -78,6 +82,9 @@ group :test do
# Easy installation and use of chromedriver to run system tests with Chrome
gem 'chromedriver-helper'
gem 'launchy'
gem "database_cleaner"
gem "simplecov", require: false
end
# Windows does not include zoneinfo files, so bundle the tzinfo-data gem

View file

@ -94,6 +94,7 @@ GEM
coderay (1.1.2)
concurrent-ruby (1.0.5)
crass (1.0.4)
database_cleaner (1.7.0)
debug_inspector (0.0.3)
devise (4.4.3)
bcrypt (~> 3.0)
@ -103,8 +104,14 @@ GEM
warden (~> 1.2.3)
devise-i18n (1.6.2)
devise (>= 4.4)
docile (1.3.1)
erubi (1.7.1)
execjs (2.7.0)
factory_bot (4.10.0)
activesupport (>= 3.0.0)
factory_bot_rails (4.10.0)
factory_bot (~> 4.10.0)
railties (>= 3.0.0)
ffi (1.9.23)
formatador (0.2.5)
globalid (0.4.1)
@ -128,6 +135,7 @@ GEM
jbuilder (2.7.0)
activesupport (>= 4.2.0)
multi_json (>= 1.2)
json (2.1.0)
launchy (2.4.3)
addressable (~> 2.3)
letter_opener (1.6.0)
@ -167,6 +175,8 @@ GEM
notiffany (0.1.1)
nenv (~> 0.1)
shellany (~> 0.0)
octicons (7.3.0)
nokogiri (>= 1.6.3.1)
open4 (1.3.4)
orm_adapter (0.5.0)
parallel (1.12.1)
@ -253,6 +263,11 @@ GEM
simple_form (4.0.1)
actionpack (>= 5.0)
activemodel (>= 5.0)
simplecov (0.16.1)
docile (~> 1.1)
json (>= 1.8, < 3)
simplecov-html (~> 0.10.0)
simplecov-html (0.10.2)
spring (2.0.2)
activesupport (>= 4.2)
spring-watcher-listen (2.0.1)
@ -310,8 +325,10 @@ DEPENDENCIES
capistrano3-puma
capybara (>= 2.15, < 4.0)
chromedriver-helper
database_cleaner
devise (~> 4.4)
devise-i18n (~> 1.6)
factory_bot_rails
guard
guard-minitest
jbuilder (~> 2.5)
@ -320,6 +337,7 @@ DEPENDENCIES
listen (>= 3.0.5, < 3.2)
mysql2 (>= 0.4.4, < 0.6.0)
naught
octicons
open4
pry-byebug
pry-rails
@ -331,6 +349,7 @@ DEPENDENCIES
sass-rails (~> 5.0)
selenium-webdriver
simple_form (~> 4.0)
simplecov
spring
spring-watcher-listen (~> 2.0.0)
turbolinks (~> 5)

View file

@ -21,9 +21,11 @@ guard "minitest", spring: "bin/rails test" do
watch(%r{^app/controllers/application_controller\.rb$}) { "test/controllers" }
watch(%r{^app/controllers/(.+)_controller\.rb$}) { |m| "test/integration/#{m[1]}_test.rb" }
watch(%r{^app/views/(.+)_mailer/.+}) { |m| "test/mailers/#{m[1]}_mailer_test.rb" }
watch(%r{^app/services/notifier/.+\.rb}) { |_m| "test/services/notifier" }
watch(%r{^app/services/whois/.+\.rb}) { |_m| "test/services/whois" }
watch(%r{^lib/(.*/)?([^/]+)\.rb$}) { |m| "test/#{m[1]}test_#{m[2]}.rb" }
watch(%r{^test/.+_test\.rb$})
watch(%r{^test/test_helper\.rb$}) { "test" }
watch(%r{^test/fixtures/.+\.yml$}) { "test" }
watch(%r{^test/factories/.+\.rb$}) { "test" }
end

View file

@ -10,6 +10,7 @@ class ChecksController < ApplicationController
def new
@check = Check.new
build_empty_notification
authorize @check
end
@ -27,7 +28,9 @@ class ChecksController < ApplicationController
end
end
def edit; end
def edit
build_empty_notification
end
def update
if @check.update(update_check_params)
@ -35,6 +38,7 @@ class ChecksController < ApplicationController
redirect_to checks_path
else
flash.now[:alert] = "An error occured."
build_empty_notification
render :edit
end
end
@ -62,6 +66,11 @@ class ChecksController < ApplicationController
end
def check_params(*others)
params.require(:check).permit(:domain, :domain_created_at, :comment, :vendor, *others)
params.require(:check).permit(:domain, :domain_created_at, :comment, :vendor, *others,
notifications_attributes: [:id, :channel, :recipient, :delay])
end
def build_empty_notification
@check.notifications.build
end
end

View file

@ -0,0 +1,44 @@
class NotificationsController < ApplicationController
before_action :authenticate_user!
before_action :set_notification, except: [:create]
def create
check = Check.find(params[:check_id])
@notification = check.notifications.build(notification_params)
authorize @notification
if @notification.save
flash[:notice] = "Your notification has been saved."
redirect_to check_path
else
flash.now[:alert] = "An error occured."
render "checks/edit"
end
end
def destroy
@notification.destroy!
respond_to do |format|
format.js
end
end
private
def set_notification
# joins the check because policy use the check relation
@notification = Notification
.joins(:check)
.find_by!(id: params[:id], check_id: params[:check_id])
authorize @notification
end
def notification_params
params.require(:notification).permit(:channel, :recipient, :delay)
end
def check_path
edit_check_path(check_id: params[:check_id])
end
end

View file

@ -0,0 +1,5 @@
.octicon {
fill: currentColor;
vertical-align: text-top;
display: inline-block;
}

View file

@ -1,3 +1,4 @@
@import '~bootstrap/scss/bootstrap';
@import 'layout';
@import 'icons';
@import 'components/users';

View file

@ -1,2 +1,5 @@
module ApplicationHelper
def format_utc(time, format: :default)
l(time.utc, format: format)
end
end

View file

@ -0,0 +1,9 @@
module NotificationsHelper
def many_channels_available?
Notification.channels.many?
end
def recipient_col_class
many_channels_available? ? "col-md-7" : "col-md-9"
end
end

View file

@ -7,8 +7,9 @@ class WhoisSyncJob < ApplicationJob
def perform(check_id)
@check = Check.find(check_id)
response = Whois.ask(check.domain)
check.update_attribute(:last_run_at, Time.now)
response = Whois.ask(check.domain)
return unless response.valid?
update_from_response(response)
@ -17,11 +18,14 @@ class WhoisSyncJob < ApplicationJob
rescue Whois::DomainNotFoundError
check.active = false
check.save!
rescue Whois::ParserError # rubocop:disable Lint/HandleExceptions
# already logged
end
def update_from_response(response)
check.domain_created_at = response.created_at
check.domain_updated_at = response.updated_at
check.domain_expire_at = response.expire_at
check.domain_expires_at = response.expire_at
check.last_success_at = Time.now
end
end

View file

@ -0,0 +1,22 @@
class NotificationsMailer < ApplicationMailer
helper :application
before_action do
@notification = params.fetch(:notification)
@check = @notification.check
end
default to: -> { @notification.recipient }
def domain_expires_soon
@expire_in_days = Integer(@check.domain_expires_at.to_date - Date.today)
subject = t(".subject", domain: @check.domain, count: @expire_in_days)
mail subject: subject
end
def domain_recurrent_failures
subject = t(".subject", domain: @check.domain)
mail subject: subject
end
end

View file

@ -7,7 +7,7 @@
# comment :string(255)
# domain :string(255) not null
# domain_created_at :datetime
# domain_expire_at :datetime
# domain_expires_at :datetime
# domain_updated_at :datetime
# kind :integer not null
# last_run_at :datetime
@ -29,13 +29,17 @@
class Check < ApplicationRecord
belongs_to :user
has_many :logs, class_name: "CheckLog"
has_many :notifications, validate: true, dependent: :destroy
accepts_nested_attributes_for :notifications,
allow_destroy: true,
reject_if: lambda { |at| at["recipient"].blank? && at["delay"].blank? }
enum kind: [:domain, :ssl]
self.skip_time_zone_conversion_for_attributes = [
:domain_created_at,
:domain_updated_at,
:domain_expire_at,
:domain_expires_at,
]
validates :kind, presence: true
@ -45,9 +49,10 @@ class Check < ApplicationRecord
validates :comment, length: { maximum: 255 }
validates :vendor, length: { maximum: 255 }
after_update :reset_notifications
after_save :enqueue_sync
protected
private
def domain_created_at_past
errors.add(:domain_created_at, :past) if domain_created_at.present? && domain_created_at.future?
@ -63,4 +68,10 @@ class Check < ApplicationRecord
WhoisSyncJob.perform_later(id) if domain?
end
def reset_notifications
return unless (saved_changes.keys & %w[domain domain_expires_at]).present?
notifications.each(&:reset!)
end
end

View file

@ -0,0 +1,44 @@
# == Schema Information
#
# Table name: notifications
#
# id :bigint(8) not null, primary key
# channel :integer default("email"), not null
# delay :integer not null
# recipient :string(255) not null
# sent_at :datetime
# status :integer default("pending"), not null
# created_at :datetime not null
# updated_at :datetime not null
# check_id :bigint(8)
#
# Indexes
#
# index_notifications_on_check_id (check_id)
#
# Foreign Keys
#
# fk_rails_... (check_id => checks.id)
#
class Notification < ApplicationRecord
belongs_to :check
enum channel: [:email]
enum status: [:pending, :ongoing, :succeed, :failed]
validates :channel, presence: true
validates :delay, numericality: { only_integer: true, greater_than_or_equal_to: 1 }
validates :recipient, presence: true
def pending!
self.sent_at = nil
super
end
alias reset! pending!
def ongoing!
self.sent_at = Time.now
super
end
end

View file

@ -37,4 +37,6 @@ class User < ApplicationRecord
has_many :checks
validates :tos_accepted, acceptance: true
scope :notifications_disabled, -> { where(notifications_enabled: false) }
end

View file

@ -0,0 +1,21 @@
class NotificationPolicy < ApplicationPolicy
class Scope < Scope
def resolve
scope.joins(:check).where(checks: { user: user })
end
end
def destroy?
check_owner?
end
def show?
false
end
private
def check_owner?
record.check.user == user
end
end

9
app/services/notifier.rb Normal file
View file

@ -0,0 +1,9 @@
module Notifier
class << self
def process_all(configuration = nil)
processor = Processor.new(configuration)
processor.process_expires_soon
processor.process_recurrent_failures
end
end
end

View file

@ -0,0 +1,41 @@
module Notifier
module Channels
class Base
def notify(reason, notification) # rubocop:disable Metrics/MethodLength
return unless supports?(reason, notification)
notification.ongoing!
case [notification.check.kind.to_sym, reason]
when [:domain, :expires_soon]
domain_notify_expires_soon(notification)
when [:domain, :recurrent_failures]
domain_notify_recurrent_failures(notification)
else
fail ArgumentError,
"Invalid notification reason `#{reason}` for check kind `#{notification.check.kind}`."
end
end
private
# :nocov:
def supports?(_reason, _notification)
fail NotImplementedError,
"#{self.class.name} channel did not implemented method #{__callee__}"
end
# domain notifications
def domain_notify_expires_soon(_notification)
fail NotImplementedError,
"Channel #{self.class.name} does not implement #{__callee__}"
end
def domain_notify_recurrent_failures(_notification)
fail NotImplementedError,
"Channel #{self.class.name} does not implement #{__callee__}"
end
# :nocov:
end
end
end

View file

@ -0,0 +1,21 @@
module Notifier
module Channels
class Email < Base
REASONS = %i[expires_soon recurrent_failures].freeze
protected
def supports?(reason, _notification)
REASONS.include?(reason)
end
def domain_notify_expires_soon(notification)
NotificationsMailer.with(notification: notification).domain_expires_soon.deliver_now
end
def domain_notify_recurrent_failures(notification)
NotificationsMailer.with(notification: notification).domain_recurrent_failures.deliver_now
end
end
end
end

View file

@ -0,0 +1,56 @@
module Notifier
Configuration = Struct.new(:interval, :failure_days)
class Processor
attr_reader :configuration
attr_reader :channels
attr_reader :resolver
def initialize(configuration = nil)
@configuration = configuration || default_configuration
@resolver = Resolver.new
@channels = {
email: Channels::Email.new,
}
end
def process_expires_soon
resolver.resolve_expires_soon.find_each do |notification|
notifier_channel_for(notification).notify(:expires_soon, notification)
sleep configuration.interval
end
end
def process_recurrent_failures
resolver.resolve_check_failed.find_each do |notification|
next unless should_notify_for_recurrent_failures?(notification)
notifier_channel_for(notification).notify(:recurrent_failures, notification)
sleep configuration.interval
end
end
private
def default_configuration
config = Rails.configuration.chexpire.fetch("notifier", {})
Configuration.new(
config.fetch("interval") { 0.00 },
config.fetch("failures_days") { 3 },
)
end
def notifier_channel_for(notification)
channels.fetch(notification.channel.to_sym)
end
def should_notify_for_recurrent_failures?(_notification)
true
# TODO: dependent of logs consecutive failures
end
end
end

View file

@ -0,0 +1,31 @@
module Notifier
class Resolver
def resolve_expires_soon
scope
.where("checks.domain_expires_at >= CURDATE()")
.where("DATE(checks.domain_expires_at)
<= DATE_ADD(CURDATE(), INTERVAL notifications.delay DAY)")
end
def resolve_check_failed
# Only gets here the checks having its last run in error
# Logical rules are in plain ruby inside processor
scope
.includes(check: :logs)
.where("checks.last_success_at <= DATE_SUB(checks.last_run_at, INTERVAL 5 MINUTE)")
end
private
def scope
Notification
.includes(:check)
.where(status: [:pending, :failed], checks: { active: true })
.where.not(checks: { user: ignore_users })
end
def ignore_users
@ignore_users ||= User.notifications_disabled.pluck(:id)
end
end
end

View file

@ -34,6 +34,7 @@ class SystemCommand
private
# :nocov:
def call(cmd)
pid, _, stdout, stderr = Open4.popen4 cmd
_, status = Process.waitpid2 pid
@ -45,6 +46,7 @@ class SystemCommand
stderr.read.strip,
)
end
# :nocov:
def escape_arg(arg)
arg.to_s.gsub('"') { '\"' }

View file

@ -12,5 +12,18 @@
<%= f.input :active %>
<% end %>
<%= f.button :submit, "Validate", class: "btn-primary" %>
<h2 class="mt-5"><%= t(".notifications") %></h2>
<p class="alert alert-light"><%= t(".notifications_hint") %></p>
<%- check.notifications.each_with_index do |notification, index| %>
<div data-notification-id="<%= notification.id %>">
<%= f.fields_for :notifications, notification do |nf| %>
<%= render "notifications/nested_form_headers", f: nf if index.zero? %>
<%= render "notifications/nested_form", f: nf, check: check %>
<% end %>
</div>
<% end %>
<%= f.button :submit, class: "btn-primary mt-5" %>
<% end %>

View file

@ -4,7 +4,7 @@
<div>Kind: <%= check.kind %></div>
<div>Created date: <%= l(check.domain_created_at.to_date) if check.domain_created_at.present? %></div>
<div>Update date: <%= l(check.domain_updated_at.to_date) if check.domain_updated_at.present? %></div>
<div>Expire date: <%= l(check.domain_expire_at.to_date) if check.domain_expire_at.present? %></div>
<div>Expire date: <%= l(check.domain_expires_at.to_date) if check.domain_expires_at.present? %></div>
<% if check.comment? %>
<div>Comment: <%= check.comment %></div>

View file

@ -0,0 +1,29 @@
<fieldset class="form-group">
<div class="form-row">
<%- if many_channels_available? %>
<div class="form-group col-md-2">
<%- if f.object.new_record? -%>
<%= f.input :channel, collection: Notification.channels.keys, label: false %>
<% else -%>
<%= f.input_field :channel, as: :string, readonly: true, class: "form-control-plaintext" %>
<%- end %>
</div>
<% end %>
<div class="form-group <%= recipient_col_class %>">
<%= f.input :recipient, as: :email, label: false %>
</div>
<div class="form-group col-md-2">
<%= f.input :delay, as: :integer, label: false %>
</div>
<div class="form-group col-md-1">
<% if f.object.persisted? %>
<%= link_to check_notification_path(check, f.object), method: :delete, remote: true, class: "btn btn-danger" do %>
<%== Octicons::Octicon.new("x", width: 15, height: 20).to_svg %>
<% end %>
<% end %>
</div>
</div>
</fieldset>

View file

@ -0,0 +1,15 @@
<div class="form-row">
<%- if many_channels_available? %>
<div class="col-md-2">
<%= f.label :channel %>
</div>
<% end %>
<div class="<%= recipient_col_class %>">
<%= f.label :recipient %>
</div>
<div class="col-md-2">
<%= f.label :delay %>
</div>
</div>

View file

@ -0,0 +1 @@
document.querySelector("[data-notification-id='<%= @notification.id %>']").remove();

View file

@ -0,0 +1,13 @@
<%- if @check.comment.present? -%>
<p>
You wrote the following comment with this domain:
<blockquote>
<%= @check.comment -%>
</blockquote>
</p>
<%- end -%>
<%- if @check.vendor.present? -%>
Vendor: <%= @check.vendor %>
<% end %>

View file

@ -0,0 +1,11 @@
<%- if @check.comment.present? -%>
You wrote the following comment with this domain:
<%= @check.comment -%>
<%- end -%>
<%- if @check.vendor.present? -%>
Vendor: <%= @check.vendor %>
<% end %>

View file

@ -0,0 +1,11 @@
<br />
<br />
--
<p>
You received this email because of the notification <%= delay %> days before the expiry date.<br />
You can handle the notifications for this check by following this link:<br />
<%= link_to nil, edit_check_url(check) %>
</p>
<p>The Chexpire Team</p>

View file

@ -0,0 +1,7 @@
--
You received this email because of the notification <%= delay %> days before the expiry date.
You can handle the notifications for this check by following this link:
<%= edit_check_url(check) %>
The Chexpire Team

View file

@ -0,0 +1,11 @@
<br />
<br />
--
<p>You received this email because of the notification <%= delay %> days before
the last known expiry date.<br />
You can handle the check by following this link: <br />
<%= link_to nil, edit_check_url(check) %>
</p>
<p>The Chexpire Team</p>

View file

@ -0,0 +1,8 @@
--
You received this email because of the notification <%= delay %> days before
the last known expiry date.
You can handle the check by following this link:
<%= edit_check_url(check) %>
The Chexpire Team

View file

@ -0,0 +1,12 @@
<p>
Hi,
<br />
<br />
the domain <strong><%= @check.domain %></strong> will expire
<strong><%= format_utc(@check.domain_expires_at) %></strong>.
</p>
<br />
<%= render "check_comment_vendor" %>
<%= render "footer_expires_soon", delay: @notification.delay, check: @check %>

View file

@ -0,0 +1,9 @@
Hi,
the domain <%= @check.domain %> will expire <%= format_utc(@check.domain_expires_at) %>.
<%= render "check_comment_vendor" %>
<%= render "footer_expires_soon", delay: @notification.delay, check: @check %>

View file

@ -0,0 +1,25 @@
<p>
Hi,
<br />
<br />
We had <strong>recurrent failures</strong> while checking the whois database for domain
<strong><%= @check.domain %></strong>. As of today, we can't anymore verify the expiry date.
</p>
<p>
Our last known expiry date is <%= format_utc(@check.domain_expires_at) %>.
<br />
Our last successful check occured <%= format_utc(@check.last_success_at) %>.
</p>
<p>
If you have deleted the domain or have not renewed it, please disable
or delete the check by following this link: <br /><br />
<%= link_to nil, edit_check_url(@check) %>
</p>
<br />
<%= render "check_comment_vendor" %>
<%= render "footer_recurrent_failures", delay: @notification.delay, check: @check %>

View file

@ -0,0 +1,28 @@
Hi,
We had recurrent failures while checking the whois database for domain
<%= @check.domain %>. As of today, we can't anymore verify the expiry date.
The last known expiry date is <%= format_utc(@check.domain_expires_at) %>.
The last successful check occured <%= format_utc(@check.last_success_at) %>.
If you have deleted your domain or have not renewed it, please disable
or delete the check by following this link:
<%= edit_check_url(@check) %>
<%- if @check.comment.present? -%>
You wrote the following comment with this domain:
<%= @check.comment -%>
<%- end -%>
<%- if @check.vendor.present? -%>
Vendor: <%= @check.vendor %>
<% end %>
<%= render "footer_recurrent_failures", delay: @notification.delay, check: @check %>

View file

@ -1,5 +1,8 @@
default: &default
mailer_default_from: "from@example.org"
notifier:
interval: 0.00
failure_days: 3
development:
<<: *default

View file

@ -1,3 +1,6 @@
test:
mailer_default_from: "contact@chexpire.org"
host: "localhost"
notifier:
interval: 0.00
failure_days: 3

View file

@ -67,6 +67,4 @@ Rails.application.configure do
# Use an evented file watcher to asynchronously detect changes in source code,
# routes, locales, etc. This feature depends on the listen gem.
config.file_watcher = ActiveSupport::EventedFileUpdateChecker
config.active_job.queue_adapter = :inline
end

View file

@ -18,7 +18,7 @@ Rails.application.config.content_security_policy do |policy|
end
# If you are using UJS then enable automatic nonce generation
# Rails.application.config.content_security_policy_nonce_generator = -> request { SecureRandom.base64(16) }
Rails.application.config.content_security_policy_nonce_generator = -> request { SecureRandom.base64(16) }
# Report CSP violations to a specified URI
# For further information see the following documentation:

View file

@ -17,8 +17,25 @@ en:
new:
tos_acceptance_html: "You must accept our Terms of service"
simple_form:
placeholders:
notifications:
recipient: john@example.org
flashes:
user_not_authorized: "You are not authorized to access to this resource."
notifications_mailer:
domain_expires_soon:
subject:
zero: "Domain %{domain} expires TODAY !"
one: "Domain %{domain} expires TOMORROW !"
other: "Domain %{domain} expires in %{count} days"
domain_recurrent_failures:
subject: "Recurrent failures in %{domain} domain expiry check"
shared:
navbar:
sign_up: "Sign up"
@ -32,3 +49,7 @@ en:
You have not set up a check yet.
Please add a <a href="%{new_domain_path}">domain</a>
or a <a href="%{new_ssl_path}">ssl</a> !
form:
notifications_hint: |
Receive notifications to warn you when our system detects that the
expiration date is coming. The delay is set in number of days.

View file

@ -1,6 +1,7 @@
# == Route Map
#
# Prefix Verb URI Pattern Controller#Action
# check_notification DELETE /checks/:check_id/notifications/:id(.:format) notifications#destroy
# checks GET /checks(.:format) checks#index
# POST /checks(.:format) checks#create
# new_check GET /checks/new(.:format) checks#new
@ -46,7 +47,9 @@
Rails.application.routes.draw do
# For details on the DSL available within this file, see http://guides.rubyonrails.org/routing.html
resources :checks, except: [:show]
resources :checks, except: [:show] do
resources :notifications, only: [:destroy]
end
devise_for :users
root to: "pages#home"

View file

@ -0,0 +1,14 @@
class CreateNotifications < ActiveRecord::Migration[5.2]
def change
create_table :notifications do |t|
t.references :check, foreign_key: true
t.integer :channel, null: false, default: 0
t.string :recipient, null: false
t.integer :delay, null: false
t.integer :status, null: false, default: 0
t.datetime :sent_at
t.timestamps
end
end
end

View file

@ -0,0 +1,5 @@
class RenameChecksDomainExpireAtToDomainExpiresAt < ActiveRecord::Migration[5.2]
def change
rename_column :checks, :domain_expire_at, :domain_expires_at
end
end

View file

@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema.define(version: 2018_05_30_123611) do
ActiveRecord::Schema.define(version: 2018_06_02_154319) do
create_table "check_logs", options: "ENGINE=InnoDB DEFAULT CHARSET=utf8", force: :cascade do |t|
t.bigint "check_id"
@ -30,7 +30,7 @@ ActiveRecord::Schema.define(version: 2018_05_30_123611) do
t.string "domain", null: false
t.datetime "domain_created_at"
t.datetime "domain_updated_at"
t.datetime "domain_expire_at"
t.datetime "domain_expires_at"
t.datetime "last_run_at"
t.datetime "last_success_at"
t.string "vendor"
@ -41,6 +41,18 @@ ActiveRecord::Schema.define(version: 2018_05_30_123611) do
t.index ["user_id"], name: "index_checks_on_user_id"
end
create_table "notifications", options: "ENGINE=InnoDB DEFAULT CHARSET=utf8", force: :cascade do |t|
t.bigint "check_id"
t.integer "channel", default: 0, null: false
t.string "recipient", null: false
t.integer "delay", null: false
t.integer "status", default: 0, null: false
t.datetime "sent_at"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["check_id"], name: "index_notifications_on_check_id"
end
create_table "users", options: "ENGINE=InnoDB DEFAULT CHARSET=utf8", force: :cascade do |t|
t.string "email", default: "", null: false
t.string "encrypted_password", default: "", null: false
@ -67,4 +79,5 @@ ActiveRecord::Schema.define(version: 2018_05_30_123611) do
add_foreign_key "check_logs", "checks"
add_foreign_key "checks", "users"
add_foreign_key "notifications", "checks"
end

View file

@ -1,7 +1,57 @@
# This file should contain all the record creation needed to seed the database with its default values.
# The data can then be loaded with the rails db:seed command (or created alongside the database with db:setup).
#
# Examples:
#
# movies = Movie.create([{ name: 'Star Wars' }, { name: 'Lord of the Rings' }])
# Character.create(name: 'Luke', movie: movies.first)
Notification.destroy_all
Check.destroy_all
User.destroy_all
user1 = User.create!(
email: "colin@example.org",
password: "password",
tos_accepted: true,
confirmed_at: Time.now
)
check_chexpire_org = Check.create!(
user: user1,
kind: :domain,
domain: "chexpire.org",
domain_expires_at: 1.week.from_now,
domain_updated_at: 6.months.ago,
domain_created_at: Time.new(2016, 8, 4, 12, 15, 1),
comment: "The date are fake, this is a seed !",
vendor: "Some random registrar",
)
check_chexpire_org_error = Check.create!(
user: user1,
kind: :domain,
domain: "chexpire.org",
domain_expires_at: 1.week.from_now,
domain_updated_at: 6.months.ago,
domain_created_at: Time.new(2016, 8, 4, 12, 15, 1),
comment: "The date are fake, this is a seed !",
vendor: "Some random registrar",
last_run_at: 20.minutes.ago,
last_success_at: 4.days.ago,
)
Notification.create!(
check: check_chexpire_org,
delay: 15,
channel: :email,
recipient: "colin@example.org",
status: :pending,
)
Notification.create!(
check: check_chexpire_org_error,
delay: 15,
channel: :email,
recipient: "colin@example.org",
status: :pending,
)
puts "\e[0;32mDone 👌\e[0m"
puts " "
puts "--------------------"
puts "Users: #{User.count}"
puts "Checks: #{Check.count}"
puts "Notifications: #{Notification.count}"

1
lib/errors.rb Normal file
View file

@ -0,0 +1 @@
class SystemCommandNotAllowedError < StandardError; end

View file

@ -0,0 +1,13 @@
namespace :factory_bot do
desc "Verify that all FactoryBot factories are valid"
task lint: :environment do
if Rails.env.test?
DatabaseCleaner.cleaning do
FactoryBot.lint
end
else
system("bundle exec rake factory_bot:lint RAILS_ENV='test'")
fail if $CHILD_STATUS.exitstatus.nonzero?
end
end
end

View file

@ -2,4 +2,9 @@ require "test_helper"
class ApplicationSystemTestCase < ActionDispatch::SystemTestCase
driven_by :headless_chrome
def teardown
Capybara.reset_sessions!
Warden.test_reset!
end
end

View file

@ -0,0 +1,23 @@
module ChexpireAssertions
def assert_just_now(expected)
assert_in_delta expected.to_i, Time.now.to_i, 1.0
end
def assert_permit(user, record, action)
msg = "User #{user.inspect} should be permitted to #{action} #{record}, but isn't permitted"
assert policy_permit(user, record, action), msg
end
def refute_permit(user, record, action)
msg = "User #{user.inspect} should NOT be permitted to #{action} #{record}, but is permitted"
refute policy_permit(user, record, action), msg
end
private
def policy_permit(user, record, action)
test_name = self.class.ancestors.select { |a| a.to_s.match(/PolicyTest/) }.first
klass = test_name.to_s.gsub(/Test/, "")
klass.constantize.new(user, record).public_send("#{action}?")
end
end

View file

@ -0,0 +1,7 @@
require "test_helper"
class NotificationsControllerTest < ActionDispatch::IntegrationTest
# test "the truth" do
# assert true
# end
end

View file

@ -0,0 +1,7 @@
inherit_from: ../../.rubocop.yml
Style/BlockDelimiters:
EnforcedStyle: line_count_based
Metrics/BlockLength:
Enabled: false

View file

@ -21,4 +21,12 @@
# fk_rails_... (check_id => checks.id)
#
# Read about fixtures at http://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
FactoryBot.define do
factory :check_log do
check
status :pending
exit_status nil
parsed_response nil
raw_response nil
end
end

77
test/factories/checks.rb Normal file
View file

@ -0,0 +1,77 @@
# == Schema Information
#
# Table name: checks
#
# id :bigint(8) not null, primary key
# active :boolean default(TRUE), not null
# comment :string(255)
# domain :string(255) not null
# domain_created_at :datetime
# domain_expires_at :datetime
# domain_updated_at :datetime
# kind :integer not null
# last_run_at :datetime
# last_success_at :datetime
# vendor :string(255)
# created_at :datetime not null
# updated_at :datetime not null
# user_id :bigint(8)
#
# Indexes
#
# index_checks_on_user_id (user_id)
#
# Foreign Keys
#
# fk_rails_... (user_id => users.id)
#
FactoryBot.define do
factory :check do
user
kind :domain
domain "domain.fr"
domain_created_at Time.new(2016, 4, 1, 12, 0, 0, "+02:00")
domain_updated_at Time.new(2017, 3, 1, 12, 0, 0, "+02:00")
domain_expires_at Time.new(2019, 4, 1, 12, 0, 0, "+02:00")
active true
vendor nil
comment nil
last_run_at nil
last_success_at nil
trait :domain do
kind :domain
end
trait :nil_dates do
domain_created_at nil
domain_updated_at nil
domain_expires_at nil
end
trait :expires_next_week do
domain_expires_at 1.week.from_now
end
trait :last_runs_failed do
last_run_at Time.now - 90.minutes
last_success_at 1.week.ago - 2.hours
end
trait :last_run_succeed do
last_run_at 1.hour.ago
last_success_at 1.hour.ago
end
trait :inactive do
active false
end
trait :with_notifications do
after :create do |check|
create_list :notification, 2, check: check
end
end
end
end

View file

@ -0,0 +1,50 @@
# == Schema Information
#
# Table name: notifications
#
# id :bigint(8) not null, primary key
# channel :integer default("email"), not null
# delay :integer not null
# recipient :string(255) not null
# sent_at :datetime
# status :integer default("pending"), not null
# created_at :datetime not null
# updated_at :datetime not null
# check_id :bigint(8)
#
# Indexes
#
# index_notifications_on_check_id (check_id)
#
# Foreign Keys
#
# fk_rails_... (check_id => checks.id)
#
FactoryBot.define do
factory :notification do
check
delay 30
channel :email
recipient "recipient@domain.fr"
status :pending
sent_at nil
trait :email do
channel :email
end
trait :ongoing do
status :ongoing
end
trait :succeed do
status :succeed
sent_at { 1.day.ago }
end
trait :failed do
status :failed
end
end
end

View file

@ -28,15 +28,14 @@
# index_users_on_email (email) UNIQUE
# index_users_on_reset_password_token (reset_password_token) UNIQUE
#
require "securerandom"
# Read about fixtures at http://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
# This model initially had no columns defined. If you add columns to the
# model remove the '{}' from the fixture names and add the columns immediately
# below each fixture, per the syntax in the comments below
#
user1:
email: user@chexpire.org
encrypted_password: <%= User.new.send(:password_digest, 'password') %>
confirmed_at: <%= 1.minute.ago %>
tos_accepted: true
FactoryBot.define do
factory :user do
sequence(:email) { |n| "user-#{n}@chexpire.org" }
password "password"
confirmed_at Time.new(2018, 4, 1, 12, 0, 0, "+02:00")
notifications_enabled true
tos_accepted true
end
end

View file

@ -1,55 +0,0 @@
# == Schema Information
#
# Table name: checks
#
# id :bigint(8) not null, primary key
# active :boolean default(TRUE), not null
# comment :string(255)
# domain :string(255) not null
# domain_created_at :datetime
# domain_expire_at :datetime
# domain_updated_at :datetime
# kind :integer not null
# last_run_at :datetime
# last_success_at :datetime
# vendor :string(255)
# created_at :datetime not null
# updated_at :datetime not null
# user_id :bigint(8)
#
# Indexes
#
# index_checks_on_user_id (user_id)
#
# Foreign Keys
#
# fk_rails_... (user_id => users.id)
#
# Read about fixtures at http://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
domain_example_org:
user: user1
kind: domain
domain: example.org
domain_created_at: 2017-03-01 17:29:50
domain_updated_at: 2018-02-15 12:10:00
domain_expire_at: 2019-03-01 17:29:49
last_run_at: 2018-05-24 17:29:50
last_success_at: 2018-05-24 17:29:50
vendor: ""
comment: ""
active: true
ssl_www_example_org:
user: user1
kind: ssl
domain: www.example.org
domain_created_at: 2018-05-24 17:29:50
domain_updated_at: 2018-05-24 17:29:50
domain_expire_at: 2019-05-24 17:29:49
last_run_at: 2018-05-24 17:29:50
last_success_at: 2018-05-24 17:29:50
vendor: ""
comment: "MyString"
active: true

View file

@ -1,7 +1,58 @@
require "test_helper"
class WhoisSyncJobTest < ActiveJob::TestCase
# test "the truth" do
# assert true
# end
test "calls whois database and update check with the response (domain.fr)" do
domain = "domain.fr"
check = create(:check, :nil_dates, domain: domain)
mock_system_command("whois", domain, stdout: whois_response(domain)) do
WhoisSyncJob.new.perform(check.id)
end
check.reload
assert_just_now check.last_run_at
assert_just_now check.last_success_at
assert_equal Time.new(2019, 2, 17, 0, 0, 0, 0), check.domain_expires_at
assert_equal Time.new(2017, 1, 28, 0, 0, 0, 0), check.domain_updated_at
assert_equal Time.new(2004, 2, 18, 0, 0, 0, 0), check.domain_created_at
assert check.active?
end
test "ignore invalid response (domain.fr)" do
check = create(:check, :nil_dates, domain: "domain.fr")
original_updated_at = check.updated_at
mock_system_command("whois", "domain.fr", stdout: "not a response") do
WhoisSyncJob.new.perform(check.id)
end
check.reload
assert_just_now check.last_run_at
assert_nil check.last_success_at
assert_equal original_updated_at, check.updated_at
assert check.active?
end
test "Disable check when whois responds domain not found" do
domain = "willneverexist.fr"
check = create(:check, :nil_dates, domain: domain)
mock_system_command("whois", domain, stdout: whois_response(domain)) do
WhoisSyncJob.new.perform(check.id)
end
check.reload
refute check.active?
assert_just_now check.last_run_at
assert_nil check.last_success_at
end
private
def whois_response(domain)
file_fixture("whois/#{domain}.txt").read
end
end

View file

@ -0,0 +1,78 @@
require "test_helper"
class NotificationsMailerTest < ActionMailer::TestCase
test "domain_expires_soon" do
check = create(:check, domain_expires_at: Time.new(2018, 6, 10, 12, 0, 5, "+02:00"))
notification = build(:notification, delay: 10, check: check, recipient: "colin@example.org")
Date.stub :today, Date.new(2018, 6, 2) do
mail = NotificationsMailer.with(notification: notification).domain_expires_soon
assert_emails 1 do
mail.deliver_now
end
assert_match "domain.fr", mail.subject
assert_match "in 8 days", mail.subject
assert_equal ["colin@example.org"], mail.to
assert_equal [Rails.configuration.chexpire.fetch("mailer_default_from")], mail.from
parts = [mail.text_part.body.to_s, mail.html_part.to_s]
parts.each do |part|
assert_match "domain.fr", part
assert_match "Sun, 10 Jun 2018 10:00:05 +0000", part
assert_match "10 days", part
assert_match "/checks/#{check.id}/edit", part
assert_no_match "comment", part
assert_no_match "vendor", part
end
end
end
test "domain_expires_soon include comment & vendor" do
check = create(:check,
domain_expires_at: 1.week.from_now,
comment: "My comment",
vendor: "The vendor")
notification = build(:notification, check: check)
mail = NotificationsMailer.with(notification: notification).domain_expires_soon
parts = [mail.text_part.body.to_s, mail.html_part.to_s]
parts.each do |part|
assert_match "My comment", part
assert_match "The vendor", part
end
end
test "domain_recurrent_failures" do
last_success_at = Time.new(2018, 5, 30, 6, 10, 0, "+00:00")
domain_expires_at = Time.new(2018, 10, 10, 7, 20, 0, "+04:00")
check = build(:check, :last_runs_failed,
domain: "invalid-domain.fr",
last_success_at: last_success_at,
domain_expires_at: domain_expires_at,
comment: "My comment")
notification = create(:notification, check: check)
mail = NotificationsMailer.with(notification: notification).domain_recurrent_failures
assert_match "failures", mail.subject
assert_match "invalid-domain.fr", mail.subject
assert_equal ["recipient@domain.fr"], mail.to
assert_equal [Rails.configuration.chexpire.fetch("mailer_default_from")], mail.from
parts = [mail.text_part.body.to_s, mail.html_part.to_s]
parts.each do |part|
assert_match "invalid-domain.fr", part
assert_match "recurrent failures", part
assert_match(/success[a-z ]+ Wed, 30 May 2018 06:10:00 \+0000/, part)
assert_match(/expiry[a-z ]+ Wed, 10 Oct 2018 03:20:00 \+0000/, part)
assert_match "My comment", part
assert_match "/checks/#{check.id}/edit", part
end
end
end

View file

@ -0,0 +1,13 @@
# Preview all emails at http://localhost:3000/rails/mailers/notifications_mailer
class NotificationsMailerPreview < ActionMailer::Preview
# Preview this email at http://localhost:3000/rails/mailers/notifications_mailer/domain_expires_soon
def domain_expires_soon
NotificationsMailer.with(notification: Notification.first).domain_expires_soon
end
# Preview this email at http://localhost:3000/rails/mailers/notifications_mailer/domain_recurrent_failures
def domain_recurrent_failures
check = Check.where("last_run_at != last_success_at").limit(1).first
NotificationsMailer.with(notification: check.notifications.first).domain_recurrent_failures
end
end

View file

@ -7,7 +7,7 @@
# comment :string(255)
# domain :string(255) not null
# domain_created_at :datetime
# domain_expire_at :datetime
# domain_expires_at :datetime
# domain_updated_at :datetime
# kind :integer not null
# last_run_at :datetime
@ -29,7 +29,24 @@
require "test_helper"
class CheckTest < ActiveSupport::TestCase
# test "the truth" do
# assert true
# end
test "notifications are resetted when domain expiration date has changed" do
check = create(:check)
notification = create(:notification, :succeed, check: check)
check.comment = "Will not reset because of this attribute"
check.save!
notification.reload
assert notification.succeed?
assert_not_nil notification.sent_at
check.domain_expires_at = 1.year.from_now
check.save!
notification.reload
assert notification.pending?
assert_nil notification.sent_at
end
end

View file

@ -0,0 +1,30 @@
# == Schema Information
#
# Table name: notifications
#
# id :bigint(8) not null, primary key
# channel :integer default("email"), not null
# delay :integer not null
# recipient :string(255) not null
# sent_at :datetime
# status :integer default("pending"), not null
# created_at :datetime not null
# updated_at :datetime not null
# check_id :bigint(8)
#
# Indexes
#
# index_notifications_on_check_id (check_id)
#
# Foreign Keys
#
# fk_rails_... (check_id => checks.id)
#
require "test_helper"
class NotificationTest < ActiveSupport::TestCase
# test "the truth" do
# assert true
# end
end

View file

@ -1,13 +1,40 @@
require "test_helper"
class CheckPolicyTest < ActiveSupport::TestCase
def test_scope; end
setup do
@owner, @other = create_list(:user, 2)
@check = create(:check, user: @owner)
end
def test_show; end
test "create" do
assert_permit @other, Check, :create
assert_permit @other, Check, :new
end
def test_create; end
test "check owner" do
assert_permit @owner, @check, :update
assert_permit @owner, @check, :edit
assert_permit @owner, @check, :destroy
assert_permit @owner, @check, :show
end
def test_update; end
test "anonymous and other user" do
refute_permit @other, @check, :update
refute_permit @other, @check, :edit
refute_permit @other, @check, :destroy
refute_permit @other, @check, :show
def test_destroy; end
refute_permit nil, @check, :update
refute_permit nil, @check, :edit
refute_permit nil, @check, :destroy
refute_permit nil, @check, :show
end
test "scope only to owner" do
others = create_list(:check, 2, user: @other)
assert_empty Pundit.policy_scope!(nil, Check)
assert_equal [@check], Pundit.policy_scope!(@owner, Check)
assert_equal others, Pundit.policy_scope!(@other, Check)
end
end

View file

@ -0,0 +1,32 @@
require "test_helper"
class NotificationPolicyTest < ActiveSupport::TestCase
setup do
@owner, @other = create_list(:user, 2)
@notification = create(:notification, check: build(:check, user: @owner))
end
test "permit to check user" do
assert_permit @owner, @notification, :destroy
end
test "disallow to anonymous and other user" do
refute_permit @other, @notification, :destroy
refute_permit nil, @notification, :destroy
end
test "scope only to user checks" do
other_notifications = create_list(:notification, 2, check: build(:check, user: @other))
assert_empty Pundit.policy_scope!(nil, Notification)
assert_equal [@notification], Pundit.policy_scope!(@owner, Notification)
assert_equal other_notifications, Pundit.policy_scope!(@other, Notification)
end
test "disabled actions" do
refute_permit @owner, @notification, :update
refute_permit @owner, @notification, :edit
refute_permit @owner, @notification, :create
refute_permit @owner, @notification, :index
end
end

View file

@ -4,7 +4,7 @@ require "system_command"
class CheckLoggerTest < ActiveSupport::TestCase
setup do
@check = checks(:domain_example_org)
@check = create(:check)
@logger = CheckLogger.new(@check)
end

View file

@ -0,0 +1,49 @@
require "test_helper"
module Notifier
module Channels
class BaseTest < ActiveSupport::TestCase
setup do
class FakeChannel < Base
def supports?(reason, _notification)
reason != :unsupported
end
def domain_notify_expires_soon(*); end
end
@channel = FakeChannel.new
end
test "#notify change the status of the notification" do
notification = create(:notification)
@channel.notify(:expires_soon, notification)
notification.reload
assert notification.ongoing?
assert_just_now notification.sent_at
end
test "#notify raises an exception for a invalid reason" do
notification = build(:notification)
assert_raises ArgumentError do
@channel.notify(:unknown, notification)
end
end
test "#notify does nothing when channel does not support a reason" do
notification = create(:notification)
@channel.notify(:unsupported, notification)
notification.reload
assert notification.pending?
assert_nil notification.sent_at
end
end
end
end

View file

@ -0,0 +1,54 @@
require "test_helper"
module Notifier
class ProcessorTest < ActiveSupport::TestCase
test "#process_expires_soon sends an email for checks expiring soon" do
create_list(:notification, 3, :email, check: build(:check, :expires_next_week))
create(:notification, :email, check: build(:check, :nil_dates))
create(:notification, :email, check: build(:check, :inactive))
processor = Processor.new
assert_difference "ActionMailer::Base.deliveries.size", +3 do
processor.process_expires_soon
end
last_email = ActionMailer::Base.deliveries.last
assert_match "expires in 7 days", last_email.subject
end
test "#process_expires_soon respects the interval configuration between sends" do
create_list(:notification, 3, :email, check: build(:check, :expires_next_week))
test_interval_respected(:process_expires_soon, 3)
end
test "#process_recurrent_failures respects the interval configuration between sends" do
create_list(:notification, 3, :email, check: build(:check, :last_runs_failed))
test_interval_respected(:process_recurrent_failures, 3)
end
private
# rubocop:disable Metrics/MethodLength
def test_interval_respected(process_method, count_expected)
configuration = Minitest::Mock.new
count_expected.times do
configuration.expect(:interval, 0.000001)
end
processor = Processor.new(configuration)
mock = Minitest::Mock.new
assert_stub = lambda { |actual_time|
assert_equal 0.000001, actual_time
mock
}
processor.stub :sleep, assert_stub do
processor.public_send(process_method)
end
configuration.verify
mock.verify
end
# rubocop:enable Metrics/MethodLength
end
end

View file

@ -0,0 +1,113 @@
require "test_helper"
module Notifier
class ResolverTest < ActiveSupport::TestCase
setup do
@resolver = Notifier::Resolver.new
end
test "#resolve_expires_soon ignores user having notification disabled" do
n1 = create(:notification, check: build(:check, :expires_next_week))
n1.check.user.update_attribute(:notifications_enabled, false)
n2 = create(:notification, check: build(:check, :expires_next_week))
notifications = @resolver.resolve_expires_soon
assert_not_includes notifications, n1
assert_includes notifications, n2
end
test "#resolve_expires_soon ignores inactive checks" do
n1 = create(:notification, check: build(:check, :expires_next_week, :inactive))
n2 = create(:notification, check: build(:check, :expires_next_week))
notifications = @resolver.resolve_expires_soon
assert_not_includes notifications, n1
assert_includes notifications, n2
end
test "#resolve_expires_soon gets only checks inside delay" do
n1 = create(:notification, check: build(:check, :expires_next_week), delay: 6)
n2 = create(:notification, check: build(:check, :expires_next_week), delay: 7)
notifications = @resolver.resolve_expires_soon
assert_not_includes notifications, n1
assert_includes notifications, n2
end
test "#resolve_expires_soon can gets several notifications for a same check" do
check = create(:check, :expires_next_week)
n1 = create(:notification, check: check, delay: 3)
n2 = create(:notification, check: check, delay: 10)
n3 = create(:notification, check: check, delay: 30)
notifications = @resolver.resolve_expires_soon
assert_not_includes notifications, n1
assert_includes notifications, n2
assert_includes notifications, n3
end
test "#resolve_expires_soon takes care of the status" do
check = create(:check, :expires_next_week)
n1 = create(:notification, check: check)
n2 = create(:notification, :failed, check: check)
n3 = create(:notification, :ongoing, check: check)
n4 = create(:notification, :succeed, check: check)
notifications = @resolver.resolve_expires_soon
assert_includes notifications, n1
assert_includes notifications, n2
assert_not_includes notifications, n3
assert_not_includes notifications, n4
end
test "#resolve_expires_soon ignores checks expired and without date" do
n1 = create(:notification, check: build(:check, :expires_next_week))
n2 = create(:notification, check: build(:check, domain_expires_at: 1.week.ago))
n3 = create(:notification, check: build(:check, :nil_dates))
notifications = @resolver.resolve_expires_soon
assert_includes notifications, n1
assert_not_includes notifications, n2
assert_not_includes notifications, n3
end
test "#resolve_check_failed ignores inactive checks" do
n1 = create(:notification, check: build(:check, :last_runs_failed, :inactive))
n2 = create(:notification, check: build(:check, :last_runs_failed))
notifications = @resolver.resolve_check_failed
assert_not_includes notifications, n1
assert_includes notifications, n2
end
test "#resolve_check_failed ignores user having notification disabled" do
n1 = create(:notification, check: build(:check, :last_runs_failed))
n1.check.user.update_attribute(:notifications_enabled, false)
n2 = create(:notification, check: build(:check, :last_runs_failed))
notifications = @resolver.resolve_check_failed
assert_not_includes notifications, n1
assert_includes notifications, n2
end
test "#resolve_check_failed gets only checks having last run in error" do
n1 = create(:notification, check: build(:check, :nil_dates))
n2 = create(:notification, check: build(:check, :last_run_succeed))
n3 = create(:notification, check: build(:check, :last_runs_failed))
notifications = @resolver.resolve_check_failed
assert_not_includes notifications, n1
assert_not_includes notifications, n2
assert_includes notifications, n3
end
end
end

View file

@ -0,0 +1,25 @@
require "test_helper"
require "notifier"
class NotifierTest < ActiveSupport::TestCase
test "#process_all process expirable & failures notifications" do
mock = Minitest::Mock.new
mock.expect(:process_expires_soon, nil)
mock.expect(:process_recurrent_failures, nil)
myconfig = "test config"
assert_stub = lambda { |actual_config|
assert_equal myconfig, actual_config,
"Processor was not initialized with the expected configuration"
mock
}
Notifier::Processor.stub :new, assert_stub do
Notifier.process_all(myconfig)
end
mock.verify
end
end

View file

@ -0,0 +1,85 @@
require "application_system_test_case"
class ChecksTest < ApplicationSystemTestCase
setup do
@user = create(:user)
login_as(@user)
@check = create(:check, :with_notifications, user: @user)
end
test "create a check and a notification" do
visit new_check_path
domain = "domain-test.fr"
fill_in("check[domain]", with: domain)
choose "domain"
recipient = "recipient@example.org"
fill_in("check[notifications_attributes][0][recipient]", with: recipient)
fill_in("check[notifications_attributes][0][delay]", with: 30)
click_button
assert_equal checks_path, page.current_path
assert page.has_css?(".alert-success")
assert page.has_content?(domain)
notification = Notification.last
assert_equal recipient, notification.recipient
assert_equal 30, notification.delay
assert notification.email?
assert notification.pending?
end
test "remove a notification" do
visit edit_check_path(@check)
notification = @check.notifications.first
selector = "[data-notification-id=\"#{notification.id}\"]"
assert_difference "Notification.where(check_id: #{@check.id}).count", -1 do
within selector do
find(".btn-danger").click
end
page.has_no_content?(selector)
end
end
test "update a check" do
visit edit_check_path(@check)
fill_in "check[comment]", with: "My comment"
click_button "Update Check"
assert_equal checks_path, page.current_path
assert page.has_css?(".alert-success")
assert page.has_content?("My comment")
end
test "add a notification" do
visit edit_check_path(@check)
recipient = "recipient2@example.org"
fill_in("check[notifications_attributes][2][recipient]", with: recipient)
fill_in("check[notifications_attributes][2][delay]", with: 55)
assert_difference "Notification.where(check_id: #{@check.id}).count", +1 do
click_button "Update Check"
assert_equal checks_path, page.current_path
end
assert page.has_css?(".alert-success")
notification = Notification.last
assert_equal recipient, notification.recipient
assert_equal 55, notification.delay
assert notification.email?
assert notification.pending?
end
end

View file

@ -1,6 +1,10 @@
require "application_system_test_case"
class UsersTest < ApplicationSystemTestCase
setup do
@user = create(:user)
end
test "an user can signup from the homepage and confirm its account" do
visit root_path
@ -31,28 +35,25 @@ class UsersTest < ApplicationSystemTestCase
end
test "an user can signin from the homepage" do
user = users(:user1)
visit root_path
click_on I18n.t("shared.navbar.sign_in")
fill_in "user[email]", with: user.email
fill_in "user[email]", with: @user.email
fill_in "user[password]", with: "password"
click_button I18n.t("devise.sessions.new.sign_in")
assert_equal root_path, page.current_path
assert page.has_content?(user.email)
assert page.has_content?(@user.email)
end
test "an user can signout from the homepage" do
user = users(:user1)
login_as user
login_as @user
visit root_path
find ".navbar" do
click_on user.email
click_on @user.email
click_on I18n.t("shared.navbar.sign_out")
end
@ -90,12 +91,11 @@ class UsersTest < ApplicationSystemTestCase
end
test "an user can globally disable its notifications" do
user = users(:user1)
login_as user
login_as @user
visit edit_user_registration_path
assert_equal user.email, find_field("user[email]").value
assert_equal @user.email, find_field("user[email]").value
assert find_field("user[notifications_enabled]").value
uncheck "user[notifications_enabled]"
@ -104,7 +104,7 @@ class UsersTest < ApplicationSystemTestCase
click_button I18n.t("devise.registrations.edit.update")
user.reload
refute user.notifications_enabled
@user.reload
refute @user.notifications_enabled
end
end

View file

@ -1,8 +1,22 @@
ENV["RAILS_ENV"] ||= "test"
require "pry"
if !ENV["NO_COVERAGE"] && (ARGV.empty? || ARGV.include?("test/test_helper.rb"))
require "simplecov"
SimpleCov.start "rails" do
add_group "Notifier", "app/services/notifier"
add_group "Whois", "app/services/whois"
add_group "Services", "app/services"
add_group "Policies", "app/policies"
end
end
require_relative "../config/environment"
require "rails/test_help"
require "minitest/mock"
require_relative "test_mocks_helper"
require_relative "chexpire_assertions"
class ActiveSupport::TestCase
# Setup all fixtures in test/fixtures/*.yml for all tests in alphabetical order.
@ -12,8 +26,13 @@ class ActiveSupport::TestCase
Warden.test_mode!
# Add more helper methods to be used by all tests here...
include ActiveJob::TestHelper
include FactoryBot::Syntax::Methods
include TestMocksHelper
include ChexpireAssertions
end
# Capybara configuration
Capybara.register_driver :headless_chrome do |app|
capabilities = Selenium::WebDriver::Remote::Capabilities.chrome(
"chromeOptions" => { args: %w[headless disable-gpu] + ["window-size=1280,800"] },
@ -22,3 +41,21 @@ Capybara.register_driver :headless_chrome do |app|
end
Capybara.save_path = Rails.root.join("tmp/capybara")
Capybara.javascript_driver = :headless_chrome
# Disable Open4 real system calls
require "open4"
require "errors"
module Open4
def popen4(*)
fail SystemCommandNotAllowedError,
"Real Open4 calls are disabled in test env. Use mock_system_command helper instead."
end
alias open4 popen4
alias pfork4 popen4
alias popen4ext popen4
module_function :open4
module_function :popen4
module_function :pfork4
module_function :popen4ext
end

29
test/test_mocks_helper.rb Normal file
View file

@ -0,0 +1,29 @@
require "system_command"
module TestMocksHelper
# rubocop:disable Metrics/MethodLength
def mock_system_command(program, args, exit_status: 0, stdout: "", stderr: "")
syscmd = "#{program} #{Array.wrap(args).join(' ')}"
result = SystemCommandResult.new(syscmd, exit_status, stdout, stderr)
mock = Minitest::Mock.new
mock.expect :execute, result
assert_stub = lambda { |actual_program, actual_args, _logger|
assert_equal program, actual_program,
"SystemCommand was not initialized with the expected program name"
assert_equal args, actual_args,
"SystemCommand was not initialized with the expected arguments"
mock
}
SystemCommand.stub :new, assert_stub do
yield
end
mock.verify
end
# rubocop:enable Metrics/MethodLength
end