diff --git a/README.md b/README.md index 9a6399a..5800ae6 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,66 @@ -# chexpire [![Build Status](https://travis-ci.org/Evolix/chexpire.svg?branch=master)](https://travis-ci.org/Evolix/chexpire) +# Chexpire [![Build Status](https://travis-ci.org/Evolix/chexpire.svg?branch=master)](https://travis-ci.org/Evolix/chexpire) A web application to help check for domain or SSL/TLS certificate expirations. ![Shakespeare quote: « An SSL error has occured and a secure connection to the server cannot be made. »](app/assets/images/shakespeare_quote_ssl.png) + + +## How-To Add a new check kind + +TL;DR: + +- write a job for executing and updating a check instance of your kind and use `CheckLogger` to log important event during a check execution. +- Write a `CheckProcessor` class to perform theses jobs at regular interval. +- For each notification channel, implement the code that notify the user when the date will expires soon or when there are recurrent failures. + + +Start by appending a new kind into the `Check` model at the `enum :kind` line. + +### Write a job and the check execution + +The job takes a `Check` instance as an argument and is responsible for performing the check and updating the following dates fields for the check instance: + + - mandatory: `last_run_at`, immediately when the execution is starting + - mandatory: `domain_expires_at` + - mandatory: `last_success_at`, when no problems have occured during the verification and fields have been updated + - optional: `domain_created_at`, `domain_updated_at`, when the kind of check allows them + +The code architecture is up to you and can vary depending on the check, but most of time you should write a service which will execute something and parse the response. Then you'll return a simple response object which responds to methods used for updating check fields in the job. + +Look at the SSL and Domain implementations for examples. + + +### CheckLogger + +This is not currently required, but you should use the `CheckLogger` class to emit log events at important steps of a check execution (especially when error occurs): this can help in case of problems. A single `CheckLogger` instance is attached to a check execution and fill a `CheckLog` model instance with various contents. Looks into the code to see supported events. + +### CheckProcessor + +A `processor` is responsible for executing checks at regular interval, selectively or not, depending of your needs. +Basically, it performs your job for all relevant checks. + +Create a processor dedicated for your check kind. The class should include the `CheckProcessor` module and respond to the following methods : + +- `configuration_key`: returns the key name of configuration in chexpire configuration file. For instance: `checks_domain` +- `scope`: returns the checks scope used by each resolver. Generally, this should be the base scope defined into the `CheckProcessor` module, filtered by your check kind (ie: `base_scope.your_kind`) +- `resolvers`: returns an array of methods which returns checks to execute. Allows conditional checks depending of various checks criterias such as far known expiry date, failing checks etc… +Looks into the `CheckProcessor` for available resolvers methods or write your own. use `resolve_all` resolver if you want execute all of your check at each execution. +- `process`: must execute or enqueue your job for a given check as argument. Note: because the processor is called into a task, and to take profit of a interval configuration parameter, it's better to execute the job synchronously in this method. + +### Configuration + +Each check kind can have configuration. Looks for other checks configuration for examples, such as `checks_domain` configuration in `config/chexpire.example.yml` file. + +### Schedule a task + +Write a task in `lib/tasks/checks.rake` under the namespaces `checks:sync_dates` and invoke the `sync_dates` method of your processor. Schedule to run it periodically in `config/schedule.rb`, or list the task in the `all` alias which is the default processor scheduler. + +### Notifier + +Finally, you have to write the way the checks will be notified to theirs users. For each notifier channel (email, …) you need to write two notifications : + +- expires_soon +- recurrent_failures + +First, add your checks kinds and these notifications definitions in the base class for notifier: `app/services/notifier/base.rb` : in the notify method, for your new check kind, a specific method will be called in each notifier. For example, in the email channel, a specific mailer action is called for each couple (check kin, notification kind). +Then, in each notifier class, implements the details of this method. If you want to ignore a notification for a given channel, simply write the method and do nothing. diff --git a/app/jobs/ssl_sync_job.rb b/app/jobs/ssl_sync_job.rb new file mode 100644 index 0000000..6c47679 --- /dev/null +++ b/app/jobs/ssl_sync_job.rb @@ -0,0 +1,43 @@ +class SSLSyncJob < ApplicationJob + queue_as :default + + rescue_from StandardError do |exception| + check_logger.log(:standard_error, exception) if check.present? + raise # rubocop:disable Style/SignalException + end + + rescue_from ActiveRecord::RecordNotFound do; end + + # parser error are already logged + rescue_from SSL::Error do; end + + attr_reader :check + + def perform(check_id) + prepare_check(check_id) + + response = SSL.ask(check.domain, logger: check_logger) + return unless response.valid? + + update_from_response(response) + end + + def update_from_response(response) + check.domain_expires_at = response.expire_at + check.last_success_at = Time.now + + check.save! + end + + private + + def prepare_check(check_id) + @check = Check.find(check_id) + check.update_attribute(:last_run_at, Time.now) + end + + # logger is a reserved ActiveJob method + def check_logger + @check_logger ||= CheckLogger.new(check) + end +end diff --git a/app/mailers/notifications_mailer.rb b/app/mailers/notifications_mailer.rb index 814d526..31a4677 100644 --- a/app/mailers/notifications_mailer.rb +++ b/app/mailers/notifications_mailer.rb @@ -19,4 +19,16 @@ class NotificationsMailer < ApplicationMailer subject = t(".subject", domain: @check.domain) mail subject: subject end + + def ssl_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 ssl_recurrent_failures + subject = t(".subject", domain: @check.domain) + mail subject: subject + end end diff --git a/app/services/check_domain_processor.rb b/app/services/check_domain_processor.rb new file mode 100644 index 0000000..183fc03 --- /dev/null +++ b/app/services/check_domain_processor.rb @@ -0,0 +1,26 @@ +class CheckDomainProcessor + include CheckProcessor + + protected + + def configuration_key + "checks_domain" + end + + def resolvers + %i[ + resolve_last_run_failed + resolve_expire_short_term + resolve_expire_long_term + resolve_unknown_expiry + ] + end + + def scope + base_scope.domain + end + + def process(check) + WhoisSyncJob.perform_now(check.id) + end +end diff --git a/app/services/check_processor.rb b/app/services/check_processor.rb index 31737c3..abe087c 100644 --- a/app/services/check_processor.rb +++ b/app/services/check_processor.rb @@ -1,17 +1,12 @@ -class CheckProcessor +module CheckProcessor attr_reader :configuration def initialize(configuration = nil) @configuration = configuration || default_configuration end - def sync_dates # rubocop:disable Metrics/MethodLength - %i[ - resolve_last_run_failed - resolve_expire_short_term - resolve_expire_long_term - resolve_unknown_expiry - ].each do |resolver| + def sync_dates + resolvers.each do |resolver| public_send(resolver).find_each(batch_size: 100).each do |check| process(check) @@ -20,6 +15,12 @@ class CheckProcessor end end + # :nocov: + def resolvers + fail NotImplementedError, "#{self.class.name} did not implemented method #{__callee__}" + end + # :nocov: + def resolve_last_run_failed scope.last_run_failed end @@ -41,25 +42,36 @@ class CheckProcessor scope.where("domain_expires_at IS NULL") end - private + def resolve_all + scope + end - def scope + protected + + def base_scope Check .active .where("last_run_at IS NULL OR last_run_at < DATE_SUB(NOW(), INTERVAL 12 HOUR)") end - def process(check) - case check.kind.to_sym - when :domain - WhoisSyncJob.perform_now(check.id) - else - fail ArgumentError, "Unsupported check kind `#{check.kind}`" - end + # :nocov: + def scope + fail NotImplementedError, "#{self.class.name} did not implemented method #{__callee__}" end + def process(_check) + fail NotImplementedError, "#{self.class.name} did not implemented method #{__callee__}" + end + + def configuration_key + fail NotImplementedError, "#{self.class.name} did not implemented method #{__callee__}" + end + # :nocov: + + private + def default_configuration - config = Rails.configuration.chexpire.fetch("checks", {}) + config = Rails.configuration.chexpire.fetch(configuration_key, {}) OpenStruct.new( interval: config.fetch("interval") { 0.00 }, diff --git a/app/services/check_ssl_processor.rb b/app/services/check_ssl_processor.rb new file mode 100644 index 0000000..f36e7c5 --- /dev/null +++ b/app/services/check_ssl_processor.rb @@ -0,0 +1,23 @@ +class CheckSSLProcessor + include CheckProcessor + + protected + + def configuration_key + "checks_ssl" + end + + def resolvers + %i[ + resolve_all + ] + end + + def scope + base_scope.ssl + end + + def process(check) + SSLSyncJob.perform_now(check.id) + end +end diff --git a/app/services/notifier/channels/base.rb b/app/services/notifier/channels/base.rb index b63a52d..a3e9586 100644 --- a/app/services/notifier/channels/base.rb +++ b/app/services/notifier/channels/base.rb @@ -11,6 +11,10 @@ module Notifier domain_notify_expires_soon(notification) when [:domain, :recurrent_failures] domain_notify_recurrent_failures(notification) + when [:ssl, :expires_soon] + ssl_notify_expires_soon(notification) + when [:ssl, :recurrent_failures] + ssl_notify_recurrent_failures(notification) else fail ArgumentError, "Invalid notification reason `#{reason}` for check kind `#{notification.check.kind}`." @@ -25,15 +29,16 @@ module Notifier "#{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__}" + %i[ + domain_notify_expires_soon + domain_notify_recurrent_failures + ssl_notify_expires_soon + ssl_notify_recurrent_failures + ].each do |method| + define_method(method) do + fail NotImplementedError, + "Channel #{self.class.name} does not implement method #{method}." + end end # :nocov: end diff --git a/app/services/notifier/channels/email.rb b/app/services/notifier/channels/email.rb index 17bbdab..9243b7d 100644 --- a/app/services/notifier/channels/email.rb +++ b/app/services/notifier/channels/email.rb @@ -16,6 +16,14 @@ module Notifier def domain_notify_recurrent_failures(notification) NotificationsMailer.with(notification: notification).domain_recurrent_failures.deliver_now end + + def ssl_notify_expires_soon(notification) + NotificationsMailer.with(notification: notification).ssl_expires_soon.deliver_now + end + + def ssl_notify_recurrent_failures(notification) + NotificationsMailer.with(notification: notification).ssl_recurrent_failures.deliver_now + end end end end diff --git a/app/services/ssl.rb b/app/services/ssl.rb new file mode 100644 index 0000000..95af40f --- /dev/null +++ b/app/services/ssl.rb @@ -0,0 +1,66 @@ +require "null_logger" +require "system_command" +require_relative "ssl/parser" +require_relative "ssl/response" +require_relative "ssl/errors" + +module SSL + class << self + def ask(domain, system_klass: SystemCommand, logger: NullLogger.new) + Service.new(domain, system_klass: system_klass, logger: logger).call + end + end + + class Service + attr_reader :domain + attr_reader :logger + attr_reader :system_klass + attr_reader :configuration + + def initialize(domain, system_klass: SystemCommand, configuration: nil, logger: NullLogger.new) + @domain = domain + @logger = logger + @system_klass = system_klass + @configuration = configuration || default_configuration + end + + def call + result = run_command + parse(result) + rescue StandardError => ex + logger.log :service_error, ex + raise + end + + def run_command + command = system_klass.new(check_http_path, check_http_args, logger: logger) + result = command.execute + + unless result.exit_status.zero? + fail SSLCommandError, "SSL command failed with status #{result.exit_status}" + end + + result + end + + def parse(result) + parser = Parser.new(domain, logger: logger) + parser.parse(result.stdout) + end + + def check_http_path + configuration.check_http_path.presence || "check_http" + end + + def check_http_args + [ + configuration.check_http_args.presence, + "-H '#{domain}'", + ].compact + end + + def default_configuration + OpenStruct.new(Rails.configuration.chexpire.fetch("checks_ssl") { {} }) + end + end +end diff --git a/app/services/ssl/errors.rb b/app/services/ssl/errors.rb new file mode 100644 index 0000000..6982c62 --- /dev/null +++ b/app/services/ssl/errors.rb @@ -0,0 +1,10 @@ +module SSL + class Error < StandardError; end + + class SSLCommandError < Error; end + + class ParserError < Error; end + class DomainNotMatchError < ParserError; end + class InvalidResponseError < ParserError; end + class InvalidDateError < ParserError; end +end diff --git a/app/services/ssl/parser.rb b/app/services/ssl/parser.rb new file mode 100644 index 0000000..68dffe8 --- /dev/null +++ b/app/services/ssl/parser.rb @@ -0,0 +1,52 @@ +require "null_logger" +require "ssl/errors" + +module SSL + class Parser + DATE_REGEX = /will expire on (.+)\./ + # Several date formats possible: + # OK - Certificate 'domain.net' will expire on Sat 10 Jun 2028 09:14:18 AM GMT +0000. + # OK - Certificate 'domain.net' will expire on 2018-08-06 02:57 +0200/CEST. + + attr_reader :logger + attr_reader :domain + + def initialize(domain, logger: NullLogger.new) + @logger = logger + @domain = domain + end + + def parse(raw) + fail DomainNotMatchError unless match_domain?(raw) + + match = raw.match(DATE_REGEX) + + fail InvalidResponseError unless match.present? + + response = build_response(match) + + logger.log :parsed_response, response + + response + rescue ParserError => ex + logger.log :parser_error, ex + raise + end + + def match_domain?(raw) + raw.match(/\b#{domain}\b/).present? + end + + def build_response(match) + Response.new(domain).tap do |response| + response.expire_at = parse_datetime(match[1]) + end + end + + def parse_datetime(date_str) + Time.parse(date_str).utc + rescue StandardError => ex + raise InvalidDateError, ex.message + end + end +end diff --git a/app/services/ssl/response.rb b/app/services/ssl/response.rb new file mode 100644 index 0000000..762c498 --- /dev/null +++ b/app/services/ssl/response.rb @@ -0,0 +1,13 @@ +module SSL + class Response + attr_accessor :expire_at + + def initialize(domain) + @domain = domain + end + + def valid? + expire_at.present? + end + end +end diff --git a/app/views/notifications_mailer/_footer_expires_soon.fr.html.erb b/app/views/notifications_mailer/_footer_expires_soon.fr.html.erb index d6f13b9..2389402 100644 --- a/app/views/notifications_mailer/_footer_expires_soon.fr.html.erb +++ b/app/views/notifications_mailer/_footer_expires_soon.fr.html.erb @@ -3,7 +3,7 @@ --

- Vous avez reçu ce courriel à <%= pluralize(delay, "jour", "jours") %> avant la date d'expiration.
+ Vous avez reçu ce courriel à <%= pluralize(interval, "jour", "jours") %> avant la date d'expiration.
Vous pouvez gérer les notifications pour cette vérification à ce adresse : <%= link_to nil, edit_check_url(check) %>

diff --git a/app/views/notifications_mailer/_footer_expires_soon.fr.text.erb b/app/views/notifications_mailer/_footer_expires_soon.fr.text.erb index 35d29f2..6c3880b 100644 --- a/app/views/notifications_mailer/_footer_expires_soon.fr.text.erb +++ b/app/views/notifications_mailer/_footer_expires_soon.fr.text.erb @@ -1,6 +1,6 @@ -- -Vous avez reçu ce courriel à <%= pluralize(delay, "jour", "jours") %> avant +Vous avez reçu ce courriel à <%= pluralize(interval, "jour", "jours") %> avant la date d'expiration. Vous pouvez gérer les notifications pour cette vérification à ce adresse : <%= edit_check_url(check) %> diff --git a/app/views/notifications_mailer/_footer_recurrent_failures.fr.html.erb b/app/views/notifications_mailer/_footer_recurrent_failures.fr.html.erb index 8d68f9d..bb35480 100644 --- a/app/views/notifications_mailer/_footer_recurrent_failures.fr.html.erb +++ b/app/views/notifications_mailer/_footer_recurrent_failures.fr.html.erb @@ -2,7 +2,7 @@
-- -

Vous avez reçu ce courriel à <%= pluralize(delay, "jour", "jours") %> avant la dernière date d'expiration connue.
+

Vous avez reçu ce courriel à <%= pluralize(interval, "jour", "jours") %> avant la dernière date d'expiration connue.
Vous pouvez gérer les notifications pour cette vérification à ce adresse : <%= link_to nil, edit_check_url(check) %>

diff --git a/app/views/notifications_mailer/_footer_recurrent_failures.fr.text.erb b/app/views/notifications_mailer/_footer_recurrent_failures.fr.text.erb index ca48eea..a77dd21 100644 --- a/app/views/notifications_mailer/_footer_recurrent_failures.fr.text.erb +++ b/app/views/notifications_mailer/_footer_recurrent_failures.fr.text.erb @@ -1,6 +1,6 @@ -- -Vous avez reçu ce courriel à <%= pluralize(delay, "jour", "jours") %> +Vous avez reçu ce courriel à <%= pluralize(interval, "jour", "jours") %> avant la dernière date d'expiration connue. Vous pouvez gérer les notifications pour cette vérification à ce adresse : <%= edit_check_url(check) %> diff --git a/app/views/notifications_mailer/domain_expires_soon.fr.html.erb b/app/views/notifications_mailer/domain_expires_soon.fr.html.erb index 365f986..9b39910 100644 --- a/app/views/notifications_mailer/domain_expires_soon.fr.html.erb +++ b/app/views/notifications_mailer/domain_expires_soon.fr.html.erb @@ -9,4 +9,4 @@ <%= render "check_comment_vendor" %> -<%= render "footer_expires_soon", delay: @notification.delay, check: @check %> +<%= render "footer_expires_soon", interval: @notification.interval, check: @check %> diff --git a/app/views/notifications_mailer/domain_expires_soon.fr.text.erb b/app/views/notifications_mailer/domain_expires_soon.fr.text.erb index 7d11568..2caad62 100644 --- a/app/views/notifications_mailer/domain_expires_soon.fr.text.erb +++ b/app/views/notifications_mailer/domain_expires_soon.fr.text.erb @@ -6,4 +6,4 @@ le domaine <%= @check.domain %> va expirer le <%= format_utc(@check.domain_expir <%= render "check_comment_vendor" %> -<%= render "footer_expires_soon", delay: @notification.delay, check: @check %> +<%= render "footer_expires_soon", interval: @notification.interval, check: @check %> diff --git a/app/views/notifications_mailer/domain_recurrent_failures.en.html.erb b/app/views/notifications_mailer/domain_recurrent_failures.en.html.erb index 5584f9b..2146dbc 100644 --- a/app/views/notifications_mailer/domain_recurrent_failures.en.html.erb +++ b/app/views/notifications_mailer/domain_recurrent_failures.en.html.erb @@ -8,9 +8,14 @@

- Our last known expiry date is <%= format_utc(@check.domain_expires_at) %>. -
- Our last successful check occured <%= format_utc(@check.last_success_at) %>. + <%- if @check.domain_expires_at.present? %> + Our last known expiry date is <%= format_utc(@check.domain_expires_at) %>. +
+ <% end %> + + <%- if @check.last_success_at.present? %> + Our last successful check occured <%= format_utc(@check.last_success_at) %>. + <% end %>

diff --git a/app/views/notifications_mailer/domain_recurrent_failures.en.text.erb b/app/views/notifications_mailer/domain_recurrent_failures.en.text.erb index a54d658..b5ce466 100644 --- a/app/views/notifications_mailer/domain_recurrent_failures.en.text.erb +++ b/app/views/notifications_mailer/domain_recurrent_failures.en.text.erb @@ -3,8 +3,12 @@ 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. +<%- if @check.domain_expires_at.present? %> The last known expiry date is <%= format_utc(@check.domain_expires_at) %>. +<% end %> +<%- if @check.last_success_at.present? %> The last successful check occured <%= format_utc(@check.last_success_at) %>. +<% end %> If you have deleted your domain or have not renewed it, please disable or delete the check by following this link: diff --git a/app/views/notifications_mailer/domain_recurrent_failures.fr.html.erb b/app/views/notifications_mailer/domain_recurrent_failures.fr.html.erb index 94e15ba..7da5aed 100644 --- a/app/views/notifications_mailer/domain_recurrent_failures.fr.html.erb +++ b/app/views/notifications_mailer/domain_recurrent_failures.fr.html.erb @@ -8,9 +8,14 @@

- La dernière date d'expiration connue est le <%= format_utc(@check.domain_expires_at) %>. -
- Notre dernière vérification réussie a eu lieu le <%= format_utc(@check.last_success_at) %>. + <%- if @check.domain_expires_at.present? %> + La dernière date d'expiration connue est le <%= format_utc(@check.domain_expires_at) %>. +
+ <% end %> + + <%- if @check.last_success_at.present? %> + Notre dernière vérification réussie a eu lieu le <%= format_utc(@check.last_success_at) %>. + <% end %>

@@ -21,4 +26,4 @@ Si vous avez supprimé le domaine ou ne l'avez pas renouvellé, merci de désact <%= render "check_comment_vendor" %> -<%= render "footer_recurrent_failures", delay: @notification.delay, check: @check %> +<%= render "footer_recurrent_failures", interval: @notification.interval, check: @check %> diff --git a/app/views/notifications_mailer/domain_recurrent_failures.fr.text.erb b/app/views/notifications_mailer/domain_recurrent_failures.fr.text.erb index 64b76c5..cb8673e 100644 --- a/app/views/notifications_mailer/domain_recurrent_failures.fr.text.erb +++ b/app/views/notifications_mailer/domain_recurrent_failures.fr.text.erb @@ -4,10 +4,17 @@ Nous avons rencontré de multiples erreurs pendant l'exécution des vérificatio du domaine <%= @check.domain %>. Nous ne pouvons plus interroger la base Whois pour vérifier la date d'expiration. +<%- if @check.domain_expires_at.present? %> +La dernière date d'expiration connue est le <%= format_utc(@check.domain_expires_at) %>. +<% end %> +<%- if @check.last_success_at.present? %> +Notre dernière vérification réussie a eu lieu le <%= format_utc(@check.last_success_at) %>. +<% end %> + Si vous avez supprimé le domaine ou ne l'avez pas renouvellé, merci de désactiver la vérification associée, avec ce lien : <%= edit_check_url(@check) %> <%= render "check_comment_vendor" %> -<%= render "footer_recurrent_failures", delay: @notification.delay, check: @check %> +<%= render "footer_recurrent_failures", interval: @notification.interval, check: @check %> diff --git a/app/views/notifications_mailer/ssl_expires_soon.en.html.erb b/app/views/notifications_mailer/ssl_expires_soon.en.html.erb new file mode 100644 index 0000000..401df10 --- /dev/null +++ b/app/views/notifications_mailer/ssl_expires_soon.en.html.erb @@ -0,0 +1,12 @@ +

+ Hi, +
+
+ the SSL certificate for <%= @check.domain %> will expire + <%= format_utc(@check.domain_expires_at) %>. +

+
+ +<%= render "check_comment_vendor" %> + +<%= render "footer_expires_soon", interval: @notification.interval, check: @check %> diff --git a/app/views/notifications_mailer/ssl_expires_soon.en.text.erb b/app/views/notifications_mailer/ssl_expires_soon.en.text.erb new file mode 100644 index 0000000..a1d1c56 --- /dev/null +++ b/app/views/notifications_mailer/ssl_expires_soon.en.text.erb @@ -0,0 +1,9 @@ +Hi, + +the SSL certificate for <%= @check.domain %> will expire <%= format_utc(@check.domain_expires_at) %>. + + +<%= render "check_comment_vendor" %> + + +<%= render "footer_expires_soon", interval: @notification.interval, check: @check %> diff --git a/app/views/notifications_mailer/ssl_expires_soon.fr.html.erb b/app/views/notifications_mailer/ssl_expires_soon.fr.html.erb new file mode 100644 index 0000000..dfc1c3f --- /dev/null +++ b/app/views/notifications_mailer/ssl_expires_soon.fr.html.erb @@ -0,0 +1,12 @@ +

+ Salut, +
+
+ le certificat SSL pour <%= @check.domain %> va expirer le + <%= format_utc(@check.domain_expires_at) %>. +

+
+ +<%= render "check_comment_vendor" %> + +<%= render "footer_expires_soon", interval: @notification.interval, check: @check %> diff --git a/app/views/notifications_mailer/ssl_expires_soon.fr.text.erb b/app/views/notifications_mailer/ssl_expires_soon.fr.text.erb new file mode 100644 index 0000000..0664fb5 --- /dev/null +++ b/app/views/notifications_mailer/ssl_expires_soon.fr.text.erb @@ -0,0 +1,9 @@ +Salut, + +le certificat SSL pour <%= @check.domain %> va expirer le <%= format_utc(@check.domain_expires_at) %>. + + +<%= render "check_comment_vendor" %> + + +<%= render "footer_expires_soon", interval: @notification.interval, check: @check %> diff --git a/app/views/notifications_mailer/ssl_recurrent_failures.en.html.erb b/app/views/notifications_mailer/ssl_recurrent_failures.en.html.erb new file mode 100644 index 0000000..602c32d --- /dev/null +++ b/app/views/notifications_mailer/ssl_recurrent_failures.en.html.erb @@ -0,0 +1,31 @@ +

+ Hi, +
+
+ + We had recurrent failures while checking the SSL certificate for + <%= @check.domain %>. As of today, we can no longer verify the certificate + expiry date. +

+ +

+ <%- if @check.domain_expires_at.present? %> + Our last known expiry date is <%= format_utc(@check.domain_expires_at) %>. +
+ <%- end %> + + <%- if @check.last_success_at.present? %> + Our last successful check occured <%= format_utc(@check.last_success_at) %>. + <%- end %> +

+ +

+ If there is no more SSL endpoint for this domain, please disable + or delete the check by following this link:

+ <%= link_to nil, edit_check_url(@check) %> +

+
+<%= render "check_comment_vendor" %> + + +<%= render "footer_recurrent_failures", interval: @notification.interval, check: @check %> diff --git a/app/views/notifications_mailer/ssl_recurrent_failures.en.text.erb b/app/views/notifications_mailer/ssl_recurrent_failures.en.text.erb new file mode 100644 index 0000000..2bb6dd3 --- /dev/null +++ b/app/views/notifications_mailer/ssl_recurrent_failures.en.text.erb @@ -0,0 +1,22 @@ +Hi, + +We had recurrent failures while checking the SSL certificate for +<%= @check.domain %>. As of today, we can no longer verify the certificate +expiry date. + +<%- if @check.domain_expires_at.present? %> +The last known expiry date is <%= format_utc(@check.domain_expires_at) %>. +<%- end %> + +<%- if @check.last_success_at.present? %> +The last successful check occured <%= format_utc(@check.last_success_at) %>. +<%- end %> + +If there is no more SSL endpoint for this domain, please disable +or delete the check by following this link: + +<%= edit_check_url(@check) %> + +<%= render "check_comment_vendor" %> + +<%= render "footer_recurrent_failures", interval: @notification.interval, check: @check %> diff --git a/app/views/notifications_mailer/ssl_recurrent_failures.fr.html.erb b/app/views/notifications_mailer/ssl_recurrent_failures.fr.html.erb new file mode 100644 index 0000000..ea05abc --- /dev/null +++ b/app/views/notifications_mailer/ssl_recurrent_failures.fr.html.erb @@ -0,0 +1,35 @@ +

+ Salut, +
+
+ + Nous avons rencontré de multiples erreurs + pendant l'exécution des vérifications du certificat SSL + du site <%= @check.domain %>. + Nous ne pouvons plus vérifier la date d'expiration du certificat + en nous connectant au site. +

+ +

+ <%- if @check.domain_expires_at.present? %> + La dernière date d'expiration connue est le <%= format_utc(@check.domain_expires_at) %>. +
+ <% end %> + + <%- if @check.last_success_at.present? %> + Notre dernière vérification réussie a eu lieu le <%= format_utc(@check.last_success_at) %>. + <% end %> +

+ +

+ S'il n'y a plus de terminaison SSL pour ce site ou s'il n'existe plus, + merci de désactiver la vérification associée, avec ce lien : +
+
+ <%= link_to nil, edit_check_url(@check) %> +

+
+<%= render "check_comment_vendor" %> + + +<%= render "footer_recurrent_failures", interval: @notification.interval, check: @check %> diff --git a/app/views/notifications_mailer/ssl_recurrent_failures.fr.text.erb b/app/views/notifications_mailer/ssl_recurrent_failures.fr.text.erb new file mode 100644 index 0000000..fe15787 --- /dev/null +++ b/app/views/notifications_mailer/ssl_recurrent_failures.fr.text.erb @@ -0,0 +1,22 @@ +Salut, + +Nous avons rencontré de multiples erreurs pendant l'exécution des vérifications +du certificat SSL pour le site <%= @check.domain %>. +Nous ne pouvons plus vérifier la date d'expiration du certificat +en nous connectant au site. + +<%- if @check.domain_expires_at.present? %> +La dernière date d'expiration connue est le <%= format_utc(@check.domain_expires_at) %>. +<% end %> + +<%- if @check.last_success_at.present? %> +Notre dernière vérification réussie a eu lieu le <%= format_utc(@check.last_success_at) %>. +<% end %> + +S'il n'y a plus de terminaison SSL pour ce site ou s'il n'existe plus, +merci de désactiver la vérification associée, avec ce lien : +<%= edit_check_url(@check) %> + +<%= render "check_comment_vendor" %> + +<%= render "footer_recurrent_failures", interval: @notification.interval, check: @check %> diff --git a/config/chexpire.example.yml b/config/chexpire.example.yml index ffb7daa..b322b96 100644 --- a/config/chexpire.example.yml +++ b/config/chexpire.example.yml @@ -3,10 +3,13 @@ default: &default notifier: interval: 0.00 failure_days: 3 - checks: + checks_domain: interval: 0.5 long_term: 60 long_term_frequency: 10 + checks_ssl: + check_http_path: "" + check_http_args: "" development: <<: *default diff --git a/config/chexpire.test.yml b/config/chexpire.test.yml index db88e9a..2a72a7b 100644 --- a/config/chexpire.test.yml +++ b/config/chexpire.test.yml @@ -4,7 +4,10 @@ test: notifier: interval: 0.00 failure_days: 3 - checks: + checks_domain: interval: 0.00 long_term: 60 long_term_frequency: 10 + checks_ssl: + check_http_path: "" + check_http_args: "" diff --git a/config/locales/en.yml b/config/locales/en.yml index d93597e..9a34ad9 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -38,6 +38,15 @@ en: domain_recurrent_failures: subject: "Recurrent failures in %{domain} domain expiry check" + ssl_expires_soon: + subject: + zero: "SSL certificate for %{domain} expires TODAY!" + one: "SSL certificate for %{domain} expires TOMORROW!" + other: "SSL certificate for %{domain} expires in %{count} days" + + ssl_recurrent_failures: + subject: "Recurrent failures in %{domain} SSL certificate expiry check" + shared: locales: diff --git a/config/locales/fr.yml b/config/locales/fr.yml index 4e4b71c..ed5926f 100644 --- a/config/locales/fr.yml +++ b/config/locales/fr.yml @@ -12,6 +12,14 @@ fr: check: past: "ne peut être dans le futur" + time: + am: am + formats: + default: "%a %d %b %Y %H:%M:%S %z" + long: "%A %d %B %Y %H:%M" + short: "%d %b %H:%M" + pm: pm + devise: registrations: new: @@ -36,7 +44,16 @@ fr: other: "Le domaine %{domain} expire dans %{count} jours" domain_recurrent_failures: - subject: "Multiples erreur dans la vérification d'expiration du domaine %{domain}" + subject: "Erreurs dans la vérification d'expiration du domaine %{domain}" + + ssl_expires_soon: + subject: + zero: "Le certificat SSL pour %{domain} expire AUJOURD'HUI !" + one: "Le certificat SSL pour %{domain} expire DEMAIN !" + other: "Le certificat SSL pour %{domain} expire dans %{count} jours" + + ssl_recurrent_failures: + subject: "Erreurs dans la vérification d'expiration du certificat SSL %{domain}" shared: diff --git a/config/schedule.rb b/config/schedule.rb index 709286e..27553b1 100644 --- a/config/schedule.rb +++ b/config/schedule.rb @@ -19,7 +19,7 @@ set :output, standard: "log/cron.log" # Learn more: http://github.com/javan/whenever every 1.day, at: '4:30 am', roles: [:app] do - rake "checks:sync_dates" + rake "checks:sync_dates:all" end every 1.day, at: '10:30 am', roles: [:app] do diff --git a/db/seeds.rb b/db/seeds.rb index c89deb6..2bf199b 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -1,3 +1,4 @@ +CheckLog.destroy_all Notification.destroy_all Check.destroy_all User.destroy_all @@ -33,6 +34,31 @@ check_chexpire_org_error = Check.create!( last_success_at: 4.days.ago, ) +ssl_check_chexpire_org = Check.create!( + user: user1, + kind: :ssl, + domain: "www.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", +) + +ssl_check_chexpire_org_error = Check.create!( + user: user1, + kind: :ssl, + 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, interval: 15, @@ -49,6 +75,22 @@ Notification.create!( status: :pending, ) +Notification.create!( + check: ssl_check_chexpire_org, + interval: 15, + channel: :email, + recipient: "colin@example.org", + status: :pending, +) + +Notification.create!( + check: ssl_check_chexpire_org_error, + interval: 15, + channel: :email, + recipient: "colin@example.org", + status: :pending, +) + puts "\e[0;32mDone 👌\e[0m" puts " " puts "--------------------" diff --git a/lib/tasks/checks.rake b/lib/tasks/checks.rake index 5d82236..6703e73 100644 --- a/lib/tasks/checks.rake +++ b/lib/tasks/checks.rake @@ -1,7 +1,17 @@ namespace :checks do - desc "Refresh expiry dates for checks" - task sync_dates: :environment do - process = CheckProcessor.new - process.sync_dates + namespace :sync_dates do + task all: [:domain, :ssl] + + desc "Refresh domains expiry dates" + task domain: :environment do + process = CheckDomainProcessor.new + process.sync_dates + end + + desc "Refresh SSL expiry dates" + task domain: :environment do + process = CheckSSLProcessor.new + process.sync_dates + end end end diff --git a/test/factories/checks.rb b/test/factories/checks.rb index 62d95ec..f940eb2 100644 --- a/test/factories/checks.rb +++ b/test/factories/checks.rb @@ -44,6 +44,10 @@ FactoryBot.define do kind :domain end + trait :ssl do + kind :ssl + end + trait :nil_dates do domain_created_at nil domain_updated_at nil diff --git a/test/fixtures/files/ssl/ssl0.domain.org.txt b/test/fixtures/files/ssl/ssl0.domain.org.txt new file mode 100644 index 0000000..8663a1d --- /dev/null +++ b/test/fixtures/files/ssl/ssl0.domain.org.txt @@ -0,0 +1 @@ +OK - Certificate 'ssl0.domain.org' will expire on Sat 10 Jun 2028 09:14:18 AM GMT +0000. diff --git a/test/fixtures/files/ssl/ssl1.domain.org.txt b/test/fixtures/files/ssl/ssl1.domain.org.txt new file mode 100644 index 0000000..91b39ee --- /dev/null +++ b/test/fixtures/files/ssl/ssl1.domain.org.txt @@ -0,0 +1 @@ +SSL OK - Certificate 'ssl1.domain.org' will expire on 2022-08-06 02:57 +0200/CEST. diff --git a/test/fixtures/files/ssl/ssl100.invalid.org.txt b/test/fixtures/files/ssl/ssl100.invalid.org.txt new file mode 100644 index 0000000..3153b3e --- /dev/null +++ b/test/fixtures/files/ssl/ssl100.invalid.org.txt @@ -0,0 +1 @@ +check_http: Invalid hostname/address - ssl100.invalid.org diff --git a/test/fixtures/files/ssl/ssl101.invalid.org.txt b/test/fixtures/files/ssl/ssl101.invalid.org.txt new file mode 100644 index 0000000..1698540 --- /dev/null +++ b/test/fixtures/files/ssl/ssl101.invalid.org.txt @@ -0,0 +1 @@ +SSL OK - Certificate 'ssl101.invalid.org' will expire on unknown date. diff --git a/test/jobs/ssl_sync_job_test.rb b/test/jobs/ssl_sync_job_test.rb new file mode 100644 index 0000000..0a55cdb --- /dev/null +++ b/test/jobs/ssl_sync_job_test.rb @@ -0,0 +1,68 @@ +require "test_helper" + +class SSLSyncJobTest < ActiveJob::TestCase + test "calls whois database and update check with the response (domain.fr)" do + domain = "ssl0.domain.org" + check = create(:check, :nil_dates, domain: domain) + + mock_system_command("check_http", expected_command_arg(domain), stdout: ssl_response(domain)) do + SSLSyncJob.perform_now(check.id) + end + + check.reload + + assert_just_now check.last_run_at + assert_just_now check.last_success_at + assert_equal Time.new(2028, 6, 10, 9, 14, 18, 0), check.domain_expires_at + assert_nil check.domain_updated_at + assert_nil check.domain_created_at + assert check.active? + end + + test "ignore invalid response" do + domain = "domain.fr" + check = create(:check, :nil_dates, domain: domain) + original_updated_at = check.updated_at + + mock_system_command("check_http", expected_command_arg(domain), stdout: "not a response") do + SSLSyncJob.perform_now(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 "should ignore not found (removed) checks" do + assert_nothing_raised do + SSLSyncJob.perform_now("9999999") + end + end + + test "should log and re-raise StandardError" do + check = create(:check) + + assert_raise StandardError do + SSL.stub :ask, nil do + SSLSyncJob.perform_now(check.id) + end + end + + assert_equal 1, check.logs.count + assert_match(/undefined method \W+valid\?/, check.logs.last.error) + assert check.logs.last.failed? + end + + private + + def ssl_response(domain) + file_fixture("ssl/#{domain}.txt").read + end + + def expected_command_arg(domain) + ["-H '#{domain}'"] + end +end diff --git a/test/mailers/notifications_mailer_test.rb b/test/mailers/notifications_mailer_test.rb index adce75c..c261131 100644 --- a/test/mailers/notifications_mailer_test.rb +++ b/test/mailers/notifications_mailer_test.rb @@ -1,6 +1,6 @@ require "test_helper" -class NotificationsMailerTest < ActionMailer::TestCase +class NotificationsMailerTest < ActionMailer::TestCase # rubocop:disable Metrics/ClassLength test "domain_expires_soon" do check = create(:check, domain_expires_at: Time.new(2018, 6, 10, 12, 0, 5, "+02:00")) notification = build(:notification, interval: 10, check: check, recipient: "colin@example.org") @@ -17,7 +17,7 @@ class NotificationsMailerTest < ActionMailer::TestCase 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 = [mail.text_part.decode_body, mail.html_part.decode_body] parts.each do |part| assert_match "domain.fr", part @@ -30,6 +30,37 @@ class NotificationsMailerTest < ActionMailer::TestCase end end + test "domain_expires_soon FR" do + check = create(:check, domain_expires_at: Time.new(2018, 6, 10, 12, 0, 5, "+02:00")) + notification = build(:notification, interval: 10, check: check, recipient: "colin@example.org") + + I18n.with_locale :fr do + 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 "dans 8 jours", mail.subject + assert_equal ["colin@example.org"], mail.to + assert_equal [Rails.configuration.chexpire.fetch("mailer_default_from")], mail.from + + parts = [mail.text_part.decode_body, mail.html_part.decode_body] + + parts.each do |part| + assert_match "domain.fr", part + assert_match "dim 10 juin 2018 10:00:05 +0000", part + assert_match "10 jours", part + assert_match "/checks/#{check.id}/edit", part + assert_no_match "commentaire", part + assert_no_match "fournisseur", part + end + end + end + end + test "domain_expires_soon include comment & vendor" do check = create(:check, domain_expires_at: 1.week.from_now, @@ -39,7 +70,7 @@ class NotificationsMailerTest < ActionMailer::TestCase mail = NotificationsMailer.with(notification: notification).domain_expires_soon - parts = [mail.text_part.body.to_s, mail.html_part.to_s] + parts = [mail.text_part.decode_body, mail.html_part.decode_body] parts.each do |part| assert_match "My comment", part @@ -47,6 +78,25 @@ class NotificationsMailerTest < ActionMailer::TestCase end end + test "domain_expires_soon include comment & vendor - FR" do + check = create(:check, + domain_expires_at: 1.week.from_now, + comment: "My comment", + vendor: "The vendor") + notification = build(:notification, check: check) + + I18n.with_locale :fr do + mail = NotificationsMailer.with(notification: notification).domain_expires_soon + + parts = [mail.text_part.decode_body, mail.html_part.decode_body] + + parts.each do |part| + assert_match "commentaire", part + assert_match "Fournisseur", part + end + 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") @@ -64,7 +114,7 @@ class NotificationsMailerTest < ActionMailer::TestCase 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 = [mail.text_part.decode_body, mail.html_part.decode_body] parts.each do |part| assert_match "invalid-domain.fr", part @@ -75,4 +125,159 @@ class NotificationsMailerTest < ActionMailer::TestCase assert_match "/checks/#{check.id}/edit", part end end + + test "domain_recurrent_failures - FR" 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) + + I18n.with_locale :fr do + mail = NotificationsMailer.with(notification: notification).domain_recurrent_failures + assert_match "Erreurs", 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.decode_body, mail.html_part.decode_body] + + parts.each do |part| + assert_match "invalid-domain.fr", part + assert_match "erreurs", part + assert_match(/réussie[a-z ]+ mer 30 mai 2018 06:10:00 \+0000/, part) + assert_match(/expiration[a-z ]+ mer 10 oct. 2018 03:20:00 \+0000/, part) + assert_match "commentaire", part + assert_match "/checks/#{check.id}/edit", part + end + end + end + + test "ssl_expires_soon" do + check = create(:check, :ssl, domain_expires_at: Time.new(2018, 6, 10, 12, 0, 5, "+02:00")) + notification = build(:notification, interval: 10, check: check, recipient: "colin@example.org") + + Date.stub :today, Date.new(2018, 6, 2) do + mail = NotificationsMailer.with(notification: notification).ssl_expires_soon + + assert_emails 1 do + mail.deliver_now + end + + assert_match "domain.fr", mail.subject + assert_match "SSL", 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.decode_body, mail.html_part.decode_body] + + 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 "ssl_expires_soon - FR" do + check = create(:check, :ssl, domain_expires_at: Time.new(2018, 6, 10, 12, 0, 5, "+02:00")) + notification = build(:notification, interval: 10, check: check, recipient: "colin@example.org") + + I18n.with_locale :fr do + Date.stub :today, Date.new(2018, 6, 2) do + mail = NotificationsMailer.with(notification: notification).ssl_expires_soon + + assert_emails 1 do + mail.deliver_now + end + + assert_match "domain.fr", mail.subject + assert_match "SSL", mail.subject + assert_match "dans 8 jours", mail.subject + assert_equal ["colin@example.org"], mail.to + assert_equal [Rails.configuration.chexpire.fetch("mailer_default_from")], mail.from + + parts = [mail.text_part.decode_body, mail.html_part.decode_body] + + parts.each do |part| + assert_match "domain.fr", part + assert_match "dim 10 juin 2018 10:00:05 +0000", part + assert_match "10 jours", part + assert_match "/checks/#{check.id}/edit", part + assert_no_match "commentaire", part + assert_no_match "fournisseur", part + end + end + end + end + + test "ssl_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, :ssl, :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).ssl_recurrent_failures + assert_match "failures", mail.subject + assert_match "invalid-domain.fr", mail.subject + assert_match "SSL", mail.subject + + assert_equal ["recipient@domain.fr"], mail.to + assert_equal [Rails.configuration.chexpire.fetch("mailer_default_from")], mail.from + + parts = [mail.text_part.decode_body, mail.html_part.decode_body] + + 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 + + test "ssl_recurrent_failures - FR" 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, :ssl, :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) + + I18n.with_locale :fr do + mail = NotificationsMailer.with(notification: notification).ssl_recurrent_failures + assert_match "Erreurs", mail.subject + assert_match "invalid-domain.fr", mail.subject + assert_match "SSL", mail.subject + + assert_equal ["recipient@domain.fr"], mail.to + assert_equal [Rails.configuration.chexpire.fetch("mailer_default_from")], mail.from + + parts = [mail.text_part.decode_body, mail.html_part.decode_body] + + parts.each do |part| + assert_match "invalid-domain.fr", part + assert_match "erreurs", part + assert_match(/réussie[a-z ]+ mer 30 mai 2018 06:10:00 \+0000/, part) + assert_match(/expiration[a-z ]+ mer 10 oct. 2018 03:20:00 \+0000/, part) + assert_match "commentaire", part + assert_match "/checks/#{check.id}/edit", part + end + end + end end diff --git a/test/mailers/previews/notifications_mailer_preview.rb b/test/mailers/previews/notifications_mailer_preview.rb index f9c2898..01f5085 100644 --- a/test/mailers/previews/notifications_mailer_preview.rb +++ b/test/mailers/previews/notifications_mailer_preview.rb @@ -2,12 +2,25 @@ 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 + check = Check.domain.first + NotificationsMailer.with(notification: check.notifications.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 + check = Check.domain.where("last_run_at != last_success_at").first NotificationsMailer.with(notification: check.notifications.first).domain_recurrent_failures end + + # Preview this email at http://localhost:3000/rails/mailers/notifications_mailer/ssl_expires_soon + def ssl_expires_soon + check = Check.ssl.first + NotificationsMailer.with(notification: check.notifications.first).ssl_expires_soon + end + + # Preview this email at http://localhost:3000/rails/mailers/notifications_mailer/ssl_recurrent_failures + def ssl_recurrent_failures + check = Check.ssl.where("last_run_at != last_success_at").first + NotificationsMailer.with(notification: check.notifications.first).ssl_recurrent_failures + end end diff --git a/test/services/check_domain_processor_test.rb b/test/services/check_domain_processor_test.rb new file mode 100644 index 0000000..1ea3023 --- /dev/null +++ b/test/services/check_domain_processor_test.rb @@ -0,0 +1,34 @@ +require "test_helper" + +class CheckDomainProcessorTest < ActiveSupport::TestCase + setup do + @processor = CheckDomainProcessor.new + end + + test "process WhoisSyncJob for domain checks" do + domain = "domain.fr" + check = create(:check, :domain, :nil_dates, domain: domain) + + mock_system_command("whois", domain, stdout: file_fixture("whois/domain.fr.txt").read) do + @processor.send(:process, check) + end + + check.reload + + assert_equal Time.new(2019, 2, 17, 0, 0, 0, 0), check.domain_expires_at + end + + test "scope concerns only checks of kind 'domain'" do + domains = create_list(:check, 2, :domain) + create_list(:check, 2, :ssl) + + assert_equal domains, @processor.send(:scope) + end + + test "resolvers returns an array of methods returning a scope" do + assert_not_empty @processor.send(:resolvers) + @processor.send(:resolvers).each do |method| + assert_kind_of ActiveRecord::Relation, @processor.public_send(method) + end + end +end diff --git a/test/services/check_processor_test.rb b/test/services/check_processor_test.rb index 66e1e53..d5c1387 100644 --- a/test/services/check_processor_test.rb +++ b/test/services/check_processor_test.rb @@ -1,31 +1,26 @@ require "test_helper" +class CheckDummyProcessor + include CheckProcessor + def scope + base_scope + end + + def configuration_key + "checks_dummy" + end + + def resolvers + %i[ + resolve_expire_short_term + resolve_expire_long_term + ] + end +end + class CheckProcessorTest < ActiveSupport::TestCase setup do - @processor = CheckProcessor.new - end - - test "process WhoisSyncJob for domain checks" do - domain = "domain.fr" - check = create(:check, :domain, :nil_dates, domain: domain) - - mock_system_command("whois", domain, stdout: file_fixture("whois/domain.fr.txt").read) do - @processor.send(:process, check) - end - - check.reload - - assert_equal Time.new(2019, 2, 17, 0, 0, 0, 0), check.domain_expires_at - end - - test "raises an error for an unsupported check kind" do - check = build(:check) - - check.stub :kind, :unknown do - assert_raises ArgumentError do - @processor.send(:process, check) - end - end + @processor = CheckDummyProcessor.new end test "resolve_last_run_failed includes already and never succeeded" do @@ -74,6 +69,14 @@ class CheckProcessorTest < ActiveSupport::TestCase assert_not_includes checks, c4 end + test "resolve_all include all eligible checks" do + create(:check, :expires_next_week) + create(:check, :expires_next_week, last_run_at: 4.hours.ago) + create(:check, :last_runs_failed) + + assert_equal @processor.send(:scope), @processor.resolve_all + end + test "resolvers does not include checks recently executed" do c1 = create(:check, :expires_next_week) c2 = create(:check, :expires_next_week, last_run_at: 4.hours.ago) @@ -113,7 +116,7 @@ class CheckProcessorTest < ActiveSupport::TestCase configuration.expect(:interval, 0.000001) end - processor = CheckProcessor.new(configuration) + processor = CheckDummyProcessor.new(configuration) mock = Minitest::Mock.new assert_stub = lambda { |actual_time| diff --git a/test/services/check_ssl_processor_test.rb b/test/services/check_ssl_processor_test.rb new file mode 100644 index 0000000..db6fa3c --- /dev/null +++ b/test/services/check_ssl_processor_test.rb @@ -0,0 +1,35 @@ +require "test_helper" + +class CheckSSLProcessorTest < ActiveSupport::TestCase + setup do + @processor = CheckSSLProcessor.new + end + + test "process SSLSyncJob for ssl checks" do + domain = "ssl0.domain.org" + check = create(:check, :ssl, :nil_dates, domain: domain) + + response = file_fixture("ssl/ssl0.domain.org.txt").read + mock_system_command("check_http", ["-H '#{domain}'"], stdout: response) do + @processor.send(:process, check) + end + + check.reload + + assert_equal Time.new(2028, 6, 10, 9, 14, 18, 0), check.domain_expires_at + end + + test "scope concerns only checks of kind 'ssl'" do + domains = create_list(:check, 2, :ssl) + create_list(:check, 2, :domain) + + assert_equal domains, @processor.send(:scope) + end + + test "resolvers returns an array of methods returning a scope" do + assert_not_empty @processor.send(:resolvers) + @processor.send(:resolvers).each do |method| + assert_kind_of ActiveRecord::Relation, @processor.public_send(method) + end + end +end diff --git a/test/services/ssl/parser_test.rb b/test/services/ssl/parser_test.rb new file mode 100644 index 0000000..05566d6 --- /dev/null +++ b/test/services/ssl/parser_test.rb @@ -0,0 +1,53 @@ +require "test_helper" +require "ssl/parser" +require "ssl/errors" + +module SSL + class ParserTest < ActiveSupport::TestCase + test "should parse a SSL check response" do + parser = Parser.new("ssl0.domain.org") + domain = file_fixture("ssl/ssl0.domain.org.txt").read + response = parser.parse(domain) + assert_kind_of Response, response + + assert_equal Time.new(2028, 6, 10, 9, 14, 18, 0), response.expire_at + assert response.expire_at.utc? + end + + test "should parse a SSL check response in another format and convert it in UTC" do + parser = Parser.new("ssl1.domain.org") + domain = file_fixture("ssl/ssl1.domain.org.txt").read + response = parser.parse(domain) + assert_kind_of Response, response + + assert_equal Time.new(2022, 8, 6, 0, 57, 0, 0), response.expire_at + assert response.expire_at.utc? + end + + test "should raises DomainNotMatchError when parsed text does not match the domain" do + parser = Parser.new("anotherdomain.fr") + output = file_fixture("ssl/ssl1.domain.org.txt").read + + assert_raises DomainNotMatchError do + parser.parse(output) + end + end + test "should raises InvalidResponseError when check response is not matched" do + parser = Parser.new("ssl100.invalid.org") + output = file_fixture("ssl/ssl100.invalid.org.txt").read + + assert_raises InvalidResponseError do + parser.parse(output) + end + end + + test "should raises InvalidDateError when a date is not in the expected format" do + parser = Parser.new("ssl101.invalid.org") + output = file_fixture("ssl/ssl101.invalid.org.txt").read + + assert_raises InvalidDateError do + parser.parse(output) + end + end + end +end diff --git a/test/services/ssl_test.rb b/test/services/ssl_test.rb new file mode 100644 index 0000000..5b98396 --- /dev/null +++ b/test/services/ssl_test.rb @@ -0,0 +1,74 @@ +require "test_helper" +require "ssl" +require "system_command" + +module SSL + class ServiceTest < ActiveSupport::TestCase + test "should run the command, return the result" do + result = OpenStruct.new(exit_status: 0) + + mock_system_klass("check_http", ["-H 'example.org'"], result) do |system_klass| + service = Service.new("example.org", system_klass: system_klass) + assert_equal result, service.run_command + end + end + + test "should raise an exception if exit status > 0" do + result = OpenStruct.new(exit_status: 1) + + mock_system_klass("check_http", ["-H 'example.org'"], result) do |system_klass| + service = Service.new("example.org", system_klass: system_klass) + + assert_raises SSLCommandError do + service.run_command + end + end + end + + test "should parse from a command result" do + result = OpenStruct.new( + exit_status: 0, + stdout: file_fixture("ssl/ssl0.domain.org.txt").read, + ) + + service = Service.new("ssl0.domain.org") + assert_kind_of Response, service.parse(result) + end + + test "should uses the command line arguments of the configuration" do + result = OpenStruct.new(exit_status: 0) + config = OpenStruct.new(check_http_args: "-f follow -I 127.0.0.1") + + expected_args = ["-f follow -I 127.0.0.1", "-H 'example.org'"] + mock_system_klass("check_http", expected_args, result) do |system_klass| + service = Service.new("example.org", configuration: config, system_klass: system_klass) + assert_equal result, service.run_command + end + end + + test "should uses the program path from the configuration" do + result = OpenStruct.new(exit_status: 0) + config = OpenStruct.new(check_http_path: "/usr/local/custom/path") + + mock_system_klass("/usr/local/custom/path", ["-H 'example.org'"], result) do |system_klass| + service = Service.new("example.org", configuration: config, system_klass: system_klass) + assert_equal result, service.run_command + end + end + + def mock_system_klass(program, command_args, result) + system_klass = Minitest::Mock.new + system_command = Minitest::Mock.new.expect(:execute, result) + system_klass.expect(:new, system_command) do |arg1, arg2, logger:| + arg1 == program && + arg2 == command_args && + logger.class == NullLogger + end + + yield system_klass + + system_klass.verify + system_command.verify + end + end +end diff --git a/test/test_helper.rb b/test/test_helper.rb index fda0a43..91ae325 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -6,6 +6,7 @@ if !ENV["NO_COVERAGE"] && (ARGV.empty? || ARGV.include?("test/test_helper.rb")) SimpleCov.start "rails" do add_group "Notifier", "app/services/notifier" add_group "Whois", "app/services/whois" + add_group "SSL", "app/services/ssl" add_group "Services", "app/services" add_group "Policies", "app/policies" end