diff --git a/app/assets/javascripts/app/controllers/monitoring.coffee b/app/assets/javascripts/app/controllers/monitoring.coffee new file mode 100644 index 000000000..5850e8102 --- /dev/null +++ b/app/assets/javascripts/app/controllers/monitoring.coffee @@ -0,0 +1,47 @@ +class Index extends App.ControllerSubContent + requiredPermission: 'admin.monitoring' + header: 'Monitoring' + events: + 'click .js-resetToken': 'resetToken' + 'click .js-select': 'selectAll' + + constructor: -> + super + @load() + @interval( + => + @load() + 35000 + ) + + # fetch data, render view + load: -> + @startLoading() + @ajax( + id: 'health_check' + type: 'GET' + url: "#{@apiPath}/monitoring/health_check" + success: (data) => + @stopLoading() + console.log('111', data, @data) + return if @data && data.token is @data.token && data.healthy is @data.healthy && data.message is @data.message + console.log('222') + @data = data + @render() + ) + + render: => + @html App.view('monitoring')(data: @data) + + resetToken: (e) => + e.preventDefault() + @formDisable(e) + @ajax( + id: 'health_check_token' + type: 'POST' + url: "#{@apiPath}/monitoring/token" + success: (data) => + @load() + ) + +App.Config.set('Monitoring', { prio: 3600, name: 'Monitoring', parent: '#system', target: '#system/monitoring', controller: Index, permission: ['admin.monitoring'] }, 'NavBarAdmin') diff --git a/app/assets/javascripts/app/controllers/package.coffee b/app/assets/javascripts/app/controllers/package.coffee index 00d9702a0..8f7bb7af0 100644 --- a/app/assets/javascripts/app/controllers/package.coffee +++ b/app/assets/javascripts/app/controllers/package.coffee @@ -54,4 +54,4 @@ class Index extends App.ControllerSubContent @load() ) -App.Config.set('Packages', { prio: 3600, name: 'Packages', parent: '#system', target: '#system/package', controller: Index, permission: ['admin.package'] }, 'NavBarAdmin') +App.Config.set('Packages', { prio: 3700, name: 'Packages', parent: '#system', target: '#system/package', controller: Index, permission: ['admin.package'] }, 'NavBarAdmin') diff --git a/app/assets/javascripts/app/views/monitoring.jst.eco b/app/assets/javascripts/app/views/monitoring.jst.eco new file mode 100644 index 000000000..053c128ed --- /dev/null +++ b/app/assets/javascripts/app/views/monitoring.jst.eco @@ -0,0 +1,39 @@ + +
+ +
+
+

<%- @T('Current Token') %>

+
+

+ +
+ +
+
+

<%- @T('Health Check') %>

+
+

<%- @T('Health information can be retrieved as JSON using') %>:

+

+
+ +
+
+

<% if _.isEmpty(@data.issues): %><%- @Icon('status', 'ok inline') %><% else: %><%- @Icon('status', 'error inline') %><% end %> <%- @T('Current Status') %>

+
+ +
+ +
\ No newline at end of file diff --git a/app/controllers/monitoring_controller.rb b/app/controllers/monitoring_controller.rb new file mode 100644 index 000000000..1fa9c3bdc --- /dev/null +++ b/app/controllers/monitoring_controller.rb @@ -0,0 +1,182 @@ +# Copyright (C) 2012-2016 Zammad Foundation, http://zammad-foundation.org/ + +class MonitoringController < ApplicationController + before_action -> { authentication_check(permission: 'admin.monitoring') }, except: [:health_check, :status] + +=begin + +Resource: +GET /api/v1/monitoring/health_check?token=XXX + +Response: +{ + "healthy": true, + "message": "success", +} + +{ + "healthy": false, + "message": "authentication of XXX failed; issue #2", + "issues": ["authentication of XXX failed", "issue #2"], +} + +Test: +curl http://localhost/api/v1/monitoring/health_check?token=XXX + +=end + + def health_check + token_or_permission_check + + issues = [] + + # channel check + Channel.where(active: true).each { |channel| + next if (channel.status_in.empty? || channel.status_in == 'ok') && (channel.status_out.empty? || channel.status_out == 'ok') + if channel.status_in == 'error' + message = "Channel: #{channel.area} in " + %w(host user uid).each { |key| + next if !channel.options[key] || channel.options[key].empty? + message += "key:#{channel.options[key]};" + } + issues.push "#{message} #{channel.last_log_in}" + end + next if channel.status_out != 'error' + message = "Channel: #{channel.area} out " + %w(host user uid).each { |key| + next if !channel.options[key] || channel.options[key].empty? + message += "key:#{channel.options[key]};" + } + issues.push "#{message} #{channel.last_log_out}" + } + + # unprocessable mail check + directory = "#{Rails.root}/tmp/unprocessable_mail" + if File.exist?(directory) + count = 0 + Dir.glob("#{directory}/*.eml") { |_entry| + count += 1 + } + if count.nonzero? + issues.push "unprocessable mails: #{count}" + end + end + + # scheduler check + Scheduler.where(active: true).where.not(last_run: nil).each { |scheduler| + next if scheduler.period <= 300 + next if scheduler.last_run + scheduler.period.seconds > Time.zone.now - 5.minutes + issues.push 'scheduler not running' + break + } + if Scheduler.where(active: true, last_run: nil).count == Scheduler.where(active: true).count + issues.push 'scheduler not running' + end + + token = Setting.get('monitoring_token') + + if issues.empty? + result = { + healthy: true, + message: 'success', + token: token, + } + render json: result + return + end + + result = { + healthy: false, + message: issues.join(';'), + issues: issues, + token: token, + } + render json: result + end + +=begin + +Resource: +GET /api/v1/monitoring/status?token=XXX + +Response: +{ + "agents": 8123, + "last_login": "2016-11-21T14:14:14Z", + "counts": { + "users": 12313, + "tickets": 23123, + "ticket_articles": 131451, + }, + "last_created_at": { + "users": "2016-11-21T14:14:14Z", + "tickets": "2016-11-21T14:14:14Z", + "ticket_articles": "2016-11-21T14:14:14Z", + }, +} + +Test: +curl http://localhost/api/v1/monitoring/status?token=XXX + +=end + + def status + token_or_permission_check + + last_login = nil + last_login_user = User.where('last_login IS NOT NULL').order(last_login: :desc).limit(1).first + if last_login_user + last_login = last_login_user.last_login + end + + status = { + counts: {}, + last_created_at: {}, + last_login: last_login, + agents: User.with_permissions('ticket.agent').count, + } + + map = { + users: User, + groups: Group, + overviews: Overview, + tickets: Ticket, + ticket_articles: Ticket::Article, + } + map.each { |key, class_name| + status[:counts][key] = class_name.count + last = class_name.last + status[:last_created_at][key] = if last + last.created_at + end + } + + render json: status + end + + def token + access_check + token = SecureRandom.urlsafe_base64(40) + Setting.set('monitoring_token', token) + + result = { + token: token, + } + render json: result, status: :created + end + + private + + def token_or_permission_check + user = authentication_check_only(permission: 'admin.monitoring') + return if user + return if Setting.get('monitoring_token') == params[:token] + raise Exceptions::NotAuthorized + end + + def access_check + return if Permission.find_by(name: 'admin.monitoring', active: true) + raise Exceptions::NotAuthorized + end + +end diff --git a/config/routes/monitoring.rb b/config/routes/monitoring.rb new file mode 100644 index 000000000..18f2e6c93 --- /dev/null +++ b/config/routes/monitoring.rb @@ -0,0 +1,8 @@ +Zammad::Application.routes.draw do + api_path = Rails.configuration.api_path + + match api_path + '/monitoring/health_check', to: 'monitoring#health_check', via: :get + match api_path + '/monitoring/status', to: 'monitoring#status', via: :get + match api_path + '/monitoring/token', to: 'monitoring#token', via: :post + +end diff --git a/db/migrate/20161122000001_monitoring_issue_453.rb b/db/migrate/20161122000001_monitoring_issue_453.rb new file mode 100644 index 000000000..769ea181f --- /dev/null +++ b/db/migrate/20161122000001_monitoring_issue_453.rb @@ -0,0 +1,37 @@ +class MonitoringIssue453 < ActiveRecord::Migration + def up + # return if it's a new setup + return if !Setting.find_by(name: 'system_init_done') + + Setting.create_if_not_exists( + title: 'Monitoring Token', + name: 'monitoring_token', + area: 'HealthCheck::Base', + description: 'Token for Monitoring.', + options: { + form: [ + { + display: '', + null: false, + name: 'monitoring_token', + tag: 'input', + }, + ], + }, + state: SecureRandom.urlsafe_base64(40), + preferences: { + permission: ['admin.monitoring'], + }, + frontend: false, + ) + + Permission.create_if_not_exists( + name: 'admin.monitoring', + note: 'Manage %s', + preferences: { + translations: ['Monitoring'] + }, + ) + + end +end diff --git a/db/seeds.rb b/db/seeds.rb index b584368de..5762b971d 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -1575,7 +1575,7 @@ Setting.create_if_not_exists( frontend: false ) -Setting.create_or_update( +Setting.create_if_not_exists( title: 'API Token Access', name: 'api_token_access', area: 'API::Base', @@ -1600,7 +1600,7 @@ Setting.create_or_update( }, frontend: false ) -Setting.create_or_update( +Setting.create_if_not_exists( title: 'API Password Access', name: 'api_password_access', area: 'API::Base', @@ -1626,6 +1626,28 @@ Setting.create_or_update( frontend: false ) +Setting.create_if_not_exists( + title: 'Monitoring Token', + name: 'monitoring_token', + area: 'HealthCheck::Base', + description: 'Token for Monitoring.', + options: { + form: [ + { + display: '', + null: false, + name: 'monitoring_token', + tag: 'input', + }, + ], + }, + state: SecureRandom.urlsafe_base64(40), + preferences: { + permission: ['admin.monitoring'], + }, + frontend: false +) + Setting.create_if_not_exists( title: 'Enable Chat', name: 'chat', @@ -2765,6 +2787,13 @@ Permission.create_if_not_exists( translations: ['Translations'] }, ) +Permission.create_if_not_exists( + name: 'admin.monitoring', + note: 'Manage %s', + preferences: { + translations: ['Monitoring'] + }, +) Permission.create_if_not_exists( name: 'admin.maintenance', note: 'Manage %s', diff --git a/script/build/test_slice_tests.sh b/script/build/test_slice_tests.sh index 5020c3220..6d5c0e79d 100755 --- a/script/build/test_slice_tests.sh +++ b/script/build/test_slice_tests.sh @@ -47,6 +47,8 @@ if [ "$LEVEL" == '1' ]; then # test/browser/maintenance_login_message_test.rb # test/browser/maintenance_mode_test.rb # test/browser/maintenance_session_message_test.rb + # test/browser/manage_test.rb + # test/browser/monitoring_test.rb rm test/browser/preferences_language_test.rb rm test/browser/preferences_permission_check_test.rb rm test/browser/preferences_token_access_test.rb @@ -101,6 +103,7 @@ elif [ "$LEVEL" == '2' ]; then rm test/browser/maintenance_mode_test.rb rm test/browser/maintenance_session_message_test.rb rm test/browser/manage_test.rb + rm test/browser/monitoring_test.rb rm test/browser/preferences_language_test.rb rm test/browser/preferences_permission_check_test.rb rm test/browser/preferences_token_access_test.rb @@ -155,6 +158,7 @@ elif [ "$LEVEL" == '3' ]; then rm test/browser/maintenance_mode_test.rb rm test/browser/maintenance_session_message_test.rb rm test/browser/manage_test.rb + rm test/browser/monitoring_test.rb rm test/browser/preferences_language_test.rb rm test/browser/preferences_permission_check_test.rb rm test/browser/preferences_token_access_test.rb @@ -209,6 +213,7 @@ elif [ "$LEVEL" == '4' ]; then rm test/browser/maintenance_mode_test.rb rm test/browser/maintenance_session_message_test.rb rm test/browser/manage_test.rb + rm test/browser/monitoring_test.rb rm test/browser/preferences_language_test.rb rm test/browser/preferences_permission_check_test.rb rm test/browser/preferences_token_access_test.rb @@ -262,6 +267,7 @@ elif [ "$LEVEL" == '5' ]; then rm test/browser/maintenance_mode_test.rb rm test/browser/maintenance_session_message_test.rb rm test/browser/manage_test.rb + rm test/browser/monitoring_test.rb rm test/browser/preferences_language_test.rb rm test/browser/preferences_permission_check_test.rb rm test/browser/preferences_token_access_test.rb @@ -318,6 +324,7 @@ elif [ "$LEVEL" == '6' ]; then rm test/browser/maintenance_mode_test.rb rm test/browser/maintenance_session_message_test.rb rm test/browser/manage_test.rb + rm test/browser/monitoring_test.rb # test/browser/preferences_language_test.rb # test/browser/preferences_permission_check_test.rb # test/browser/preferences_token_access_test.rb diff --git a/test/browser/monitoring_test.rb b/test/browser/monitoring_test.rb new file mode 100644 index 000000000..d4e79b4db --- /dev/null +++ b/test/browser/monitoring_test.rb @@ -0,0 +1,43 @@ +# encoding: utf-8 +require 'browser_test_helper' + +class MonitoringTest < TestCase + + def test_mode + browser1 = browser_instance + login( + browser: browser1, + username: 'master@example.com', + password: 'test', + url: browser_url, + ) + click( + browser: browser1, + css: 'a[href="#manage"]', + ) + click( + browser: browser1, + css: 'a[href="#system/monitoring"]', + ) + + token = browser1.find_elements(css: '.active.content .js-token')[0].attribute('value') + url = browser1.find_elements(css: '.active.content .js-url')[0].attribute('value') + + assert_match(token.to_s, url) + + click( + browser: browser1, + css: '.active.content .js-resetToken', + ) + sleep 3 + + token_new = browser1.find_elements(css: '.active.content .js-token')[0].attribute('value') + url_new = browser1.find_elements(css: '.active.content .js-url')[0].attribute('value') + + assert_not_equal(token, token_new) + assert_not_equal(url, url_new) + assert_match(token_new.to_s, url_new) + + end + +end diff --git a/test/controllers/monitoring_controller_test.rb b/test/controllers/monitoring_controller_test.rb new file mode 100644 index 000000000..e1ac7fe63 --- /dev/null +++ b/test/controllers/monitoring_controller_test.rb @@ -0,0 +1,393 @@ +# encoding: utf-8 +require 'test_helper' + +class MonitoringControllerTest < ActionDispatch::IntegrationTest + setup do + + # set accept header + @headers = { 'ACCEPT' => 'application/json', 'CONTENT_TYPE' => 'application/json' } + + # set token + @token = SecureRandom.urlsafe_base64(64) + Setting.set('monitoring_token', @token) + + # create agent + roles = Role.where(name: %w(Admin Agent)) + groups = Group.all + + # channel cleanup + Channel.where.not(area: 'Email::Notification').destroy_all + Channel.all.each { |channel| + channel.status_in = 'ok' + channel.status_out = 'ok' + channel.last_log_in = nil + channel.last_log_out = nil + channel.save! + } + dir = "#{Rails.root}/tmp/unprocessable_mail" + Dir.glob("#{dir}/*.eml") do |entry| + File.delete(entry) + end + + Scheduler.where(active: true).each { |scheduler| + scheduler.last_run = Time.zone.now + scheduler.save! + } + + permission = Permission.find_by(name: 'admin.monitoring') + permission.active = true + permission.save! + + UserInfo.current_user_id = 1 + @admin = User.create_or_update( + login: 'monitoring-admin', + firstname: 'Monitoring', + lastname: 'Admin', + email: 'monitoring-admin@example.com', + password: 'adminpw', + active: true, + roles: roles, + groups: groups, + ) + + # create agent + roles = Role.where(name: 'Agent') + @agent = User.create_or_update( + login: 'monitoring-agent@example.com', + firstname: 'Monitoring', + lastname: 'Agent', + email: 'monitoring-agent@example.com', + password: 'agentpw', + active: true, + roles: roles, + groups: groups, + ) + + # create customer without org + roles = Role.where(name: 'Customer') + @customer_without_org = User.create_or_update( + login: 'monitoring-customer1@example.com', + firstname: 'Monitoring', + lastname: 'Customer1', + email: 'monitoring-customer1@example.com', + password: 'customer1pw', + active: true, + roles: roles, + ) + + end + + test '01 monitoring without token' do + + # health_check + get '/api/v1/monitoring/health_check', {}, @headers + assert_response(401) + + result = JSON.parse(@response.body) + assert_equal(Hash, result.class) + assert_not(result['healthy']) + assert_equal('Not authorized', result['error']) + + # status + get '/api/v1/monitoring/status', {}, @headers + assert_response(401) + + result = JSON.parse(@response.body) + assert_equal(Hash, result.class) + assert_not(result['agents']) + assert_not(result['last_login']) + assert_not(result['counts']) + assert_not(result['last_created_at']) + assert_equal('Not authorized', result['error']) + + # token + post '/api/v1/monitoring/token', {}, @headers + assert_response(401) + + result = JSON.parse(@response.body) + assert_equal(Hash, result.class) + assert_not(result['token']) + assert_equal('authentication failed', result['error']) + + end + + test '02 monitoring with wrong token' do + + # health_check + get '/api/v1/monitoring/health_check?token=abc', {}, @headers + assert_response(401) + + result = JSON.parse(@response.body) + assert_equal(Hash, result.class) + assert_not(result['healthy']) + assert_equal('Not authorized', result['error']) + + # status + get '/api/v1/monitoring/status?token=abc', {}, @headers + assert_response(401) + + result = JSON.parse(@response.body) + assert_equal(Hash, result.class) + assert_not(result['agents']) + assert_not(result['last_login']) + assert_not(result['counts']) + assert_not(result['last_created_at']) + assert_equal('Not authorized', result['error']) + + # token + post '/api/v1/monitoring/token', { token: 'abc' }.to_json, @headers + assert_response(401) + + result = JSON.parse(@response.body) + assert_equal(Hash, result.class) + assert_not(result['token']) + assert_equal('authentication failed', result['error']) + + end + + test '03 monitoring with correct token' do + + # health_check + get "/api/v1/monitoring/health_check?token=#{@token}", {}, @headers + assert_response(200) + + result = JSON.parse(@response.body) + assert_equal(Hash, result.class) + assert_not(result['error']) + assert_equal(true, result['healthy']) + assert_equal('success', result['message']) + + # status + get "/api/v1/monitoring/status?token=#{@token}", {}, @headers + assert_response(200) + + result = JSON.parse(@response.body) + assert_equal(Hash, result.class) + assert_not(result['error']) + assert(result.key?('agents')) + assert(result.key?('last_login')) + assert(result.key?('counts')) + assert(result.key?('last_created_at')) + + # token + post '/api/v1/monitoring/token', { token: @token }.to_json, @headers + assert_response(401) + + result = JSON.parse(@response.body) + assert_equal(Hash, result.class) + assert_not(result['token']) + assert_equal('authentication failed', result['error']) + + end + + test '04 monitoring with admin user' do + + credentials = ActionController::HttpAuthentication::Basic.encode_credentials('monitoring-admin@example.com', 'adminpw') + + # health_check + get '/api/v1/monitoring/health_check', {}, @headers.merge('Authorization' => credentials) + assert_response(200) + + result = JSON.parse(@response.body) + assert_equal(Hash, result.class) + assert_not(result['error']) + assert_equal(true, result['healthy']) + assert_equal('success', result['message']) + + # status + get '/api/v1/monitoring/status', {}, @headers.merge('Authorization' => credentials) + assert_response(200) + + result = JSON.parse(@response.body) + assert_equal(Hash, result.class) + assert_not(result['error']) + assert(result.key?('agents')) + assert(result.key?('last_login')) + assert(result.key?('counts')) + assert(result.key?('last_created_at')) + + # token + post '/api/v1/monitoring/token', { token: @token }.to_json, @headers.merge('Authorization' => credentials) + assert_response(201) + + result = JSON.parse(@response.body) + assert_equal(Hash, result.class) + assert(result['token']) + @token = result['token'] + assert_not(result['error']) + + end + + test '05 monitoring with agent user' do + + credentials = ActionController::HttpAuthentication::Basic.encode_credentials('monitoring-agent@example.com', 'agentpw') + + # health_check + get '/api/v1/monitoring/health_check', {}, @headers.merge('Authorization' => credentials) + assert_response(401) + + result = JSON.parse(@response.body) + assert_equal(Hash, result.class) + assert_not(result['healthy']) + assert_equal('Not authorized (user)!', result['error']) + + # status + get '/api/v1/monitoring/status', {}, @headers.merge('Authorization' => credentials) + assert_response(401) + + result = JSON.parse(@response.body) + assert_equal(Hash, result.class) + assert_not(result['agents']) + assert_not(result['last_login']) + assert_not(result['counts']) + assert_not(result['last_created_at']) + assert_equal('Not authorized (user)!', result['error']) + + # token + post '/api/v1/monitoring/token', { token: @token }.to_json, @headers.merge('Authorization' => credentials) + assert_response(401) + + result = JSON.parse(@response.body) + assert_equal(Hash, result.class) + assert_not(result['token']) + assert_equal('Not authorized (user)!', result['error']) + + end + + test '06 monitoring with admin user and invalid permission' do + + permission = Permission.find_by(name: 'admin.monitoring') + permission.active = false + permission.save! + + credentials = ActionController::HttpAuthentication::Basic.encode_credentials('monitoring-admin@example.com', 'adminpw') + + # health_check + get '/api/v1/monitoring/health_check', {}, @headers.merge('Authorization' => credentials) + assert_response(401) + + result = JSON.parse(@response.body) + assert_equal(Hash, result.class) + assert_not(result['healthy']) + assert_equal('Not authorized (user)!', result['error']) + + # status + get '/api/v1/monitoring/status', {}, @headers.merge('Authorization' => credentials) + assert_response(401) + + result = JSON.parse(@response.body) + assert_equal(Hash, result.class) + assert_not(result['agents']) + assert_not(result['last_login']) + assert_not(result['counts']) + assert_not(result['last_created_at']) + assert_equal('Not authorized (user)!', result['error']) + + # token + post '/api/v1/monitoring/token', { token: @token }.to_json, @headers.merge('Authorization' => credentials) + assert_response(401) + + result = JSON.parse(@response.body) + assert_equal(Hash, result.class) + assert_not(result['token']) + assert_equal('Not authorized (user)!', result['error']) + + permission.active = true + permission.save! + end + + test '07 monitoring with correct token and invalid permission' do + + permission = Permission.find_by(name: 'admin.monitoring') + permission.active = false + permission.save! + + # health_check + get "/api/v1/monitoring/health_check?token=#{@token}", {}, @headers + assert_response(200) + + result = JSON.parse(@response.body) + assert_equal(Hash, result.class) + assert_not(result['error']) + assert_equal(true, result['healthy']) + assert_equal('success', result['message']) + + # status + get "/api/v1/monitoring/status?token=#{@token}", {}, @headers + assert_response(200) + + result = JSON.parse(@response.body) + assert_equal(Hash, result.class) + assert_not(result['error']) + assert(result.key?('agents')) + assert(result.key?('last_login')) + assert(result.key?('counts')) + assert(result.key?('last_created_at')) + + # token + post '/api/v1/monitoring/token', { token: @token }.to_json, @headers + assert_response(401) + + result = JSON.parse(@response.body) + assert_equal(Hash, result.class) + assert_not(result['token']) + assert_equal('authentication failed', result['error']) + + permission.active = true + permission.save! + + end + + test '08 check health false' do + + channel = Channel.find_by(active: true) + channel.status_in = 'ok' + channel.status_out = 'error' + channel.last_log_in = nil + channel.last_log_out = nil + channel.save! + + # health_check + get "/api/v1/monitoring/health_check?token=#{@token}", {}, @headers + assert_response(200) + + result = JSON.parse(@response.body) + assert_equal(Hash, result.class) + assert(result['message']) + assert(result['issues']) + assert_equal(false, result['healthy']) + assert_equal('Channel: Email::Notification out ', result['message']) + + scheduler = Scheduler.where(active: true).last + scheduler.last_run = Time.zone.now - 1.day + scheduler.period = 600 + scheduler.save! + + # health_check + get "/api/v1/monitoring/health_check?token=#{@token}", {}, @headers + assert_response(200) + + result = JSON.parse(@response.body) + assert_equal(Hash, result.class) + assert(result['message']) + assert(result['issues']) + assert_equal(false, result['healthy']) + assert_equal('Channel: Email::Notification out ;scheduler not running', result['message']) + + dir = "#{Rails.root}/tmp/unprocessable_mail" + FileUtils.touch("#{dir}/test.eml") + + # health_check + get "/api/v1/monitoring/health_check?token=#{@token}", {}, @headers + assert_response(200) + + result = JSON.parse(@response.body) + assert_equal(Hash, result.class) + assert(result['message']) + assert(result['issues']) + assert_equal(false, result['healthy']) + assert_equal('Channel: Email::Notification out ;unprocessable mails: 1;scheduler not running', result['message']) + + end + +end