Add connection test for pods in the network
* add a class for checking pod connectivity * extend pod model to handle new functionality * add an admin frontend to list pods and re-trigger checks manually * add a daily worker to run through all the pods * add unit tests for most of the new code
This commit is contained in:
parent
aeea030c9a
commit
ea397ffdfb
29 changed files with 1194 additions and 26 deletions
9
app/assets/javascripts/app/collections/pods.js
Normal file
9
app/assets/javascripts/app/collections/pods.js
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
// @license magnet:?xt=urn:btih:0b31508aeb0634b347b8270c7bee4d411b5d4109&dn=agpl-3.0.txt AGPL-v3-or-Later
|
||||
app.collections.Pods = Backbone.Collection.extend({
|
||||
model: app.models.Pod,
|
||||
|
||||
comparator: function(model) {
|
||||
return model.get("host").toLowerCase();
|
||||
}
|
||||
});
|
||||
// @license-end
|
||||
15
app/assets/javascripts/app/models/pod.js
Normal file
15
app/assets/javascripts/app/models/pod.js
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
// @license magnet:?xt=urn:btih:0b31508aeb0634b347b8270c7bee4d411b5d4109&dn=agpl-3.0.txt AGPL-v3-or-Later
|
||||
app.models.Pod = Backbone.Model.extend({
|
||||
urlRoot: Routes.adminPods(),
|
||||
|
||||
recheck: function() {
|
||||
var self = this,
|
||||
url = Routes.adminPodRecheck(this.id).toString();
|
||||
|
||||
return $.ajax({url: url, method: "POST", dataType: "json"})
|
||||
.done(function(newAttributes) {
|
||||
self.set(newAttributes);
|
||||
});
|
||||
}
|
||||
});
|
||||
// @license-end
|
||||
45
app/assets/javascripts/app/pages/admin_pods.js
Normal file
45
app/assets/javascripts/app/pages/admin_pods.js
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
// @license magnet:?xt=urn:btih:0b31508aeb0634b347b8270c7bee4d411b5d4109&dn=agpl-3.0.txt AGPL-v3-or-Later
|
||||
|
||||
app.pages.AdminPods = app.views.Base.extend({
|
||||
templateName: "pod_table",
|
||||
|
||||
initialize: function() {
|
||||
this.pods = new app.collections.Pods(app.parsePreload("pods"));
|
||||
this.rows = []; // contains the table row views
|
||||
},
|
||||
|
||||
postRenderTemplate: function() {
|
||||
var self = this;
|
||||
this._showMessages();
|
||||
|
||||
// avoid reflowing the page for every entry
|
||||
var fragment = document.createDocumentFragment();
|
||||
this.pods.each(function(pod) {
|
||||
self.rows.push(new app.views.PodEntry({
|
||||
parent: fragment,
|
||||
model: pod
|
||||
}).render());
|
||||
});
|
||||
this.$("tbody").append(fragment);
|
||||
|
||||
return this;
|
||||
},
|
||||
|
||||
_showMessages: function() {
|
||||
var msgs = document.createDocumentFragment();
|
||||
if( gon.uncheckedCount && gon.uncheckedCount > 0 ) {
|
||||
var unchecked = $("<div class='alert alert-info' />")
|
||||
.append(Diaspora.I18n.t("admin.pods.unchecked", {count: gon.uncheckedCount}));
|
||||
msgs.appendChild(unchecked[0]);
|
||||
}
|
||||
if( gon.errorCount && gon.errorCount > 0 ) {
|
||||
var errors = $("<div class='alert alert-danger' />")
|
||||
.append(Diaspora.I18n.t("admin.pods.errors", {count: gon.errorCount}));
|
||||
msgs.appendChild(errors[0]);
|
||||
}
|
||||
|
||||
$("#pod-alerts").html(msgs);
|
||||
}
|
||||
});
|
||||
|
||||
// @license-end
|
||||
|
|
@ -11,6 +11,7 @@ app.Router = Backbone.Router.extend({
|
|||
"users/sign_up": "registration",
|
||||
"profile/edit": "settings",
|
||||
"admins/dashboard": "adminDashboard",
|
||||
"admin/pods": "adminPods",
|
||||
|
||||
//new hotness
|
||||
"posts/:id": "singlePost",
|
||||
|
|
@ -52,6 +53,14 @@ app.Router = Backbone.Router.extend({
|
|||
app.page = new app.pages.AdminDashboard();
|
||||
},
|
||||
|
||||
adminPods: function() {
|
||||
this.renderPage(function() {
|
||||
return new app.pages.AdminPods({
|
||||
el: $("#pod-list")
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
contacts: function() {
|
||||
app.aspect = new app.models.Aspect(gon.preloads.aspect);
|
||||
app.contacts = new app.collections.Contacts(app.parsePreload("contacts"));
|
||||
|
|
|
|||
|
|
@ -119,12 +119,14 @@ app.views.Base = Backbone.View.extend({
|
|||
});
|
||||
},
|
||||
|
||||
destroyConfirmMsg: function() { return Diaspora.I18n.t("confirm_dialog"); },
|
||||
|
||||
destroyModel: function(evt) {
|
||||
evt && evt.preventDefault();
|
||||
var self = this;
|
||||
var url = this.model.urlRoot + '/' + this.model.id;
|
||||
|
||||
if (confirm(Diaspora.I18n.t("confirm_dialog"))) {
|
||||
if( confirm(_.result(this, "destroyConfirmMsg")) ) {
|
||||
this.$el.addClass('deleting');
|
||||
this.model.destroy({ url: url })
|
||||
.done(function() {
|
||||
|
|
|
|||
88
app/assets/javascripts/app/views/pod_entry_view.js
Normal file
88
app/assets/javascripts/app/views/pod_entry_view.js
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
// @license magnet:?xt=urn:btih:0b31508aeb0634b347b8270c7bee4d411b5d4109&dn=agpl-3.0.txt AGPL-v3-or-Later
|
||||
|
||||
app.views.PodEntry = app.views.Base.extend({
|
||||
templateName: "pod_table_entry",
|
||||
|
||||
tagName: "tr",
|
||||
|
||||
events: {
|
||||
"click .more": "toggleMore",
|
||||
"click .recheck": "recheckPod"
|
||||
},
|
||||
|
||||
className: function() {
|
||||
if( this.model.get("offline") ) { return "bg-danger"; }
|
||||
if( this.model.get("status")==="version_failed" ) { return "bg-warning"; }
|
||||
if( this.model.get("status")==="no_errors" ) { return "bg-success"; }
|
||||
},
|
||||
|
||||
initialize: function(opts) {
|
||||
this.parent = opts.parent;
|
||||
this.rendered = false;
|
||||
this.model.on("change", this.render, this);
|
||||
},
|
||||
|
||||
presenter: function() {
|
||||
return _.extend({}, this.defaultPresenter(), {
|
||||
/* jshint camelcase: false */
|
||||
is_unchecked: (this.model.get("status")==="unchecked"),
|
||||
has_no_errors: (this.model.get("status")==="no_errors"),
|
||||
has_errors: (this.model.get("status")!=="no_errors"),
|
||||
status_text: Diaspora.I18n.t("admin.pods.states."+this.model.get("status")),
|
||||
pod_url: (this.model.get("ssl") ? "https" : "http") + "://" + this.model.get("host"),
|
||||
response_time_fmt: this._fmtResponseTime()
|
||||
/* jshint camelcase: true */
|
||||
});
|
||||
},
|
||||
|
||||
postRenderTemplate: function() {
|
||||
if( !this.rendered ) {
|
||||
this.parent.appendChild(this.el);
|
||||
}
|
||||
|
||||
this.rendered = true;
|
||||
return this;
|
||||
},
|
||||
|
||||
toggleMore: function() {
|
||||
this.$(".details").toggle();
|
||||
return false;
|
||||
},
|
||||
|
||||
recheckPod: function() {
|
||||
var self = this,
|
||||
flash = new Diaspora.Widgets.FlashMessages();
|
||||
this.$el.addClass("checking");
|
||||
|
||||
this.model.recheck()
|
||||
.done(function(){
|
||||
flash.render({
|
||||
success: true,
|
||||
notice: Diaspora.I18n.t("admin.pods.recheck.success")
|
||||
});
|
||||
})
|
||||
.fail(function(){
|
||||
flash.render({
|
||||
success: false,
|
||||
notice: Diaspora.I18n.t("admin.pods.recheck.failure")
|
||||
});
|
||||
})
|
||||
.always(function(){
|
||||
self.$el
|
||||
.removeClass("bg-danger bg-warning bg-success")
|
||||
.addClass(_.result(self, "className"))
|
||||
.removeClass("checking");
|
||||
});
|
||||
|
||||
return false;
|
||||
},
|
||||
|
||||
_fmtResponseTime: function() {
|
||||
if( this.model.get("response_time")===-1 ) {
|
||||
return Diaspora.I18n.t("admin.pods.not_available");
|
||||
}
|
||||
return Diaspora.I18n.t("admin.pods.ms", {count: this.model.get("response_time")});
|
||||
}
|
||||
});
|
||||
|
||||
// @license-end
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
@import 'colors';
|
||||
@import 'new_styles/animations';
|
||||
|
||||
/** ADMIN STYlES **/
|
||||
/** user search **/
|
||||
|
|
@ -33,3 +34,33 @@
|
|||
/** reported posts **/
|
||||
|
||||
@import 'report';
|
||||
|
||||
/** pod list **/
|
||||
|
||||
#pod-list {
|
||||
th.added,
|
||||
td.added,
|
||||
td.actions { white-space: nowrap; }
|
||||
|
||||
tr.deleting {
|
||||
opacity: .5;
|
||||
}
|
||||
|
||||
tr.checking .recheck i {
|
||||
animation-duration: .4s;
|
||||
animation-name: pulsate;
|
||||
animation-iteration-count: infinite;
|
||||
animation-direction: alternate;
|
||||
}
|
||||
|
||||
td.actions {
|
||||
text-align: right;
|
||||
|
||||
a { color: inherit; }
|
||||
}
|
||||
|
||||
pre.details {
|
||||
margin-top: 1em;
|
||||
white-space: normal;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,3 +11,12 @@
|
|||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@keyframes pulsate {
|
||||
from {
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
opacity: .1;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
41
app/assets/templates/pod_table_entry_tpl.jst.hbs
Normal file
41
app/assets/templates/pod_table_entry_tpl.jst.hbs
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
|
||||
<td>
|
||||
{{#if ssl}}
|
||||
<i title="{{t 'admin.pods.ssl_enabled'}}" class="entypo-check">
|
||||
{{else}}
|
||||
<i title="{{t 'admin.pods.ssl_disabled'}}" class="entypo-block">
|
||||
{{/if}}
|
||||
</i>
|
||||
</td>
|
||||
<td>{{host}}</td>
|
||||
<td class="added">
|
||||
<small><time datetime="{{created_at}}" title="{{localTime created_at}}" /></small>
|
||||
</td>
|
||||
<td>
|
||||
{{#if has_no_errors}}
|
||||
<i title="{{status_text}}" class="glyphicon glyphicon-ok"></i>
|
||||
{{else}}
|
||||
{{status_text}}
|
||||
{{/if}}
|
||||
{{#unless is_unchecked}}
|
||||
<br><small>{{t 'admin.pods.last_check'}} <time datetime="{{checked_at}}" title="{{localTime checked_at}}" /></small>
|
||||
{{/unless}}
|
||||
{{#if offline}}
|
||||
| <small>{{t 'admin.pods.offline_since'}} <time datetime="{{offline_since}}" title="{{localTime offline_since}}" /></small>
|
||||
{{/if}}
|
||||
{{#if is_unchecked}}<br><small class="text-muted">{{t 'admin.pods.no_info'}}</small>{{/if}}
|
||||
<pre class="details" style="display: none;">
|
||||
{{#unless is_unchecked}}
|
||||
{{t 'admin.pods.server_software'}} {{#if software}}{{software}}{{else}}{{t 'admin.pods.unknown'}}{{/if}}
|
||||
<br>{{t 'admin.pods.response_time'}} {{response_time_fmt}}
|
||||
{{#if has_errors}}<br>{{error}}{{/if}}
|
||||
{{/unless}}
|
||||
</pre>
|
||||
</td>
|
||||
<td class="actions">
|
||||
{{#unless is_unchecked}}
|
||||
<a class="more" href="#"><i title="{{t 'admin.pods.more_info'}}" class="entypo-circled-help"></i></a>
|
||||
{{/unless}}
|
||||
<a class="recheck" href="{{urlTo 'adminPodRecheck' id}}"><i title="{{t 'admin.pods.check'}}" class="entypo-cycle"></i></a>
|
||||
<a href="{{pod_url}}" target="_blank"><i title="{{t 'admin.pods.follow_link'}}" class="entypo-forward"></i></a>
|
||||
</td>
|
||||
14
app/assets/templates/pod_table_tpl.jst.hbs
Normal file
14
app/assets/templates/pod_table_tpl.jst.hbs
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><i title="{{t 'admin.pods.ssl'}}" class="glyphicon glyphicon-lock"></i></th>
|
||||
<th>{{t 'admin.pods.pod'}}</th>
|
||||
<th class="added">{{t 'admin.pods.added'}}</th>
|
||||
<th>{{t 'admin.pods.status'}}</th>
|
||||
<th><i title="{{t 'admin.pods.actions'}}" class="entypo-tools pull-right"></i></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
</tbody>
|
||||
</table>
|
||||
31
app/controllers/admin/pods_controller.rb
Normal file
31
app/controllers/admin/pods_controller.rb
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
|
||||
module Admin
|
||||
class PodsController < AdminController
|
||||
respond_to :html, :json
|
||||
|
||||
def index
|
||||
pods_json = PodPresenter.as_collection(Pod.all)
|
||||
|
||||
respond_with do |format|
|
||||
format.html do
|
||||
gon.preloads[:pods] = pods_json
|
||||
gon.unchecked_count = Pod.unchecked.count
|
||||
gon.error_count = Pod.check_failed.count
|
||||
|
||||
render "admins/pods"
|
||||
end
|
||||
format.json { render json: pods_json }
|
||||
end
|
||||
end
|
||||
|
||||
def recheck
|
||||
pod = Pod.find(params[:pod_id])
|
||||
pod.test_connection!
|
||||
|
||||
respond_with do |format|
|
||||
format.html { redirect_to admin_pods_path }
|
||||
format.json { render json: PodPresenter.new(pod).as_json }
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -1,11 +1,100 @@
|
|||
class Pod < ActiveRecord::Base
|
||||
def self.find_or_create_by(opts) # Rename this method to not override an AR method
|
||||
enum status: %i(
|
||||
unchecked
|
||||
no_errors
|
||||
dns_failed
|
||||
net_failed
|
||||
ssl_failed
|
||||
http_failed
|
||||
version_failed
|
||||
unknown_error
|
||||
)
|
||||
|
||||
ERROR_MAP = {
|
||||
ConnectionTester::AddressFailure => :dns_failed,
|
||||
ConnectionTester::DNSFailure => :dns_failed,
|
||||
ConnectionTester::NetFailure => :net_failed,
|
||||
ConnectionTester::SSLFailure => :ssl_failed,
|
||||
ConnectionTester::HTTPFailure => :http_failed,
|
||||
ConnectionTester::NodeInfoFailure => :version_failed
|
||||
}
|
||||
|
||||
scope :check_failed, lambda {
|
||||
where(arel_table[:status].gt(Pod.statuses[:no_errors]))
|
||||
}
|
||||
|
||||
class << self
|
||||
def find_or_create_by(opts) # Rename this method to not override an AR method
|
||||
u = URI.parse(opts.fetch(:url))
|
||||
pod = self.find_or_initialize_by(host: u.host)
|
||||
find_or_initialize_by(host: u.host).tap do |pod|
|
||||
unless pod.persisted?
|
||||
pod.ssl = (u.scheme == 'https')? true : false
|
||||
pod.ssl = (u.scheme == "https")
|
||||
pod.save
|
||||
end
|
||||
pod
|
||||
end
|
||||
end
|
||||
|
||||
# don't consider a failed version reading to be fatal
|
||||
def offline_statuses
|
||||
[Pod.statuses[:dns_failed],
|
||||
Pod.statuses[:net_failed],
|
||||
Pod.statuses[:ssl_failed],
|
||||
Pod.statuses[:http_failed],
|
||||
Pod.statuses[:unknown_error]]
|
||||
end
|
||||
|
||||
def check_all!
|
||||
Pod.find_in_batches(batch_size: 20).each(&:test_connection!)
|
||||
end
|
||||
end
|
||||
|
||||
def offline?
|
||||
Pod.offline_statuses.include?(Pod.statuses[status])
|
||||
end
|
||||
|
||||
def was_offline?
|
||||
Pod.offline_statuses.include?(Pod.statuses[status_was])
|
||||
end
|
||||
|
||||
def test_connection!
|
||||
url = "#{ssl ? 'https' : 'http'}://#{host}"
|
||||
result = ConnectionTester.check url
|
||||
logger.info "testing pod: '#{url}' - #{result.inspect}"
|
||||
|
||||
transaction do
|
||||
update_from_result(result)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def update_from_result(result)
|
||||
self.status = status_from_result(result)
|
||||
|
||||
if offline?
|
||||
touch(:offline_since) unless was_offline?
|
||||
logger.warn "OFFLINE #{result.failure_message}"
|
||||
else
|
||||
self.offline_since = nil
|
||||
end
|
||||
|
||||
attributes_from_result(result)
|
||||
touch(:checked_at)
|
||||
|
||||
save
|
||||
end
|
||||
|
||||
def attributes_from_result(result)
|
||||
self.error = result.failure_message[0..254] if result.error?
|
||||
self.software = result.software_version[0..254] if result.software_version.present?
|
||||
self.response_time = result.rt
|
||||
end
|
||||
|
||||
def status_from_result(result)
|
||||
if result.error?
|
||||
ERROR_MAP.fetch(result.error.class, :unknown_error)
|
||||
else
|
||||
:no_errors
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
19
app/presenters/pod_presenter.rb
Normal file
19
app/presenters/pod_presenter.rb
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
class PodPresenter < BasePresenter
|
||||
def base_hash(*_arg)
|
||||
{
|
||||
id: id,
|
||||
host: host,
|
||||
ssl: ssl,
|
||||
status: status,
|
||||
checked_at: checked_at,
|
||||
response_time: response_time,
|
||||
offline: offline?,
|
||||
offline_since: offline_since,
|
||||
created_at: created_at,
|
||||
software: software,
|
||||
error: error
|
||||
}
|
||||
end
|
||||
|
||||
alias_method :as_json, :base_hash
|
||||
end
|
||||
|
|
@ -5,15 +5,17 @@
|
|||
|
||||
%ul#admin_nav.nav.nav-pills.nav-stacked
|
||||
%li{role: "presentation", class: current_page?(admin_dashboard_path) && "active"}
|
||||
= link_to t('.dashboard'), admin_dashboard_path
|
||||
= link_to t(".dashboard"), admin_dashboard_path
|
||||
%li{role: "presentation", class: current_page?(user_search_path) && "active"}
|
||||
= link_to t('.user_search'), user_search_path
|
||||
= link_to t(".user_search"), user_search_path
|
||||
%li{role: "presentation", class: current_page?(weekly_user_stats_path) && "active"}
|
||||
= link_to t('.weekly_user_stats'), weekly_user_stats_path
|
||||
= link_to t(".weekly_user_stats"), weekly_user_stats_path
|
||||
%li{role: "presentation", class: current_page?(pod_stats_path) && "active"}
|
||||
= link_to t('.pod_stats'), pod_stats_path
|
||||
= link_to t(".pod_stats"), pod_stats_path
|
||||
%li{role: "presentation", class: current_page?(report_index_path) && "active"}
|
||||
= link_to t('.report'), report_index_path
|
||||
= link_to t(".report"), report_index_path
|
||||
%li{role: "presentation", class: current_page?(admin_pods_path) && "active"}
|
||||
= link_to t(".pod_network"), admin_pods_path
|
||||
%li{role: "presentation", class: current_page?(sidekiq_path) && "active"}
|
||||
= link_to t('.sidekiq_monitor'), sidekiq_path
|
||||
= link_to t(".sidekiq_monitor"), sidekiq_path
|
||||
|
||||
|
|
|
|||
14
app/views/admins/pods.html.haml
Normal file
14
app/views/admins/pods.html.haml
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
.container
|
||||
.row
|
||||
.col-md-3
|
||||
= render partial: "admins/admin_bar"
|
||||
|
||||
.col-md-9
|
||||
%h2
|
||||
= t(".pod_network")
|
||||
|
||||
#pod-alerts
|
||||
/ filled by backbonejs
|
||||
|
||||
#pod-list
|
||||
/ filled by backbonejs
|
||||
14
app/workers/recurring_pod_check.rb
Normal file
14
app/workers/recurring_pod_check.rb
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
|
||||
module Workers
|
||||
class RecurringPodCheck < Base
|
||||
include Sidetiq::Schedulable
|
||||
|
||||
sidekiq_options queue: :maintenance
|
||||
|
||||
recurrence { daily }
|
||||
|
||||
def perform
|
||||
Pod.check_all!
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -104,6 +104,7 @@ en:
|
|||
pod_stats: "Pod stats"
|
||||
report: "Reports"
|
||||
sidekiq_monitor: "Sidekiq monitor"
|
||||
pod_network: "Pod network"
|
||||
dashboard:
|
||||
pod_status: "Pod status"
|
||||
fetching_diaspora_version: "Determining latest diaspora* version..."
|
||||
|
|
@ -173,6 +174,8 @@ en:
|
|||
current_segment: "The current segment is averaging <b>%{post_yest}</b> posts per user, from <b>%{post_day}</b>"
|
||||
50_most: "50 most popular tags"
|
||||
tag_name: "Tag name: <b>%{name_tag}</b> Count: <b>%{count_tag}</b>"
|
||||
pods:
|
||||
pod_network: "Pod network"
|
||||
application:
|
||||
helper:
|
||||
unknown_person: "Unknown person"
|
||||
|
|
|
|||
|
|
@ -35,6 +35,46 @@ en:
|
|||
outdated: "Your pod is outdated."
|
||||
compare_versions: "The latest diaspora* release is <%= latestVersion %>, your pod is running <%= podVersion %>."
|
||||
error: "Unable to determine latest diaspora* version."
|
||||
admin:
|
||||
pods:
|
||||
pod: "Pod"
|
||||
ssl: "SSL"
|
||||
ssl_enabled: "SSL enabled"
|
||||
ssl_disabled: "SSL disabled"
|
||||
added: "Added"
|
||||
status: "Status"
|
||||
states:
|
||||
unchecked: "Unchecked"
|
||||
no_errors: "OK"
|
||||
dns_failed: "Name resolution (DNS) failed"
|
||||
net_failed: "Connection attempt failed"
|
||||
ssl_failed: "Secure connection (SSL) failed"
|
||||
http_failed: "HTTP connection failed"
|
||||
version_failed: "Unable to retrieve software version"
|
||||
unknown_error: "An unspecified error has happened during the check"
|
||||
actions: "Actions"
|
||||
offline_since: "offline since:"
|
||||
last_check: "last check:"
|
||||
more_info: "show more information"
|
||||
check: "perform connection test"
|
||||
recheck:
|
||||
success: "The pod was just checked again."
|
||||
failure: "The check was not performed."
|
||||
follow_link: "open link in browser"
|
||||
no_info: "No additional information available at this point"
|
||||
server_software: "Server software:"
|
||||
response_time: "Response time:"
|
||||
ms:
|
||||
one: "<%= count %>ms"
|
||||
other: "<%= count %>ms"
|
||||
unknown: "unknown"
|
||||
not_available: "not available"
|
||||
unchecked:
|
||||
one: "There is still one pod that hasn't been checked at all."
|
||||
other: "There are still <%= count %> pods that haven't been checked at all."
|
||||
errors:
|
||||
one: "The connection test returned an error for one pod."
|
||||
other: "The connection test returned an error for <%= count %> pods."
|
||||
|
||||
aspects:
|
||||
make_aspect_list_visible: "Make contacts in this aspect visible to each other?"
|
||||
|
|
|
|||
|
|
@ -148,6 +148,10 @@ Diaspora::Application.routes.draw do
|
|||
end
|
||||
|
||||
namespace :admin do
|
||||
resources :pods, only: :index do
|
||||
post :recheck
|
||||
end
|
||||
|
||||
post 'users/:id/close_account' => 'users#close_account', :as => 'close_account'
|
||||
post 'users/:id/lock_account' => 'users#lock_account', :as => 'lock_account'
|
||||
post 'users/:id/unlock_account' => 'users#unlock_account', :as => 'unlock_account'
|
||||
|
|
|
|||
14
db/migrate/20150731123114_add_status_to_pods.rb
Normal file
14
db/migrate/20150731123114_add_status_to_pods.rb
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
class AddStatusToPods < ActiveRecord::Migration
|
||||
def change
|
||||
add_column :pods, :status, :integer, default: 0
|
||||
add_column :pods, :checked_at, :datetime, default: Time.zone.at(0)
|
||||
add_column :pods, :offline_since, :datetime, default: nil
|
||||
add_column :pods, :response_time, :integer, default: -1
|
||||
add_column :pods, :software, :string, limit: 255
|
||||
add_column :pods, :error, :string, limit: 255
|
||||
|
||||
add_index :pods, :status
|
||||
add_index :pods, :checked_at
|
||||
add_index :pods, :offline_since
|
||||
end
|
||||
end
|
||||
12
db/schema.rb
12
db/schema.rb
|
|
@ -11,7 +11,7 @@
|
|||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema.define(version: 20150724152052) do
|
||||
ActiveRecord::Schema.define(version: 20150731123114) do
|
||||
|
||||
create_table "account_deletions", force: :cascade do |t|
|
||||
t.string "diaspora_handle", limit: 255
|
||||
|
|
@ -310,8 +310,18 @@ ActiveRecord::Schema.define(version: 20150724152052) do
|
|||
t.boolean "ssl"
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.integer "status", limit: 4, default: 0
|
||||
t.datetime "checked_at", default: '1970-01-01 00:00:00'
|
||||
t.datetime "offline_since"
|
||||
t.integer "response_time", limit: 4, default: -1
|
||||
t.string "software", limit: 255
|
||||
t.string "error", limit: 255
|
||||
end
|
||||
|
||||
add_index "pods", ["checked_at"], name: "index_pods_on_checked_at", using: :btree
|
||||
add_index "pods", ["offline_since"], name: "index_pods_on_offline_since", using: :btree
|
||||
add_index "pods", ["status"], name: "index_pods_on_status", using: :btree
|
||||
|
||||
create_table "poll_answers", force: :cascade do |t|
|
||||
t.string "answer", limit: 255, null: false
|
||||
t.integer "poll_id", limit: 4, null: false
|
||||
|
|
|
|||
262
lib/connection_tester.rb
Normal file
262
lib/connection_tester.rb
Normal file
|
|
@ -0,0 +1,262 @@
|
|||
|
||||
class ConnectionTester
|
||||
NODEINFO_SCHEMA = "http://nodeinfo.diaspora.software/ns/schema/1.0"
|
||||
NODEINFO_FRAGMENT = "/.well-known/nodeinfo"
|
||||
|
||||
class << self
|
||||
# Test the reachability of a server by the given HTTP/S URL.
|
||||
# In the first step, a DNS query is performed to check whether the
|
||||
# given name even resolves correctly.
|
||||
# The second step is to send a HTTP request and look at the returned
|
||||
# status code or any returned errors.
|
||||
# This function isn't intended to check for the availability of a
|
||||
# specific page, instead a GET request is sent to the root directory
|
||||
# of the server.
|
||||
# In the third step an attempt is made to determine the software version
|
||||
# used on the server, via the nodeinfo page.
|
||||
#
|
||||
# @api This is the entry point you're supposed to use for testing
|
||||
# connections to other diaspora-compatible servers.
|
||||
# @param [String] server URL
|
||||
# @return [Result] result object containing information about the
|
||||
# server and to what point the connection was successful
|
||||
def check(url)
|
||||
url = "http://#{url}" unless url.include?("://")
|
||||
result = Result.new
|
||||
|
||||
begin
|
||||
ct = ConnectionTester.new(url, result)
|
||||
|
||||
# test DNS resolving
|
||||
ct.resolve
|
||||
|
||||
# test HTTP request
|
||||
ct.request
|
||||
|
||||
# test for the diaspora* version
|
||||
ct.nodeinfo
|
||||
|
||||
rescue Failure => e
|
||||
result_from_failure(result, e)
|
||||
end
|
||||
|
||||
result.freeze
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# infer some attributes of the result object based on the failure
|
||||
def result_from_failure(result, error)
|
||||
result.error = error
|
||||
|
||||
case error
|
||||
when AddressFailure, DNSFailure, NetFailure
|
||||
result.reachable = false
|
||||
when SSLFailure
|
||||
result.reachable = true
|
||||
result.ssl_status = false
|
||||
when HTTPFailure
|
||||
result.reachable = true
|
||||
when NodeInfoFailure
|
||||
result.software_version = ""
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# @raise [AddressFailure] if the specified url is not http(s)
|
||||
def initialize(url, result=Result.new)
|
||||
@url ||= url
|
||||
@result ||= result
|
||||
@uri ||= URI.parse(@url)
|
||||
raise AddressFailure,
|
||||
"invalid protocol: '#{@uri.scheme.upcase}'" unless http_uri?(@uri)
|
||||
|
||||
result.hostname = @uri.host
|
||||
rescue URI::InvalidURIError => e
|
||||
raise AddressFailure, e.message
|
||||
end
|
||||
|
||||
# Perform the DNS query, the IP address will be stored in the result
|
||||
# @raise [DNSFailure] caused by a failure to resolve or a timeout
|
||||
def resolve
|
||||
with_dns_resolver do |dns|
|
||||
addr = dns.getaddress(@uri.host)
|
||||
@result.ip = addr.to_s
|
||||
end
|
||||
rescue Resolv::ResolvError, Resolv::ResolvTimeout => e
|
||||
raise DNSFailure, "'#{@uri.host}' - #{e.message}"
|
||||
end
|
||||
|
||||
# Perform a HTTP GET request to determine the following information
|
||||
# * is the host reachable
|
||||
# * is port 80/443 open
|
||||
# * is the SSL certificate valid (only on HTTPS)
|
||||
# * does the server return a successful HTTP status code
|
||||
# * is there a reasonable amount of redirects (3 by default)
|
||||
# (can't do a HEAD request, since that's not a defined route in the app)
|
||||
#
|
||||
# @raise [NetFailure, SSLFailure, HTTPFailure] if any of the checks fail
|
||||
# @return [Integer] HTTP status code
|
||||
def request
|
||||
with_http_connection do |http|
|
||||
response = capture_response_time { http.get("/") }
|
||||
handle_http_response(response)
|
||||
end
|
||||
rescue Faraday::ConnectionFailed, Faraday::TimeoutError => e
|
||||
raise NetFailure, e.message
|
||||
rescue Faraday::SSLError => e
|
||||
raise SSLFailure, e.message
|
||||
rescue ArgumentError, FaradayMiddleware::RedirectLimitReached, Faraday::ClientError => e
|
||||
raise HTTPFailure, e.message
|
||||
end
|
||||
|
||||
# Try to find out the version of the other servers software.
|
||||
# Assuming the server speaks nodeinfo
|
||||
#
|
||||
# @raise [NodeInfoFailure] if the document can't be fetched
|
||||
# or the attempt to parse it failed
|
||||
def nodeinfo
|
||||
with_http_connection do |http|
|
||||
ni_resp = http.get(NODEINFO_FRAGMENT)
|
||||
nd_resp = http.get(find_nodeinfo_url(ni_resp.body))
|
||||
find_software_version(nd_resp.body)
|
||||
end
|
||||
rescue Faraday::ResourceNotFound, KeyError, JSON::JSONError => e
|
||||
raise NodeInfoFailure, e.message
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def with_http_connection
|
||||
@http ||= Faraday.new(@url) do |c|
|
||||
c.use Faraday::Response::RaiseError
|
||||
c.use FaradayMiddleware::FollowRedirects, limit: 3
|
||||
c.adapter(Faraday.default_adapter)
|
||||
c.headers[:user_agent] = "diaspora-connection-tester"
|
||||
c.options.timeout = 12
|
||||
c.options.open_timeout = 6
|
||||
# use the configured CA
|
||||
c.ssl.ca_file = Faraday.default_connection.ssl.ca_file
|
||||
end
|
||||
yield(@http) if block_given?
|
||||
end
|
||||
|
||||
def with_dns_resolver
|
||||
dns = Resolv::DNS.new
|
||||
yield(dns) if block_given?
|
||||
ensure
|
||||
dns.close
|
||||
end
|
||||
|
||||
def http_uri?(uri)
|
||||
uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
|
||||
end
|
||||
|
||||
def uses_ssl?
|
||||
@uses_ssl
|
||||
end
|
||||
|
||||
# request root path, measure response time
|
||||
# measured time may be skewed, if there are redirects
|
||||
#
|
||||
# @return [Faraday::Response]
|
||||
def capture_response_time
|
||||
start = Time.zone.now
|
||||
resp = yield if block_given?
|
||||
@result.rt = ((Time.zone.now - start) * 1000.0).to_i # milliseconds
|
||||
resp
|
||||
end
|
||||
|
||||
def handle_http_response(response)
|
||||
@uses_ssl = (response.env.url.scheme == "https")
|
||||
@result.status_code = Integer(response.status)
|
||||
|
||||
if response.success?
|
||||
@result.reachable = true
|
||||
@result.ssl_status = @uses_ssl
|
||||
else
|
||||
raise HTTPFailure, "unsuccessful response code: #{response.status}"
|
||||
end
|
||||
end
|
||||
|
||||
# walk the JSON document, get the actual document location
|
||||
def find_nodeinfo_url(body)
|
||||
links = JSON.parse(body)
|
||||
links.fetch("links").find { |entry|
|
||||
entry.fetch("rel") == NODEINFO_SCHEMA
|
||||
}.fetch("href")
|
||||
end
|
||||
|
||||
# walk the JSON document, find the version string
|
||||
def find_software_version(body)
|
||||
info = JSON.parse(body)
|
||||
sw = info.fetch("software")
|
||||
@result.software_version = "#{sw.fetch('name')} #{sw.fetch('version')}"
|
||||
end
|
||||
|
||||
class Failure < StandardError
|
||||
end
|
||||
|
||||
class AddressFailure < Failure
|
||||
end
|
||||
|
||||
class DNSFailure < Failure
|
||||
end
|
||||
|
||||
class NetFailure < Failure
|
||||
end
|
||||
|
||||
class SSLFailure < Failure
|
||||
end
|
||||
|
||||
class HTTPFailure < Failure
|
||||
end
|
||||
|
||||
class NodeInfoFailure < Failure
|
||||
end
|
||||
|
||||
Result = Struct.new(
|
||||
:hostname, :ip, :reachable, :ssl_status, :status_code, :rt, :software_version, :error
|
||||
) do
|
||||
# @!attribute hostname
|
||||
# @return [String] hostname derived from the URL
|
||||
|
||||
# @!attribute ip
|
||||
# @return [String] resolved IP address from DNS query
|
||||
|
||||
# @!attribute reachable
|
||||
# @return [Boolean] whether the host was reachable over the network
|
||||
|
||||
# @!attribute ssl_status
|
||||
# @return [Boolean] indicating how the SSL verification went
|
||||
|
||||
# @!attribute status_code
|
||||
# @return [Integer] HTTP status code that was returned for the HEAD request
|
||||
|
||||
# @!attribute rt
|
||||
# @return [Integer] response time for the HTTP request
|
||||
|
||||
# @!attribute software_version
|
||||
# @return [String] version of diaspora* as reported by nodeinfo
|
||||
|
||||
# @!attribute error
|
||||
# @return [Exception] if the test is unsuccessful, this will contain
|
||||
# an exception of type {ConnectionTester::Failure}
|
||||
|
||||
def initialize
|
||||
self.rt = -1
|
||||
end
|
||||
|
||||
def success?
|
||||
error.nil?
|
||||
end
|
||||
|
||||
def error?
|
||||
!error.nil?
|
||||
end
|
||||
|
||||
def failure_message
|
||||
"#{error.class.name}: #{error.message}" if error?
|
||||
end
|
||||
end
|
||||
end
|
||||
52
spec/controllers/admin/pods_controller_spec.rb
Normal file
52
spec/controllers/admin/pods_controller_spec.rb
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
|
||||
require "spec_helper"
|
||||
|
||||
describe Admin::PodsController, type: :controller do
|
||||
before do
|
||||
@user = FactoryGirl.create :user
|
||||
Role.add_admin(@user.person)
|
||||
|
||||
sign_in :user, @user
|
||||
end
|
||||
|
||||
describe "#index" do
|
||||
it "renders the pod list template" do
|
||||
get :index
|
||||
expect(response).to render_template("admins/pods")
|
||||
expect(response.body).to match(/id='pod-alerts'/im)
|
||||
expect(response.body).to match(/id='pod-list'/im)
|
||||
end
|
||||
|
||||
it "contains the preloads" do
|
||||
get :index
|
||||
expect(response.body).to match(/uncheckedCount=/im)
|
||||
expect(response.body).to match(/errorCount=/im)
|
||||
expect(response.body).to match(/preloads.*"pods"\s?\:/im)
|
||||
end
|
||||
|
||||
it "returns the json data" do
|
||||
@pods = (0..2).map { FactoryGirl.create(:pod).reload } # normalize timestamps
|
||||
get :index, format: :json
|
||||
|
||||
expect(response.body).to eql(PodPresenter.as_collection(@pods).to_json)
|
||||
end
|
||||
end
|
||||
|
||||
describe "#recheck" do
|
||||
before do
|
||||
@pod = FactoryGirl.create(:pod).reload
|
||||
allow(Pod).to receive(:find) { @pod }
|
||||
expect(@pod).to receive(:test_connection!)
|
||||
end
|
||||
|
||||
it "performs a connection test" do
|
||||
post :recheck, pod_id: 1
|
||||
expect(response).to be_redirect
|
||||
end
|
||||
|
||||
it "performs a connection test (format: json)" do
|
||||
post :recheck, pod_id: 1, format: :json
|
||||
expect(response.body).to eql(PodPresenter.new(@pod).to_json)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -210,6 +210,11 @@ FactoryGirl.define do
|
|||
photo_url "/assets/user/adams.jpg"
|
||||
end
|
||||
|
||||
factory :pod do
|
||||
host "pod.example.com"
|
||||
ssl true
|
||||
end
|
||||
|
||||
factory(:comment) do
|
||||
sequence(:text) {|n| "#{n} cats"}
|
||||
association(:author, :factory => :person)
|
||||
|
|
|
|||
45
spec/javascripts/app/models/pod_spec.js
Normal file
45
spec/javascripts/app/models/pod_spec.js
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
describe("app.model.Pod", function() {
|
||||
var podId = 123;
|
||||
|
||||
beforeEach(function() {
|
||||
this.pod = new app.models.Pod({
|
||||
id: podId,
|
||||
host: "pod.example.com",
|
||||
status: "unchecked",
|
||||
/* jshint camelcase: false */
|
||||
checked_at: null
|
||||
/* jshint camelcase: true */
|
||||
});
|
||||
});
|
||||
|
||||
describe("recheck", function() {
|
||||
var newAttributes = {
|
||||
id: podId,
|
||||
status: "no_errors",
|
||||
/* jshint camelcase: false */
|
||||
checked_at: new Date()
|
||||
/* jshint camelcase: true */
|
||||
};
|
||||
var ajaxSuccess = {
|
||||
status: 200,
|
||||
responseText: JSON.stringify(newAttributes)
|
||||
};
|
||||
|
||||
it("calls the recheck action on the server", function() {
|
||||
var expected = Routes.adminPodRecheck(podId);
|
||||
this.pod.recheck();
|
||||
expect(jasmine.Ajax.requests.mostRecent().url).toEqual(expected);
|
||||
});
|
||||
|
||||
it("updates the model attributes from the response", function() {
|
||||
spyOn(this.pod, "set").and.callThrough();
|
||||
expect(this.pod.get("status")).toEqual("unchecked");
|
||||
this.pod.recheck();
|
||||
jasmine.Ajax.requests.mostRecent().respondWith(ajaxSuccess);
|
||||
|
||||
expect(this.pod.set).toHaveBeenCalled();
|
||||
expect(this.pod.get("status")).toEqual("no_errors");
|
||||
expect(this.pod.get("checked_at")).not.toEqual(null);
|
||||
});
|
||||
});
|
||||
});
|
||||
91
spec/javascripts/app/views/pod_entry_view_spec.js
Normal file
91
spec/javascripts/app/views/pod_entry_view_spec.js
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
|
||||
describe("app.views.PodEntry", function() {
|
||||
beforeEach(function() {
|
||||
this.pod = new app.models.Pod({id : 123});
|
||||
this.view = new app.views.PodEntry({
|
||||
model: this.pod,
|
||||
parent: document.createDocumentFragment()
|
||||
});
|
||||
});
|
||||
|
||||
describe("className", function() {
|
||||
it("returns danger bg when offline", function() {
|
||||
this.pod.set("offline", true);
|
||||
expect(this.view.className()).toEqual("bg-danger");
|
||||
});
|
||||
|
||||
it("returns warning bg when version unknown", function() {
|
||||
this.pod.set("status", "version_failed");
|
||||
expect(this.view.className()).toEqual("bg-warning");
|
||||
});
|
||||
|
||||
it("returns success bg for no errors", function() {
|
||||
this.pod.set("status", "no_errors");
|
||||
expect(this.view.className()).toEqual("bg-success");
|
||||
});
|
||||
});
|
||||
|
||||
describe("presenter", function() {
|
||||
it("contains calculated attributes", function() {
|
||||
this.pod.set({
|
||||
status: "no_errors",
|
||||
ssl: true,
|
||||
host: "pod.example.com"
|
||||
});
|
||||
var actual = this.view.presenter();
|
||||
expect(actual).toEqual(jasmine.objectContaining({
|
||||
/* jshint camelcase: false */
|
||||
is_unchecked: false,
|
||||
has_no_errors: true,
|
||||
has_errors: false,
|
||||
status_text: jasmine.anything(),
|
||||
response_time_fmt: jasmine.anything(),
|
||||
pod_url: "https://pod.example.com"
|
||||
/* jshint camelcase: true */
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
describe("postRenderTemplate", function() {
|
||||
it("appends itself to the parent", function() {
|
||||
var childCount = $(this.view.parent).children().length;
|
||||
this.view.render();
|
||||
expect($(this.view.parent).children().length).toEqual(childCount+1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("recheckPod", function() {
|
||||
var ajaxSuccess = { status: 200, responseText: "{}" };
|
||||
var ajaxFail = { status: 400 };
|
||||
|
||||
it("calls .recheck() on the model", function() {
|
||||
spyOn(this.pod, "recheck").and.returnValue($.Deferred());
|
||||
this.view.recheckPod();
|
||||
expect(this.pod.recheck).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("renders a success flash message", function() {
|
||||
this.view.recheckPod();
|
||||
jasmine.Ajax.requests.mostRecent().respondWith(ajaxSuccess);
|
||||
expect($("[id^=\"flash\"]")).toBeSuccessFlashMessage();
|
||||
});
|
||||
|
||||
it("renders an error flash message", function() {
|
||||
this.view.recheckPod();
|
||||
jasmine.Ajax.requests.mostRecent().respondWith(ajaxFail);
|
||||
expect($("[id^=\"flash\"]")).toBeErrorFlashMessage();
|
||||
});
|
||||
|
||||
it("sets the appropriate CSS class", function() {
|
||||
this.view.$el.addClass("bg-danger");
|
||||
this.pod.set({ offline: false, status: "no_errors" });
|
||||
|
||||
this.view.recheckPod();
|
||||
expect(this.view.$el.attr("class")).toContain("checking");
|
||||
jasmine.Ajax.requests.mostRecent().respondWith(ajaxSuccess);
|
||||
expect(this.view.$el.attr("class")).toContain("bg-success");
|
||||
expect(this.view.$el.attr("class")).not.toContain("checking");
|
||||
expect(this.view.$el.attr("class")).not.toContain("bg-danger");
|
||||
});
|
||||
});
|
||||
});
|
||||
130
spec/lib/connection_tester_spec.rb
Normal file
130
spec/lib/connection_tester_spec.rb
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
|
||||
require "spec_helper"
|
||||
|
||||
describe ConnectionTester do
|
||||
describe "::check" do
|
||||
it "takes a http url and returns a result object" do
|
||||
res = ConnectionTester.check("https://pod.example.com")
|
||||
expect(res).to be_a(ConnectionTester::Result)
|
||||
end
|
||||
|
||||
it "still returns a result object, even for invalid urls" do
|
||||
res = ConnectionTester.check("i:am/not)a+url")
|
||||
expect(res).to be_a(ConnectionTester::Result)
|
||||
expect(res.error).to be_a(ConnectionTester::Failure)
|
||||
end
|
||||
end
|
||||
|
||||
describe "#initialize" do
|
||||
it "accepts the http protocol" do
|
||||
expect {
|
||||
ConnectionTester.new("https://pod.example.com")
|
||||
}.not_to raise_error
|
||||
end
|
||||
it "rejects unexpected protocols" do
|
||||
expect {
|
||||
ConnectionTester.new("xmpp:user@example.com")
|
||||
}.to raise_error(ConnectionTester::AddressFailure)
|
||||
end
|
||||
end
|
||||
|
||||
describe "#resolve" do
|
||||
before do
|
||||
@result = ConnectionTester::Result.new
|
||||
@dns = instance_double("Resolv::DNS")
|
||||
allow(@dns).to receive(:close).once
|
||||
end
|
||||
|
||||
it "resolves the IP address" do
|
||||
tester = ConnectionTester.new("https://pod.example.com", @result)
|
||||
expect(tester).to receive(:with_dns_resolver).and_yield(@dns)
|
||||
expect(@dns).to receive(:getaddress).and_return("192.168.1.2")
|
||||
|
||||
tester.resolve
|
||||
expect(@result.ip).to eq("192.168.1.2")
|
||||
end
|
||||
end
|
||||
|
||||
describe "#request" do
|
||||
before do
|
||||
@url = "https://pod.example.com"
|
||||
@stub =
|
||||
@result = ConnectionTester::Result.new
|
||||
@tester = ConnectionTester.new(@url, @result)
|
||||
end
|
||||
|
||||
it "performs a successful GET request on '/'" do
|
||||
stub_request(:get, @url).to_return(status: 200, body: "Hello World!")
|
||||
|
||||
@tester.request
|
||||
expect(@result.rt).to be > -1
|
||||
expect(@result.reachable).to be_truthy
|
||||
expect(@result.ssl_status).to be_truthy
|
||||
end
|
||||
|
||||
it "receives a 'normal' 301 redirect" do
|
||||
stub_request(:get, @url).to_return(status: 301, headers: {"Location" => "#{@url}/redirect"})
|
||||
stub_request(:get, "#{@url}/redirect").to_return(status: 200, body: "Hello World!")
|
||||
|
||||
@tester.request
|
||||
end
|
||||
|
||||
it "receives too many 301 redirects" do
|
||||
stub_request(:get, @url).to_return(status: 301, headers: {"Location" => "#{@url}/redirect"})
|
||||
stub_request(:get, "#{@url}/redirect").to_return(status: 301, headers: {"Location" => "#{@url}/redirect1"})
|
||||
stub_request(:get, "#{@url}/redirect1").to_return(status: 301, headers: {"Location" => "#{@url}/redirect2"})
|
||||
stub_request(:get, "#{@url}/redirect2").to_return(status: 301, headers: {"Location" => "#{@url}/redirect3"})
|
||||
stub_request(:get, "#{@url}/redirect3").to_return(status: 200, body: "Hello World!")
|
||||
|
||||
expect { @tester.request }.to raise_error(ConnectionTester::HTTPFailure)
|
||||
end
|
||||
|
||||
it "receives a 404 not found" do
|
||||
stub_request(:get, @url).to_return(status: 404, body: "Not Found!")
|
||||
expect { @tester.request }.to raise_error(ConnectionTester::HTTPFailure)
|
||||
end
|
||||
|
||||
it "cannot connect" do
|
||||
stub_request(:get, @url).to_raise(Faraday::ConnectionFailed.new("Error!"))
|
||||
expect { @tester.request }.to raise_error(ConnectionTester::NetFailure)
|
||||
end
|
||||
|
||||
it "encounters an invalid SSL setup" do
|
||||
stub_request(:get, @url).to_raise(Faraday::SSLError.new("Error!"))
|
||||
expect { @tester.request }.to raise_error(ConnectionTester::SSLFailure)
|
||||
end
|
||||
end
|
||||
|
||||
describe "#nodeinfo" do
|
||||
before do
|
||||
@url = "https://diaspora.example.com"
|
||||
@result = ConnectionTester::Result.new
|
||||
@tester = ConnectionTester.new(@url, @result)
|
||||
|
||||
@ni_wellknown = {links: [{rel: ConnectionTester::NODEINFO_SCHEMA,
|
||||
href: "/nodeinfo"}]}
|
||||
@ni_document = {software: {name: "diaspora", version: "a.b.c.d"}}
|
||||
end
|
||||
|
||||
it "reads the version from the nodeinfo document" do
|
||||
stub_request(:get, "#{@url}#{ConnectionTester::NODEINFO_FRAGMENT}")
|
||||
.to_return(status: 200, body: JSON.generate(@ni_wellknown))
|
||||
stub_request(:get, "#{@url}/nodeinfo").to_return(status: 200, body: JSON.generate(@ni_document))
|
||||
|
||||
@tester.nodeinfo
|
||||
expect(@result.software_version).to eq("diaspora a.b.c.d")
|
||||
end
|
||||
|
||||
it "handles a missing nodeinfo document gracefully" do
|
||||
stub_request(:get, "#{@url}#{ConnectionTester::NODEINFO_FRAGMENT}")
|
||||
.to_return(status: 404, body: "Not Found")
|
||||
expect { @tester.nodeinfo }.to raise_error(ConnectionTester::NodeInfoFailure)
|
||||
end
|
||||
|
||||
it "handles a malformed document gracefully" do
|
||||
stub_request(:get, "#{@url}#{ConnectionTester::NODEINFO_FRAGMENT}")
|
||||
.to_return(status: 200, body: '{"json"::::"malformed"}')
|
||||
expect { @tester.nodeinfo }.to raise_error(ConnectionTester::NodeInfoFailure)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -1,15 +1,78 @@
|
|||
require 'spec_helper'
|
||||
require "spec_helper"
|
||||
|
||||
describe Pod, :type => :model do
|
||||
describe '.find_or_create_by' do
|
||||
it 'takes a url, and makes one by host' do
|
||||
pod = Pod.find_or_create_by(url: 'https://joindiaspora.com/maxwell')
|
||||
expect(pod.host).to eq('joindiaspora.com')
|
||||
describe Pod, type: :model do
|
||||
describe "::find_or_create_by" do
|
||||
it "takes a url, and makes one by host" do
|
||||
pod = Pod.find_or_create_by(url: "https://joindiaspora.com/maxwell")
|
||||
expect(pod.host).to eq("joindiaspora.com")
|
||||
end
|
||||
|
||||
it 'sets ssl boolean(side-effect)' do
|
||||
pod = Pod.find_or_create_by(url: 'https://joindiaspora.com/maxwell')
|
||||
it "sets ssl boolean (side-effect)" do
|
||||
pod = Pod.find_or_create_by(url: "https://joindiaspora.com/maxwell")
|
||||
expect(pod.ssl).to be true
|
||||
end
|
||||
end
|
||||
|
||||
describe "::check_all!" do
|
||||
before do
|
||||
@pods = (0..4).map do
|
||||
double("pod").tap do |pod|
|
||||
expect(pod).to receive(:test_connection!)
|
||||
end
|
||||
end
|
||||
allow(Pod).to receive(:find_in_batches) { @pods }
|
||||
end
|
||||
|
||||
it "calls #test_connection! on every pod" do
|
||||
Pod.check_all!
|
||||
end
|
||||
end
|
||||
|
||||
describe "#test_connection!" do
|
||||
before do
|
||||
@pod = FactoryGirl.create(:pod)
|
||||
@result = double("result")
|
||||
|
||||
allow(@result).to receive(:rt) { 123 }
|
||||
allow(@result).to receive(:software_version) { "diaspora a.b.c.d" }
|
||||
allow(@result).to receive(:failure_message) { "hello error!" }
|
||||
|
||||
expect(ConnectionTester).to receive(:check).at_least(:once).and_return(@result)
|
||||
end
|
||||
|
||||
it "updates the connectivity values" do
|
||||
allow(@result).to receive(:error)
|
||||
allow(@result).to receive(:error?)
|
||||
@pod.test_connection!
|
||||
|
||||
expect(@pod.status).to eq("no_errors")
|
||||
expect(@pod.offline?).to be_falsy
|
||||
expect(@pod.response_time).to eq(123)
|
||||
expect(@pod.checked_at).to be_within(1.second).of Time.zone.now
|
||||
end
|
||||
|
||||
it "handles a failed check" do
|
||||
expect(@result).to receive(:error?).at_least(:once) { true }
|
||||
expect(@result).to receive(:error).at_least(:once) { ConnectionTester::NetFailure.new }
|
||||
@pod.test_connection!
|
||||
|
||||
expect(@pod.offline?).to be_truthy
|
||||
expect(@pod.offline_since).to be_within(1.second).of Time.zone.now
|
||||
end
|
||||
|
||||
it "preserves the original offline timestamp" do
|
||||
expect(@result).to receive(:error?).at_least(:once) { true }
|
||||
expect(@result).to receive(:error).at_least(:once) { ConnectionTester::NetFailure.new }
|
||||
@pod.test_connection!
|
||||
|
||||
now = Time.zone.now
|
||||
expect(@pod.offline_since).to be_within(1.second).of now
|
||||
|
||||
Timecop.travel(Time.zone.today + 30.days) do
|
||||
@pod.test_connection!
|
||||
expect(@pod.offline_since).to be_within(1.second).of now
|
||||
expect(Time.zone.now).to be_within(1.day).of(now + 30.days)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
17
spec/workers/recurring_pod_check_spec.rb
Normal file
17
spec/workers/recurring_pod_check_spec.rb
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
|
||||
require "spec_helper"
|
||||
|
||||
describe Workers::RecurringPodCheck do
|
||||
before do
|
||||
@pods = (0..4).map do
|
||||
FactoryGirl.create(:pod).tap { |pod|
|
||||
expect(pod).to receive(:test_connection!)
|
||||
}
|
||||
end
|
||||
allow(Pod).to receive(:find_in_batches) { @pods }
|
||||
end
|
||||
|
||||
it "performs a connection test on all existing pods" do
|
||||
Workers::RecurringPodCheck.new.perform
|
||||
end
|
||||
end
|
||||
Loading…
Reference in a new issue