diff --git a/Gemfile b/Gemfile index 03a19aee1..09d12abb3 100644 --- a/Gemfile +++ b/Gemfile @@ -22,6 +22,7 @@ gem "redcarpet" gem "font-awesome-rails" gem "bootstrap-typeahead-rails" gem "rails_stdout_logging", "~> 0.0.5", group: [:development, :staging, :production] +gem "typhoeus" # Used to store application tokens. This is already a Rails depedency. However # better safe than sorry... diff --git a/Gemfile.lock b/Gemfile.lock index e0c455439..d8b529eb5 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -93,7 +93,7 @@ GEM execjs coffee-script-source (1.9.1.1) columnize (0.9.0) - crack (0.4.2) + crack (0.4.3) safe_yaml (~> 1.0.0) crono (0.9.0) activerecord (~> 4.0) @@ -113,6 +113,8 @@ GEM excon (>= 0.38.0) json erubis (2.7.0) + ethon (0.8.1) + ffi (>= 1.3.0) excon (0.49.0) execjs (2.2.2) factory_girl (4.5.0) @@ -355,6 +357,8 @@ GEM polyglot (~> 0.3) turbolinks (2.5.3) coffee-rails + typhoeus (1.0.1) + ethon (>= 0.8.0) tzinfo (1.2.2) thread_safe (~> 0.1) uglifier (2.7.2) @@ -441,6 +445,7 @@ DEPENDENCIES thor timecop turbolinks + typhoeus uglifier vcr web-console (~> 2.1.3) diff --git a/app/controllers/api/v2/events_controller.rb b/app/controllers/api/v2/events_controller.rb index 7de3363c0..0a9a1f9dc 100644 --- a/app/controllers/api/v2/events_controller.rb +++ b/app/controllers/api/v2/events_controller.rb @@ -3,7 +3,7 @@ class Api::V2::EventsController < Api::BaseController # A new notification is coming, register it if valid. def create body = JSON.parse(request.body.read) - Portus::RegistryNotification.process!(body, Repository) + Portus::RegistryNotification.process!(body, Repository, Webhook) head status: :accepted end end diff --git a/app/controllers/webhook_deliveries_controller.rb b/app/controllers/webhook_deliveries_controller.rb new file mode 100644 index 000000000..e618946da --- /dev/null +++ b/app/controllers/webhook_deliveries_controller.rb @@ -0,0 +1,18 @@ +# WebhookDeliveriesController manages the updates of webhook deliveries. +class WebhookDeliveriesController < ApplicationController + respond_to :html, :js + + after_action :verify_authorized + + # PATCH/PUT /namespaces/1/webhooks/1/deliveries/1 + def update + namespace = Namespace.find(params[:namespace_id]) + webhook = namespace.webhooks.find(params[:webhook_id]) + webhook_delivery = webhook.deliveries.find(params[:id]) + + authorize webhook_delivery + + webhook_delivery.retrigger + render template: "webhooks/retrigger", locals: { webhook_delivery: webhook_delivery } + end +end diff --git a/app/controllers/webhook_headers_controller.rb b/app/controllers/webhook_headers_controller.rb index 416589537..8e2b34a26 100644 --- a/app/controllers/webhook_headers_controller.rb +++ b/app/controllers/webhook_headers_controller.rb @@ -1,9 +1,9 @@ +# WebhookHeadersController manages the creation/removal of webhook headers. class WebhookHeadersController < ApplicationController respond_to :html, :js before_action :set_namespace before_action :set_webhook - before_action :set_webhook_header, only: [:destroy] after_action :verify_authorized @@ -23,6 +23,8 @@ def create # DELETE /namespaces/1/webhooks/1/headers/1 # DELETE /namespaces/1/webhooks/1/headers/1.json def destroy + @webhook_header = @webhook.headers.find(params[:id]) + authorize @webhook_header @webhook_header.destroy @@ -39,10 +41,6 @@ def set_webhook @webhook = @namespace.webhooks.find(params[:webhook_id]) end - def set_webhook_header - @webhook_header = @webhook.headers.find(params[:id]) - end - def webhook_header_params params.require(:webhook_header).permit(:name, :value) end diff --git a/app/controllers/webhooks_controller.rb b/app/controllers/webhooks_controller.rb index 47b969180..f6e70c101 100644 --- a/app/controllers/webhooks_controller.rb +++ b/app/controllers/webhooks_controller.rb @@ -1,3 +1,5 @@ +# WebhooksController manages the creation/removal/update of webhooks. +# Also, it manages their state, i.e. enabled/disabled. class WebhooksController < ApplicationController respond_to :html, :js @@ -10,9 +12,8 @@ class WebhooksController < ApplicationController # GET /namespaces/1/webhooks # GET /namespaces/1/webhooks.json def index - @webhooks = policy_scope(Webhook) - .where("namespace_id = ?", @namespace.id) - .page(params[:page]) + authorize @namespace + @webhooks = policy_scope(Webhook).where(namespace: @namespace).page(params[:page]) respond_with(@namespace, @webhooks) end diff --git a/app/models/namespace.rb b/app/models/namespace.rb index 73c5744d4..4902d34e9 100644 --- a/app/models/namespace.rb +++ b/app/models/namespace.rb @@ -39,14 +39,23 @@ class Namespace < ActiveRecord::Base # namespace, returns two values: # 1. The namespace where the given repository belongs to. # 2. The name of the repository itself. - def self.get_from_name(name) + # If a registry is provided, it will query it for the given repository name. + def self.get_from_name(name, registry = nil) if name.include?("/") namespace, name = name.split("/", 2) - namespace = Namespace.find_by(name: namespace) + if registry.nil? + namespace = Namespace.find_by(name: namespace) + else + namespace = registry.namespaces.find_by(name: namespace) + end else - namespace = Namespace.find_by(global: true) + if registry.nil? + namespace = Namespace.find_by(global: true) + else + namespace = Namespace.find_by(registry: registry, global: true) + end end - [namespace, name] + [namespace, name, registry] end # Returns a String containing the cleaned name for this namespace. The diff --git a/app/models/webhook.rb b/app/models/webhook.rb index 4bbe7dea7..c492e418c 100644 --- a/app/models/webhook.rb +++ b/app/models/webhook.rb @@ -9,7 +9,7 @@ # password :string(255) # request_method :integer # content_type :integer -# enabled :boolean +# enabled :boolean default("0") # created_at :datetime not null # updated_at :datetime not null # @@ -18,6 +18,19 @@ # index_webhooks_on_namespace_id (namespace_id) # +require "base64" +require "typhoeus" +require "securerandom" +require "json" +require "uri" + +# A Webhook describes a kind of callback to an endpoint defined by an URL. +# Further parameters are username and password, which are used for basic +# authentication. The parameters request_method and content_type are limitted +# to GET and POST, and application/json and application/x-www-form-urlencoded +# respectively. Webhooks can be enabled or disabled. +# After a webhook has been triggered with the provided parameters, a +# WebhookDelivery object is created. class Webhook < ActiveRecord::Base include PublicActivity::Common @@ -26,21 +39,110 @@ class Webhook < ActiveRecord::Base belongs_to :namespace - has_many :deliveries, class_name: "WebhookDelivery" - has_many :headers, class_name: "WebhookHeader" + has_many :deliveries, class_name: "WebhookDelivery", dependent: :destroy + has_many :headers, class_name: "WebhookHeader", dependent: :destroy + + validates :url, presence: true, url: true - validates :url, presence: true + # default to http if no protocol has been specified. If unspecified, the URL + # validator will fail. + before_validation do + unless url.nil? || url.blank? + self.url = "http://#{url}" unless url.start_with?("http://") || + url.start_with?("https://") + end + end before_destroy :update_activities! + # Handle a push event from the registry. All enabled webhooks of the provided + # namespace are triggered in parallel. + def self.handle_push_event(event) + registry = Registry.find_from_event(event) + return if registry.nil? + + namespace, = Namespace.get_from_name(event["target"]["repository"], registry) + return if namespace.nil? + + hydra = Typhoeus::Hydra.hydra + + namespace.webhooks.each do |webhook| + next unless webhook.enabled + + headers = webhook.process_headers + + args = { + method: webhook.request_method, + headers: headers, + body: JSON.generate(event), + timeout: 60, + userpwd: webhook.process_auth + } + + hydra.queue create_request(webhook, args, headers, event) + end + + hydra.run + end + + # host returns the host part of the URL. This is useful when wanting a pretty + # representation of a webhook. + def host + _, _, host, = URI.split url + host + end + + # process_headers returns a hash containing the webhook's headers. + def process_headers + { "Content-Type" => content_type }.tap do |result| + headers.each do |header| + result[header.name] = header.value + end + end + end + + # process_auth returns a basic auth string if username and password are provided. + def process_auth + return if username.empty? || password.empty? + "#{username}:#{password}" + end + private + # create_request creates and returns a Request object with the provided arguments. + def self.create_request(webhook, args, headers, event) + request = Typhoeus::Request.new(webhook.url, args) + + request.on_complete do |response| + # prevent uuid clash + loop do + @uuid = SecureRandom.uuid + break if WebhookDelivery.find_by(webhook_id: webhook.id, uuid: @uuid).nil? + end + + WebhookDelivery.create( + webhook_id: webhook.id, + uuid: @uuid, + status: response.response_code, + request_header: headers.to_s, + request_body: JSON.generate(event), + response_header: response.response_headers, + response_body: response.response_body) + end + + request + end + + # Provide useful parameters for the "timeline" when a webhook has been + # removed. def update_activities! + _, _, host, = URI.split url PublicActivity::Activity.where(trackable: self).update_all( parameters: { namespace_id: namespace.id, namespace_name: namespace.clean_name, - webhook_url: url + webhook_url: url, + webhook_host: host } ) end diff --git a/app/models/webhook_delivery.rb b/app/models/webhook_delivery.rb index 9e318cded..3a82b5a78 100644 --- a/app/models/webhook_delivery.rb +++ b/app/models/webhook_delivery.rb @@ -5,7 +5,7 @@ # id :integer not null, primary key # webhook_id :integer # uuid :string(255) -# status :string(255) +# status :integer # request_header :text(65535) # request_body :text(65535) # response_header :text(65535) @@ -19,12 +19,39 @@ # index_webhook_deliveries_on_webhook_id_and_uuid (webhook_id,uuid) UNIQUE # +# A WebhookDelivery is created once a webhook has been triggered. They hold +# information regarding both the request and the response. Webhook deliveries +# can also be retrigged. class WebhookDelivery < ActiveRecord::Base belongs_to :webhook validates :uuid, uniqueness: { scope: :webhook_id } + # success? returns whether or not the request was successful (status 200). def success? status == 200 end + + # Retrigger a webhook unconditionally. + def retrigger + args = { + method: webhook.request_method, + headers: webhook.process_headers, + body: JSON.generate(JSON.load(request_body)), + timeout: 60, + userpwd: webhook.process_auth + } + + request = Typhoeus::Request.new(webhook.url, args) + + request.on_complete do |response| + update_attributes status: response.response_code, + response_header: response.response_headers, + response_body: response.response_body + # update `updated_at` field + touch + end + + request.run + end end diff --git a/app/models/webhook_header.rb b/app/models/webhook_header.rb index dc21f3c61..497d78973 100644 --- a/app/models/webhook_header.rb +++ b/app/models/webhook_header.rb @@ -15,6 +15,8 @@ # index_webhook_headers_on_webhook_id_and_name (webhook_id,name) UNIQUE # +# A WebhookHeader is a key value pair, and describes a HTTP header which is +# to be included in a webhook request. class WebhookHeader < ActiveRecord::Base belongs_to :webhook diff --git a/app/policies/namespace_policy.rb b/app/policies/namespace_policy.rb index c725de0ba..12cca1931 100644 --- a/app/policies/namespace_policy.rb +++ b/app/policies/namespace_policy.rb @@ -30,6 +30,12 @@ def push? namespace.team.contributors.exists?(user.id) end + def index? + raise Pundit::NotAuthorizedError, "must be logged in" unless user + + user.admin? || namespace.team.users.exists?(user.id) + end + alias_method :all?, :push? alias_method :create?, :push? alias_method :update?, :push? diff --git a/app/policies/public_activity/activity_policy.rb b/app/policies/public_activity/activity_policy.rb index 875d3c59b..33b877911 100644 --- a/app/policies/public_activity/activity_policy.rb +++ b/app/policies/public_activity/activity_policy.rb @@ -46,7 +46,48 @@ def resolve .union_all(namespace_activities) .union_all(repository_activities) .union_all(application_token_activities) + .union_all(webhook_activities) .distinct end + + # webhook_activities returns all webhook activities which are accessibly to + # the user. + def webhook_activities + # Show webhook events only if user is a member of the team controlling + # the namespace. + # Note: this works only for existing webhooks + activities = @scope + .joins("INNER JOIN webhooks ON activities.trackable_id = webhooks.id " \ + "INNER JOIN namespaces ON namespaces.id = webhooks.namespace_id " \ + "INNER JOIN teams ON teams.id = namespaces.team_id " \ + "INNER JOIN team_users ON teams.id = team_users.team_id") + .where("activities.trackable_type = ? AND team_users.user_id = ?", + "Webhook", user.id) + + # Convert relation to array since we want to add single objects, and objects + # cannot be added to relations. + activities = activities.to_a + + # Get all namespaces the user has access to. + user_namespaces = Namespace + .where(team_id: TeamUser.where(user_id: user.id).pluck(:team_id)) + .pluck(:id) + + # Go through all webhooks and add those to the array whose namespace_id is + # included in the user's accessible namespaces. This step is needed, as + # there is no (easy) way to match these webhooks with their corresponding + # namespace. + @scope + .where("activities.trackable_type = ?", "Webhook").distinct.find_each do |webhook| + unless webhook.parameters.empty? + if user_namespaces.include? webhook.parameters[:namespace_id] + activities << webhook + end + end + end + + # "convert" array back to relation in order to use `union_all`. + @scope.where(id: activities.map(&:id)) + end end end diff --git a/app/policies/webhook_delivery_policy.rb b/app/policies/webhook_delivery_policy.rb new file mode 100644 index 000000000..917a4f86e --- /dev/null +++ b/app/policies/webhook_delivery_policy.rb @@ -0,0 +1,13 @@ +class WebhookDeliveryPolicy < WebhookPolicy + attr_reader :webhook_delivery + + def initialize(user, webhook_delivery) + raise Pundit::NotAuthorizedError, "must be logged in" unless user + + @user = user + @webhook_delivery = webhook_delivery + + @webhook = webhook_delivery.webhook + @namespace = @webhook.namespace + end +end diff --git a/app/policies/webhook_policy.rb b/app/policies/webhook_policy.rb index 5f0e5f13a..185a707b8 100644 --- a/app/policies/webhook_policy.rb +++ b/app/policies/webhook_policy.rb @@ -10,12 +10,22 @@ def initialize(user, webhook) @namespace = webhook.namespace end - def toggle_enabled? + def create? raise Pundit::NotAuthorizedError, "must be logged in" unless user + + # Only admins and owners have WRITE access user.admin? || namespace.team.owners.exists?(user.id) end - alias_method :destroy?, :push? + def show? + raise Pundit::NotAuthorizedError, "must be logged in" unless user + + user.admin? || namespace.team.users.exists?(user.id) + end + + alias_method :destroy?, :create? + alias_method :toggle_enabled?, :create? + alias_method :update?, :create? class Scope attr_reader :user, :scope @@ -35,11 +45,9 @@ def resolve "(namespaces.public = :public OR team_users.user_id = :user_id) AND " \ "namespaces.global = :global AND namespaces.name != :username", public: true, user_id: user.id, global: false, username: user.username) - .pluk(:id) + .pluck(:id) - scope - .includes(:headers, :deliveries) - .where("namespace_id = ?", namespaces) + scope.includes(:headers, :deliveries).where(namespace: namespaces) end end end diff --git a/app/validators/url_validator.rb b/app/validators/url_validator.rb new file mode 100644 index 000000000..ccfe6695a --- /dev/null +++ b/app/validators/url_validator.rb @@ -0,0 +1,13 @@ +require "uri" + +# Validates URLs +class UrlValidator < ActiveModel::EachValidator + # Validator for the url. + def validate_each(record, attribute, value) + uri = URI.parse(value) + return if uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS) + record.errors[attribute] << "is not a valid URL" + rescue URI::InvalidURIError + record.errors[attribute] << "is not a valid URL" + end +end diff --git a/app/views/admin/namespaces/index.html.slim b/app/views/admin/namespaces/index.html.slim index f99bc9c8a..70c164c90 100644 --- a/app/views/admin/namespaces/index.html.slim +++ b/app/views/admin/namespaces/index.html.slim @@ -6,13 +6,15 @@ .table-responsive table.table.table-stripped.table-hover col.col-40 - col.col-30 + col.col-15 + col.col-15 col.col-20 col.col-10 thead tr th Name th Repositories + th Webhooks th Created At th Public tbody @@ -27,13 +29,15 @@ .table-responsive table.table.table-stripped.table-hover col.col-40 - col.col-30 + col.col-15 + col.col-15 col.col-20 col.col-10 thead tr th Name th Repositories + th Webhooks th Created At th Public tbody diff --git a/app/views/namespaces/_namespace.html.slim b/app/views/namespaces/_namespace.html.slim index c82b7c29f..ea332b974 100644 --- a/app/views/namespaces/_namespace.html.slim +++ b/app/views/namespaces/_namespace.html.slim @@ -1,6 +1,10 @@ tr id="namespace_#{namespace.id}" td= link_to namespace.clean_name, namespace td= namespace.repositories.count + - if current_user.admin? || namespace.team.users.include?(current_user) + td= link_to namespace.webhooks.count, namespace_webhooks_path(namespace) + - else + td= namespace.webhooks.count td= time_tag namespace.created_at td - if can_manage_namespace?(namespace) && !namespace.global diff --git a/app/views/namespaces/index.html.slim b/app/views/namespaces/index.html.slim index 8aa2881e2..e6f64e9ad 100644 --- a/app/views/namespaces/index.html.slim +++ b/app/views/namespaces/index.html.slim @@ -11,13 +11,15 @@ span .table-responsive table.table.table-stripped.table-hover col.col-40 - col.col-30 + col.col-15 + col.col-15 col.col-20 col.col-10 thead tr th Name th Repositories + th Webhooks th Created th Public tbody @@ -57,13 +59,15 @@ span .table-responsive table.table.table-stripped.table-hover col.col-40 - col.col-30 + col.col-15 + col.col-15 col.col-20 col.col-10 thead tr th Name th Repositories + th Webhooks th Created At th Public tbody#accessible-namespaces diff --git a/app/views/namespaces/show.html.slim b/app/views/namespaces/show.html.slim index bd48b39b8..88c4a14d8 100644 --- a/app/views/namespaces/show.html.slim +++ b/app/views/namespaces/show.html.slim @@ -101,7 +101,14 @@ tabindex="0" data-html="true"] i.fa.fa-eye ' Viewer - + - if current_user.admin? || @namespace.team.users.include?(current_user) + .pull-right + a.btn.btn-link.btn-xs.btn-show-webhooks[ + value="#{@namespace.id}" class="button_namespace_description" + href=url_for(namespace_webhooks_path(@namespace)) + ] + i.fa.fa-eye.fa-lg + | Show webhooks .panel-body .table-responsive table.table.table-stripped.table-hover diff --git a/app/views/public_activity/webhook/_create.html.slim b/app/views/public_activity/webhook/_create.html.slim index f272b660f..902a9ed34 100644 --- a/app/views/public_activity/webhook/_create.html.slim +++ b/app/views/public_activity/webhook/_create.html.slim @@ -7,10 +7,16 @@ li .description h6 strong - = "#{activity.owner.username} created the " - = link_to activity.trackable.url, [activity.trackable.namespace, activity.trackable] - = " webhook under the " - = link_to activity.trackable.namespace.name, activity.trackable.namespace + = "#{activity.owner.username} created webhook " + - if activity.trackable + = link_to activity.trackable.host, [activity.trackable.namespace, activity.trackable] + - else + = activity.parameters[:webhook_host] + = " under the " + - if activity.trackable + = link_to activity.trackable.namespace.name, activity.trackable.namespace + - else + = link_to activity.parameters[:namespace_name], controller: "namespaces", id: activity.parameters[:namespace_id] = " namespace" small i.fa.fa-clock-o diff --git a/app/views/public_activity/webhook/_destroy.html.slim b/app/views/public_activity/webhook/_destroy.html.slim index 281ae0702..015d71e8f 100644 --- a/app/views/public_activity/webhook/_destroy.html.slim +++ b/app/views/public_activity/webhook/_destroy.html.slim @@ -7,10 +7,10 @@ li .description h6 strong - = "#{activity.owner.username} removed the " - = activity.trackable.url - = " webhook from the " - = activity.trackable.namespace.name + = "#{activity.owner.username} removed webhook " + = activity.parameters[:webhook_host] + = " from the " + = link_to activity.parameters[:namespace_name], controller: "namespaces", id: activity.parameters[:namespace_id] = " namespace" small i.fa.fa-clock-o diff --git a/app/views/public_activity/webhook/_disabled.html.slim b/app/views/public_activity/webhook/_disabled.html.slim index ea9cea4b2..a63e9733a 100644 --- a/app/views/public_activity/webhook/_disabled.html.slim +++ b/app/views/public_activity/webhook/_disabled.html.slim @@ -8,7 +8,10 @@ li h6 strong = "#{activity.owner.username} set the " - = link_to activity.trackable.url, [activity.trackable.namespace, activity.trackable] + - if activity.trackable.nil? + = activity.parameters[:webhook_host] + - else + = link_to activity.trackable.host, [activity.trackable.namespace, activity.trackable] = " webhook as disabled" small i.fa.fa-clock-o diff --git a/app/views/public_activity/webhook/_enabled.html.slim b/app/views/public_activity/webhook/_enabled.html.slim index b6bb1483b..868b5667a 100644 --- a/app/views/public_activity/webhook/_enabled.html.slim +++ b/app/views/public_activity/webhook/_enabled.html.slim @@ -8,7 +8,10 @@ li h6 strong = "#{activity.owner.username} set the " - = link_to activity.trackable.url, [activity.trackable.namespace, activity.trackable] + - if activity.trackable.nil? + = activity.parameters[:webhook_host] + - else + = link_to activity.trackable.host, [activity.trackable.namespace, activity.trackable] = " webhook as enabled" small i.fa.fa-clock-o diff --git a/app/views/public_activity/webhook/_update.html.slim b/app/views/public_activity/webhook/_update.html.slim new file mode 100644 index 000000000..adad31a08 --- /dev/null +++ b/app/views/public_activity/webhook/_update.html.slim @@ -0,0 +1,23 @@ +li + .activitie-container + .activity-type.update-webhook + i.fa.fa-server + .user-image + = user_image_tag(activity.owner.email) + .description + h6 + strong + = "#{activity.owner.username} updated webhook " + - if activity.trackable + = link_to activity.trackable.host, [activity.trackable.namespace, activity.trackable] + - else + = activity.parameters[:webhook_host] + = " under the " + - if activity.trackable + = link_to activity.trackable.namespace.name, activity.trackable.namespace + - else + = link_to activity.parameters[:namespace_name], controller: "namespaces", id: activity.parameters[:namespace_id] + = " namespace" + small + i.fa.fa-clock-o + = activity_time_tag activity.created_at diff --git a/app/views/teams/show.html.slim b/app/views/teams/show.html.slim index 7a3b20d31..26525aba5 100644 --- a/app/views/teams/show.html.slim +++ b/app/views/teams/show.html.slim @@ -156,13 +156,15 @@ table.table.table-striped.table-hover colgroup col.col-50 - col.col-30 + col.col-15 + col.col-15 col.col-20 col.col-10 thead tr th Namespace th Repositories + th Webhooks th Created At th Public tbody#namespaces diff --git a/app/views/webhook_headers/_webhook_header.html.slim b/app/views/webhook_headers/_webhook_header.html.slim index 05a5153af..dc5a9e942 100644 --- a/app/views/webhook_headers/_webhook_header.html.slim +++ b/app/views/webhook_headers/_webhook_header.html.slim @@ -1,14 +1,18 @@ tr id="webhook_header_#{webhook_header.id}" td= webhook_header.name - td= webhook_header.value - td - a[class="btn btn-default" - data-placement="left" - data-toggle="popover" - data-title="Please confirm" - data-content='

Are you sure you want to remove this webhook header?

No Yes' - data-html="true" - tabindex="0" - role="button" - ] - i.fa.fa-trash.fa-lg + - if can_manage_namespace?(@namespace) + td= webhook_header.value + - else + td= '*' * webhook_header.value.length + - if can_manage_namespace?(@namespace) + td + a[class="btn btn-default" + data-placement="left" + data-toggle="popover" + data-title="Please confirm" + data-content='

Are you sure you want to remove this webhook header?

No Yes' + data-html="true" + tabindex="0" + role="button" + ] + i.fa.fa-trash.fa-lg diff --git a/app/views/webhook_headers/create.js.erb b/app/views/webhook_headers/create.js.erb index 8f8db631f..764fb58e7 100644 --- a/app/views/webhook_headers/create.js.erb +++ b/app/views/webhook_headers/create.js.erb @@ -2,7 +2,7 @@ $('#alert p').html("<%= escape_javascript(@webhook_header.errors.full_messages.join('
')) %>"); $('#alert').fadeIn(); <% else %> - $('#alert p').html("New header created"); + $('#alert p').html("Header '<%= @webhook_header.name %>' has been created successfully"); $('#alert').fadeIn(); $('#add_webhook_header_form').fadeOut(); diff --git a/app/views/webhook_headers/destroy.js.erb b/app/views/webhook_headers/destroy.js.erb index e785d1e50..1c8cdd256 100644 --- a/app/views/webhook_headers/destroy.js.erb +++ b/app/views/webhook_headers/destroy.js.erb @@ -3,6 +3,6 @@ $('#alert').fadeIn(); <% else %> $("#webhook_header_<%= @webhook_header.id %>").remove(); - $('#alert p').html("Header removed from the webhook"); + $('#alert p').html("Header '<%= @webhook_header.name %>' has been removed successfully"); $('#alert').fadeIn(); <% end %> diff --git a/app/views/webhooks/_webhook.html.slim b/app/views/webhooks/_webhook.html.slim index fb3eb2409..c1bf6828b 100644 --- a/app/views/webhooks/_webhook.html.slim +++ b/app/views/webhooks/_webhook.html.slim @@ -3,23 +3,30 @@ tr id="webhook_#{webhook.id}" td= webhook.request_method td= webhook.content_type td - a.btn.btn-default[data-remote="true" - data-method="put" - rel="nofollow" - href=url_for(toggle_enabled_namespace_webhook_path(namespace, webhook)) - ] + - if can_manage_namespace?(@namespace) + a.btn.btn-default[data-remote="true" + data-method="put" + rel="nofollow" + href=url_for(toggle_enabled_namespace_webhook_path(namespace, webhook)) + ] + - if webhook.enabled? + i.fa.fa-lg class="fa-toggle-on" + - else + i.fa.fa-lg class="fa-toggle-off" title="Click so this webhook gets enabled" + - else - if webhook.enabled? i.fa.fa-lg class="fa-toggle-on" - else - i.fa.fa-lg class="fa-toggle-off" title="Click so this webhook gets enabled" - td - a[class="btn btn-default" - data-placement="left" - data-toggle="popover" - data-title="Please confirm" - data-content='

Are you sure you want to remove this webhook?

No Yes' - data-html="true" - tabindex="0" - role="button" - ] - i.fa.fa-trash.fa-lg + i.fa.fa-lg class="fa-toggle-off" + - if can_manage_namespace?(@namespace) + td + a[class="btn btn-default" + data-placement="left" + data-toggle="popover" + data-title="Please confirm" + data-content='

Are you sure you want to remove this webhook?

No Yes' + data-html="true" + tabindex="0" + role="button" + ] + i.fa.fa-trash.fa-lg diff --git a/app/views/webhooks/create.js.erb b/app/views/webhooks/create.js.erb index 980bdeed2..0ac1de36a 100644 --- a/app/views/webhooks/create.js.erb +++ b/app/views/webhooks/create.js.erb @@ -2,12 +2,12 @@ $('#alert p').html("<%= escape_javascript(@webhook.errors.full_messages.join('
')) %>"); $('#alert').fadeIn(); <% else %> - $('#alert p').html("New webhook created"); + $('#alert p').html("Webhook '<%= @webhook.host %>' has been created successfully"); $('#alert').fadeIn(); $('#add_webhook_form').fadeOut(); - $('#add_webhook_btn i').addClass("fa-chevron-down") - $('#add_webhook_btn i').removeClass("fa-chevron-up") + $('#add_webhook_btn i').addClass("fa-plus-circle") + $('#add_webhook_btn i').removeClass("fa-minus-circle") if ($('#webhooks').length > 0) { $("<%= escape_javascript(render partial: "webhooks/webhook", locals: {namespace: @namespace,webhook: @webhook}) %>").appendTo("#webhooks"); diff --git a/app/views/webhooks/destroy.js.erb b/app/views/webhooks/destroy.js.erb index 8a4fc474d..90038abc7 100644 --- a/app/views/webhooks/destroy.js.erb +++ b/app/views/webhooks/destroy.js.erb @@ -3,6 +3,6 @@ $('#alert').fadeIn(); <% else %> $("#webhook_<%= @webhook.id %>").remove(); - $('#alert p').html("Webhook removed from the namespace"); + $('#alert p').html("Webhook '<%= @webhook.host %>' has been removed successfully"); $('#alert').fadeIn(); <% end %> diff --git a/app/views/webhooks/index.html.slim b/app/views/webhooks/index.html.slim index 4e995b8e3..cd705ee9d 100644 --- a/app/views/webhooks/index.html.slim +++ b/app/views/webhooks/index.html.slim @@ -24,29 +24,45 @@ .col-md-offset-2.col-md-7 = f.submit('Create', class: 'btn btn-primary') +span + a[data-placement="right" data-toggle="popover" data-container="span" data-content="A webhook is an HTTP callback which is triggered after an event, e.g. a push event, occurs." data-original-title="What's this?" tabindex="0"] + i.fa.fa-info-circle + | What's this? .panel.panel-default .panel-heading h5 - ' Webhooks - .pull-right - a#add_webhook_btn.btn.btn-xs.btn-link.js-toggle-button[role="button"] - i.fa.fa-plus-circle - | Create new webhook + ' Webhooks for + - if @namespace.name == "portus_global_namespace_1" + strong + = link_to " global namespace", @namespace + - else + ' namespace + strong + = link_to @namespace.name, @namespace + - if can_manage_namespace?(@namespace) + .pull-right + a#add_webhook_btn.btn.btn-xs.btn-link.js-toggle-button[role="button"] + i.fa.fa-plus-circle + | Create new webhook .panel-body .table-responsive table.table.table-stripped.table-hover col.col-40 col.col-20 col.col-20 - col.col-10 - col.col-10 + - if can_manage_namespace?(@namespace) + col.col-10 + col.col-10 + -else + col.col-20 thead tr th URL th Request method th Content type th Enabled - th Remove + - if can_manage_namespace?(@namespace) + th Remove tbody#webhooks - @webhooks.each do |webhook| = render partial: 'webhooks/webhook', locals: {namespace: @namespace, webhook: webhook} diff --git a/app/views/webhooks/retrigger.js.erb b/app/views/webhooks/retrigger.js.erb new file mode 100644 index 000000000..ad20ba346 --- /dev/null +++ b/app/views/webhooks/retrigger.js.erb @@ -0,0 +1,13 @@ +var ns = $('#webhook_delivery_<%= webhook_delivery.id %> td i').first(); + +<% if webhook_delivery.success? %> + ns.addClass("fa-check"); + ns.addClass("text-success"); + ns.removeClass("fa-close"); + ns.removeClass("text-danger"); +<% else %> + ns.addClass("fa-close"); + ns.addClass("text-danger"); + ns.removeClass("fa-check"); + ns.removeClass("text-success"); +<% end %> diff --git a/app/views/webhooks/show.html.slim b/app/views/webhooks/show.html.slim index a55d0c4e0..9a57ba3ac 100644 --- a/app/views/webhooks/show.html.slim +++ b/app/views/webhooks/show.html.slim @@ -1,89 +1,103 @@ .panel-group - .collapse id="update_webhook_#{@webhook.id}" - = form_for [@namespace, @webhook], remote: true, html: {class: 'form-horizontal', role: 'form'} do |f| - .panel.panel-default - .panel-heading - .input-group - = f.text_field(:url, class: 'form-control', required: true, placeholder: html_escape(@webhook.url), input_html: { tabindex: 1 }) - .input-group-btn - button.btn.btn-link.btn-xs.btn-edit-webhook[ - value="#{@webhook.id}" - type="button" - class="button_edit_webhook"] - i.fa.fa-close - | Edit webhook - .panel-body - .form-group - = f.label :request_method, {class: 'control-label col-md-2'} - .col-md-7 - = f.select(:request_method, Webhook.request_methods.keys, {}, {class: 'form-control'}) - .form-group - = f.label :content_type, {class: 'control-label col-md-2'} - .col-md-7 - = f.select(:content_type, Webhook.content_types.keys, {}, {class: 'form-control'}) - .form-group - = f.label :username, {class: 'control-label col-md-2'} - .col-md-7 - = f.text_field(:username, class: 'form-control', required: false, placeholder: "Username for authentication") - .form-group - = f.label :password, {class: 'control-label col-md-2'} - .col-md-7 - = f.text_field(:password, class: 'form-control', required: false, placeholder: "Password for authentication") - .form-group - .col-md-offset-2.col-md-7 - = f.submit('Save', class: 'btn btn-primary') - .errors + - if can_manage_namespace?(@namespace) + .collapse id="update_webhook_#{@webhook.id}" + = form_for [@namespace, @webhook], remote: true, html: {class: 'form-horizontal', role: 'form'} do |f| + .panel.panel-default + .panel-heading + .input-group + = f.text_field(:url, class: 'form-control', required: true, placeholder: html_escape(@webhook.url), input_html: { tabindex: 1 }) + .input-group-btn + button.btn.btn-link.btn-xs.btn-edit-webhook[ + value="#{@webhook.id}" + type="button" + class="button_edit_webhook"] + i.fa.fa-close + | Edit webhook + .panel-body + .form-group + = f.label :request_method, {class: 'control-label col-md-2'} + .col-md-7 + = f.select(:request_method, Webhook.request_methods.keys, {}, {class: 'form-control'}) + .form-group + = f.label :content_type, {class: 'control-label col-md-2'} + .col-md-7 + = f.select(:content_type, Webhook.content_types.keys, {}, {class: 'form-control'}) + .form-group + = f.label :username, {class: 'control-label col-md-2'} + .col-md-7 + = f.text_field(:username, class: 'form-control', required: false, placeholder: "Username for authentication") + .form-group + = f.label :password, {class: 'control-label col-md-2'} + .col-md-7 + = f.text_field(:password, class: 'form-control', required: false, placeholder: "Password for authentication") + .form-group + .col-md-offset-2.col-md-7 + = f.submit('Save', class: 'btn btn-primary') + .errors .panel.panel-default.webhook_information .panel-heading h5 strong - '#{@webhook.url} + '#{@webhook.host} ' webhook - .pull-right - button.btn.btn-link.btn-xs.btn-edit-webhook[ - value="#{@webhook.id}" - class="button_edit_webhook"] - i.fa.fa-pencil - | Edit webhook + small + a[data-placement="right" data-toggle="popover" data-container=".panel-heading" data-content="Request method: URL endpoint where the HTTP request is sent to.
Content type: Description of the webhook request content.
Username: Username used for basic HTTP auth.
Password: Password used for basic HTTP auth." data-original-title="What's this?" tabindex="0" data-html="true"] + i.fa.fa-info-circle + - if can_manage_namespace?(@namespace) + .pull-right + button.btn.btn-link.btn-xs.btn-edit-webhook[ + value="#{@webhook.id}" + class="button_edit_webhook"] + i.fa.fa-pencil + | Edit webhook .panel-body = render partial: "detail", locals: {webhook: @webhook} -#add_webhook_header_form.collapse - = form_for :webhook_header, url: namespace_webhook_headers_path(@namespace, @webhook), remote: true, html: {id: 'new-header-form', class: 'form-horizontal', role: 'form'} do |f| - .form-group - = f.label :name, {class: 'control-label col-md-2'} - .col-md-7 - = f.text_field(:name, class: 'form-control', required: true, placeholder: "Name") - .form-group - = f.label :value, {class: 'control-label col-md-2'} - .col-md-7 - = f.text_field(:value, class: 'form-control', required: true, placeholder: "Value") - .form-group - .col-md-offset-2.col-md-7 - = f.submit('Create', class: 'btn btn-primary') - .errors +- if can_manage_namespace?(@namespace) + #add_webhook_header_form.collapse + = form_for :webhook_header, url: namespace_webhook_headers_path(@namespace, @webhook), remote: true, html: {id: 'new-header-form', class: 'form-horizontal', role: 'form'} do |f| + .form-group + = f.label :name, {class: 'control-label col-md-2'} + .col-md-7 + = f.text_field(:name, class: 'form-control', required: true, placeholder: "Name") + .form-group + = f.label :value, {class: 'control-label col-md-2'} + .col-md-7 + = f.text_field(:value, class: 'form-control', required: true, placeholder: "Value") + .form-group + .col-md-offset-2.col-md-7 + = f.submit('Create', class: 'btn btn-primary') + .errors .panel.panel-default .panel-heading h5 ' Headers - .pull-right - a#add_webhook_header_btn.btn.btn-xs.btn-link.js-toggle-button[role="button"] - i.fa.fa-plus-circle - | Create new header + small + a[data-placement="right" data-toggle="popover" data-container=".panel-heading" data-content="A header is a HTTP header, i.e. is a key-value pair which is included in the HTTP request." data-original-title="What's this?" tabindex="0"] + i.fa.fa-info-circle + - if can_manage_namespace?(@namespace) + .pull-right + a#add_webhook_header_btn.btn.btn-xs.btn-link.js-toggle-button[role="button"] + i.fa.fa-plus-circle + | Create new header .panel-body .table-responsive table.table.table-stripped.table-hover colgroup col.col-20 - col.col-70 - col.col-10 + - if can_manage_namespace?(@namespace) + col.col-70 + col.col-10 + - else + col.col-80 thead tr th Name th Value - th Remove + - if can_manage_namespace?(@namespace) + th Remove tbody#webhook_headers - @webhook.headers.each do |header| = render partial: 'webhook_headers/webhook_header', locals: {namespace: @namespace, webhook: @webhook, webhook_header: header} @@ -92,28 +106,49 @@ .panel-heading h5 ' Deliveries + small + a[data-placement="right" data-toggle="popover" data-container=".panel-heading" data-content="A delivery is created once a webhook has been triggered. They are not re-created but updated after retriggering." data-original-title="What's this?" tabindex="0"] + i.fa.fa-info-circle .panel-body .table-responsive table.table.table-stripped.table-hover colgroup - col.col-90 - col.col-10 + - if can_manage_namespace?(@namespace) + col.col-90 + col.col-10 + - else + col.col-100 thead tr th UUID - th Retrigger + - if can_manage_namespace?(@namespace) + th Retrigger tbody#webhook_deliveries - @deliveries.each do |delivery| - tr + tr[id="webhook_delivery_#{delivery.id}"] td - if delivery.success? i.fa.fa-check.fa-lg.text-success - else i.fa.fa-close.fa-lg.text-danger = delivery.uuid - td - button.btn.btn-default.btn-retrigger-webhook[ - value="#{delivery.uuid}" - class="details"] - i.fa.fa-refresh.fa-lg + - if can_manage_namespace?(@namespace) + td + - if delivery.webhook.enabled + a.btn.btn-default.btn-retrigger-webhook[data-remote="true" + data-method="put" + rel="nofollow" + href=url_for(namespace_webhook_delivery_path(@namespace, @webhook, delivery)) + ] + i.fa.fa-refresh.fa-lg + - else + a.btn.btn-default.btn-retrigger-webhook[data-placement="left" + data-toggle="popover" + data-title="Please confirm" + data-content='

This webhook is disabled. Are you sure you want to retrigger it?

No Yes' + data-html="true" + tabindex="0" + role="button" + ] + i.fa.fa-refresh.fa-lg .panel-footer= paginate(@deliveries) diff --git a/app/views/webhooks/toggle_enabled.js.erb b/app/views/webhooks/toggle_enabled.js.erb index 11a52dd62..4a358c322 100644 --- a/app/views/webhooks/toggle_enabled.js.erb +++ b/app/views/webhooks/toggle_enabled.js.erb @@ -1,11 +1,11 @@ -var ns = $('#webhook_<%= webhook.id %> td a i'); +var ns = $('#webhook_<%= webhook.id %> td a i').first(); <% if webhook.enabled? %> - ns.removeAttr("title"); + ns.prop("title", "This webhook is enabled and will be triggered"); ns.addClass("fa-toggle-on"); ns.removeClass("fa-toggle-off"); <% else %> - ns.prop("title", "This webhook is enabled and will be trieggered"); + ns.removeAttr("title"); ns.addClass("fa-toggle-off"); ns.removeClass("fa-toggle-on"); <% end %> diff --git a/app/views/webhooks/update.js.erb b/app/views/webhooks/update.js.erb index 6541657d2..22c1ec8d3 100644 --- a/app/views/webhooks/update.js.erb +++ b/app/views/webhooks/update.js.erb @@ -1,5 +1,5 @@ <% if @webhook.errors.any? %> - $('#alert p').html("<%= escape_javascript(@webhook.errors.full_messages.join('
')) %>"); + $('#alert p').html("<%= escape_javascript(raw(@webhook.errors.full_messages.join('
'))) %>"); $('#alert').fadeIn(); <% else %> $('#update_webhook_<%= @webhook.id %>').toggle() @@ -10,4 +10,7 @@ el.addClass('fa-pencil'); $('.webhook_information .panel-body').html("<%= escape_javascript(render partial: "detail", locals: {webhook: @webhook}) %>"); + $('#alert p').html("Webhook '<%= @webhook.host %>' has been updated successfully"); + $('#alert').fadeIn(); + $('.webhook_information .panel-heading h5 strong').text("<%= @webhook.host %> ") <% end %> diff --git a/config/routes.rb b/config/routes.rb index 2aed939d7..bab1f3d42 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -12,6 +12,7 @@ put "toggle_public", on: :member resources :webhooks do resources :headers, only: [:create, :destroy], controller: :webhook_headers + resources :deliveries, only: [:update], controller: :webhook_deliveries member do put "toggle_enabled" end diff --git a/db/migrate/20160411150441_create_webhooks.rb b/db/migrate/20160411150441_create_webhooks.rb index c4b865510..eaa81fba2 100644 --- a/db/migrate/20160411150441_create_webhooks.rb +++ b/db/migrate/20160411150441_create_webhooks.rb @@ -7,7 +7,7 @@ def change t.string :password t.integer :request_method t.integer :content_type - t.boolean :enabled + t.boolean :enabled, default: false t.timestamps null: false end diff --git a/db/migrate/20160411150745_create_webhook_deliveries.rb b/db/migrate/20160411150745_create_webhook_deliveries.rb index a2409545b..535cbba64 100644 --- a/db/migrate/20160411150745_create_webhook_deliveries.rb +++ b/db/migrate/20160411150745_create_webhook_deliveries.rb @@ -3,7 +3,7 @@ def change create_table :webhook_deliveries do |t| t.references :webhook, index: true, foreign_key: true t.string :uuid - t.string :status + t.integer :status t.text :request_header t.text :request_body t.text :response_header diff --git a/db/schema.rb b/db/schema.rb index afd09e33e..3ba51056f 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -175,7 +175,7 @@ create_table "webhook_deliveries", force: :cascade do |t| t.integer "webhook_id", limit: 4 t.string "uuid", limit: 255 - t.string "status", limit: 255 + t.integer "status", limit: 4 t.text "request_header", limit: 65535 t.text "request_body", limit: 65535 t.text "response_header", limit: 65535 @@ -205,9 +205,9 @@ t.string "password", limit: 255 t.integer "request_method", limit: 4 t.integer "content_type", limit: 4 - t.boolean "enabled", limit: 1 - t.datetime "created_at", null: false - t.datetime "updated_at", null: false + t.boolean "enabled", default: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false end add_index "webhooks", ["namespace_id"], name: "index_webhooks_on_namespace_id", using: :btree diff --git a/lib/portus/registry_notification.rb b/lib/portus/registry_notification.rb index 124d36953..2aa0b61b6 100644 --- a/lib/portus/registry_notification.rb +++ b/lib/portus/registry_notification.rb @@ -5,11 +5,11 @@ class RegistryNotification # An array with the events that a handler has to support. HANDLED_EVENTS = ["push", "delete"].freeze - # Processes the notification data with the given handler. The data is the + # Processes the notification data with the given handlers. The data is the # parsed JSON body as given by the registry. A handler is a class that can # call the `handle_#{event}_event` method. This method receives an `event` # object, which is the event object as given by the registry. - def self.process!(data, handler) + def self.process!(data, *handlers) data["events"].each do |event| Rails.logger.debug "Incoming event:\n#{JSON.pretty_generate(event)}" next unless relevant?(event) @@ -18,7 +18,7 @@ def self.process!(data, handler) next unless HANDLED_EVENTS.include?(action) Rails.logger.info "Handling '#{action}' event:\n#{JSON.pretty_generate(event)}" - handler.send("handle_#{action}_event".to_sym, event) + handlers.each { |handler| handler.send("handle_#{action}_event".to_sym, event) } end end diff --git a/spec/controllers/webhook_headers_controller_spec.rb b/spec/controllers/webhook_headers_controller_spec.rb index 08567a3fc..8cff363a0 100644 --- a/spec/controllers/webhook_headers_controller_spec.rb +++ b/spec/controllers/webhook_headers_controller_spec.rb @@ -1,5 +1,77 @@ -require 'rails_helper' +require "rails_helper" RSpec.describe WebhookHeadersController, type: :controller do + let(:valid_session) { {} } + let!(:registry) { create(:registry) } + let(:user) { create(:user) } + let(:viewer) { create(:user) } + let(:contributor) { create(:user) } + let(:owner) { create(:user) } + let(:team) do + create(:team, + owners: [owner], + viewers: [user, viewer], + contributors: [contributor]) + end + let(:namespace) do + create( + :namespace, + team: team, + description: "short test description", + registry: registry) + end + let(:webhook) { create(:webhook, namespace: namespace) } + describe "POST #create" do + context "as a namespace owner" do + let(:post_params) do + { + webhook_id: webhook.id, + namespace_id: namespace.id, + webhook_header: { name: "foo", value: "bar" }, + format: :js + } + end + + it "creates a webhook header" do + sign_in owner + post :create, post_params + expect(response.status).to eq(200) + end + + it "disallows creating multiple headers with the same name" do + sign_in owner + post :create, post_params + post :create, post_params + expect(response.status).to eq(422) + end + end + end + + describe "DELETE #destroy" do + let(:webhook_header) do + create(:webhook_header, webhook: webhook, name: "foo", value: "bar") + end + + let(:post_params) do + { + id: webhook_header.id, + webhook_id: webhook.id, + namespace_id: namespace.id, + format: :js + } + end + + it "allows owner to delete webhook" do + sign_in owner + delete :destroy, post_params + expect(response.status).to eq(200) + end + + it "disallows user to delete webhook" do + sign_in user + delete :destroy, post_params + expect(response.status).to eq(401) + end + end end diff --git a/spec/controllers/webhooks_controller_spec.rb b/spec/controllers/webhooks_controller_spec.rb index 56f9119b5..935195115 100644 --- a/spec/controllers/webhooks_controller_spec.rb +++ b/spec/controllers/webhooks_controller_spec.rb @@ -1,5 +1,452 @@ require "rails_helper" RSpec.describe WebhooksController, type: :controller do - pending + let(:valid_session) { {} } + let!(:registry) { create(:registry) } + let(:user) { create(:user) } + let(:viewer) { create(:user) } + let(:contributor) { create(:user) } + let(:owner) { create(:user) } + let(:team) do + create(:team, + owners: [owner], + viewers: [user, viewer], + contributors: [contributor]) + end + let(:namespace) do + create( + :namespace, + team: team, + description: "short test description", + registry: registry) + end + let!(:webhook) do + create( + :webhook, + namespace: namespace + ) + end + let!(:webhook_header) { create(:webhook_header, webhook: webhook) } + let!(:webhook_delivery) { create(:webhook_delivery, webhook: webhook) } + + before :each do + sign_in user + end + + describe "GET #index" do + it "assigns all webhooks as @webhooks" do + get :index, { namespace_id: namespace }, valid_session + expect(assigns(:webhooks)).to match_array( + [Webhook.find_by(namespace: namespace)]) + end + + it "paginates webhooks" do + get :index, { namespace_id: namespace }, valid_session + expect(assigns(:webhooks)).to respond_to(:total_pages) + end + end + + describe "GET #show" do + it "should paginate webhook deliveries" do + sign_in owner + get :show, namespace_id: namespace.id, id: webhook.id + + expect(assigns(:deliveries)).to respond_to(:total_pages) + end + + it "allows team members to view the page" do + sign_in owner + get :show, namespace_id: namespace.id, id: webhook.id + + expect(assigns(:webhook)).to eq(webhook) + expect(response.status).to eq 200 + end + + it "blocks users that are not part of the team" do + sign_in create(:user) + get :show, namespace_id: namespace.id, id: webhook.id + + expect(response.status).to eq 401 + end + end + + describe "DELETE #destroy" do + render_views + + context "as a contributor of the team that is going to control webhooks" do + it "blocks access" do + sign_in contributor + + expect do + delete :destroy, namespace_id: namespace.id, id: webhook.id + end.not_to change(Webhook, :count) + expect(response.status).to eq(401) + end + end + + context "as a viewer of the team that is going to control webhooks" do + it "blocks access" do + sign_in viewer + + expect do + delete :destroy, namespace_id: namespace.id, id: webhook.id + end.not_to change(Webhook, :count) + expect(response.status).to eq(401) + end + + it "shows an error message" do + sign_in viewer + xhr :delete, :destroy, namespace_id: namespace.id, id: webhook.id + expect(response.body).to include("You are not authorized to access this page") + end + end + + context "as a generic user not part of the team that is going to control webhooks" do + it "blocks access" do + sign_in create(:user) + + expect do + delete :destroy, namespace_id: namespace.id, id: webhook.id + end.not_to change(Webhook, :count) + expect(response.status).to eq(401) + end + end + + it "should delete a webhook" do + sign_in owner + delete :destroy, namespace_id: namespace.id, id: webhook.id + expect(response.status).to eq 302 + end + end + + describe "PUT #toggle_enabled" do + it "allows the owner of the team to change the enabled attribute" do + sign_in owner + put :toggle_enabled, namespace_id: namespace.id, id: webhook.id, format: :js + + webhook.reload + expect(webhook).to_not be_enabled + expect(response.status).to eq 200 + end + + it "blocks users that are not part of the team" do + sign_in create(:user) + put :toggle_enabled, namespace_id: namespace.id, id: webhook.id, format: :js + + expect(response.status).to eq 401 + end + end + + describe "POST #create" do + render_views + let(:valid_attributes) do + { + namespace: namespace.id, + url: "example.org" + } + end + + let(:invalid_attributes) do + { + namespace: namespace.id + } + end + + context "as a contributor of the team that is going to control webhooks" do + it "blocks access" do + sign_in contributor + post_params = { + webhook: valid_attributes, + namespace_id: namespace, + format: :js + } + + expect do + post :create, post_params + end.not_to change(Webhook, :count) + expect(response.status).to eq(401) + end + end + + context "as a viewer of the team that is going to control webhooks" do + it "blocks access" do + sign_in viewer + post_params = { + webhook: valid_attributes, + namespace_id: namespace, + format: :js + } + + expect do + post :create, post_params + end.not_to change(Webhook, :count) + expect(response.status).to eq(401) + end + + it "shows an error message" do + sign_in viewer + post_params = { webhook: valid_attributes, namespace_id: namespace } + xhr :post, :create, post_params + expect(response.body).to include("You are not authorized to access this page") + end + end + + context "as a generic user not part of the team that is going to control webhooks" do + it "blocks access" do + sign_in create(:user) + post_params = { + webhook: valid_attributes, + namespace_id: namespace, + format: :js + } + + expect do + post :create, post_params + end.not_to change(Webhook, :count) + expect(response.status).to eq(401) + end + end + + context "with valid params" do + before :each do + sign_in owner + @post_params = { + webhook: valid_attributes, + namespace_id: namespace, + format: :js + } + end + + it "creates a new webhook" do + expect do + post :create, @post_params + end.to change(Webhook, :count).by(1) + expect(assigns(:webhook).namespace).to eq(namespace) + expect(assigns(:webhook).url).to eq("http://#{valid_attributes[:url]}") + expect(assigns(:webhook).enabled).to be_falsy + end + + it "assigns a newly created webhook as @webhook" do + post :create, @post_params + expect(assigns(:webhook)).to be_a(Webhook) + expect(assigns(:webhook)).to be_persisted + end + + it "creates a new webhook with the given username" do + @post_params[:webhook]["username"] = "user" + expect do + post :create, @post_params + end.to change(Webhook, :count).by(1) + expect(assigns(:webhook).namespace).to eq(namespace) + expect(assigns(:webhook).url).to eq("http://#{valid_attributes[:url]}") + expect(assigns(:webhook).username).to eq("user") + end + + it "creates a new webhook with the given password" do + @post_params[:webhook]["password"] = "password" + expect do + post :create, @post_params + end.to change(Webhook, :count).by(1) + expect(assigns(:webhook).namespace).to eq(namespace) + expect(assigns(:webhook).url).to eq("http://#{valid_attributes[:url]}") + expect(assigns(:webhook).password).to eq("password") + end + + it "creates a new webhook with the POST method" do + @post_params[:webhook]["request_method"] = "POST" + expect do + post :create, @post_params + end.to change(Webhook, :count).by(1) + expect(assigns(:webhook).namespace).to eq(namespace) + expect(assigns(:webhook).url).to eq("http://#{valid_attributes[:url]}") + expect(assigns(:webhook).request_method).to eq("POST") + end + + it "creates a new webhook with the JSON content type" do + @post_params[:webhook]["content_type"] = "application/json" + expect do + post :create, @post_params + end.to change(Webhook, :count).by(1) + expect(assigns(:webhook).namespace).to eq(namespace) + expect(assigns(:webhook).url).to eq("http://#{valid_attributes[:url]}") + expect(assigns(:webhook).content_type).to eq("application/json") + end + end + + context "with invalid params" do + before :each do + sign_in owner + end + + it "assigns a newly created but unsaved webhook as @webhook" do + post_params = { + webhook: invalid_attributes, + namespace_id: namespace, + format: :js + } + post :create, post_params + expect(assigns(:webhook)).to be_a_new(Webhook) + expect(response.status).to eq(422) + end + + it "fails to create a webhook with an invalid request method" do + post_params = { + webhook: invalid_attributes, + namespace_id: namespace, + request_method: "PUT", + format: :js + } + post :create, post_params + expect(assigns(:webhook)).to be_a_new(Webhook) + expect(response.status).to eq(422) + end + + it "fails to create a webhook with an invalid content type" do + post_params = { + webhook: invalid_attributes, + namespace_id: namespace, + content_type: "text/plain", + format: :js + } + post :create, post_params + expect(assigns(:webhook)).to be_a_new(Webhook) + expect(response.status).to eq(422) + end + end + end + + describe "PATCH #update" do + it "does not allow to change the url by viewers" do + team = create(:team) + user = create(:user) + TeamUser.create(team: team, user: user, role: TeamUser.roles["viewers"]) + sign_in user + patch :update, id: webhook.id, namespace_id: namespace.id, webhook: { + url: "port.us" }, format: "js" + expect(response.status).to eq(401) + end + + it "does not allow to change the request method by viewers" do + team = create(:team) + user = create(:user) + TeamUser.create(team: team, user: user, role: TeamUser.roles["viewers"]) + sign_in user + patch :update, id: webhook.id, namespace_id: namespace.id, webhook: { + request_method: "POST" }, format: "js" + expect(response.status).to eq(401) + end + + it "does not allow to change the content type by viewers" do + team = create(:team) + user = create(:user) + TeamUser.create(team: team, user: user, role: TeamUser.roles["viewers"]) + sign_in user + patch :update, id: webhook.id, namespace_id: namespace.id, webhook: { + content_type: "application/json" }, format: "js" + expect(response.status).to eq(401) + end + + it "does not allow to change the username by viewers" do + team = create(:team) + user = create(:user) + TeamUser.create(team: team, user: user, role: TeamUser.roles["viewers"]) + sign_in user + patch :update, id: webhook.id, namespace_id: namespace.id, webhook: { + username: "alice" }, format: "js" + expect(response.status).to eq(401) + end + + it "does not allow to change the password by viewers" do + team = create(:team) + user = create(:user) + TeamUser.create(team: team, user: user, role: TeamUser.roles["viewers"]) + sign_in user + patch :update, id: webhook.id, namespace_id: namespace.id, webhook: { + password: "supersecure" }, format: "js" + expect(response.status).to eq(401) + end + + it "does allow to change the url by owners" do + sign_in owner + patch :update, id: webhook.id, namespace_id: namespace.id, webhook: { + url: "port.us" }, format: "js" + expect(response.status).to eq(200) + end + + it "fails when providing invalid parameters" do + sign_in owner + patch :update, id: webhook.id, namespace_id: namespace.id, webhook: { + url: "" }, format: "js" + expect(response.status).to eq(422) + end + end + + describe "activity tracking" do + before :each do + sign_in owner + end + + it "tracks webhook creation" do + post_params = { + webhook: { url: "example.org" }, + namespace_id: namespace, + format: :js + } + + expect do + post :create, post_params + end.to change(PublicActivity::Activity, :count).by(1) + + activity = PublicActivity::Activity.last + expect(activity.key).to eq("webhook.create") + expect(activity.owner).to eq(owner) + expect(activity.trackable).to eq(Webhook.last) + end + + it "tracks set webhook enabled" do + webhook.update_attributes(enabled: false) + + expect do + put :toggle_enabled, namespace_id: namespace.id, id: webhook.id, format: :js + end.to change(PublicActivity::Activity, :count).by(1) + + activity = PublicActivity::Activity.last + expect(activity.key).to eq("webhook.enabled") + expect(activity.owner).to eq(owner) + expect(activity.trackable).to eq(webhook) + end + + it "tracks set webhook disabled" do + webhook.update_attributes(enabled: true) + + expect do + put :toggle_enabled, namespace_id: namespace.id, id: webhook.id, format: :js + end.to change(PublicActivity::Activity, :count).by(1) + + activity = PublicActivity::Activity.last + expect(activity.key).to eq("webhook.disabled") + expect(activity.owner).to eq(owner) + expect(activity.trackable).to eq(webhook) + end + + it "tracks updates to the webhook" do + expect do + patch :update, + id: webhook.id, + namespace_id: namespace.id, + webhook: { url: "port.us" }, + format: "js" + end.to change(PublicActivity::Activity, :count).by(1) + end + + it "tracks removal of the webhook" do + expect do + delete :destroy, + id: webhook.id, + namespace_id: namespace.id, + webhook: { url: "port.us" }, + format: "js" + end.to change(PublicActivity::Activity, :count).by(1) + end + end end diff --git a/spec/factories/activities.rb b/spec/factories/activities.rb index cc43d7358..866ef36a1 100644 --- a/spec/factories/activities.rb +++ b/spec/factories/activities.rb @@ -66,4 +66,15 @@ parameters Hash.new(application: "test application") end + factory :activity_webhook_create, class: PublicActivity::Activity do + trackable_type "Webhook" + owner_type "User" + key "webhook.create" + end + + factory :activity_webhook_destroy, class: PublicActivity::Activity do + trackable_type "Webhook" + owner_type "User" + key "webhook.destroy" + end end diff --git a/spec/factories/webhook_deliveries.rb b/spec/factories/webhook_deliveries.rb index 4e7aa74f6..2b5a74ff9 100644 --- a/spec/factories/webhook_deliveries.rb +++ b/spec/factories/webhook_deliveries.rb @@ -5,7 +5,7 @@ # id :integer not null, primary key # webhook_id :integer # uuid :string(255) -# status :string(255) +# status :integer # request_header :text(65535) # request_body :text(65535) # response_header :text(65535) @@ -21,5 +21,8 @@ FactoryGirl.define do factory :webhook_delivery do + sequence(:uuid) { |n| "uuid_#{n}" } + request_body "{}" + status 404 end end diff --git a/spec/factories/webhooks.rb b/spec/factories/webhooks.rb index 65e270cec..9a5405a08 100644 --- a/spec/factories/webhooks.rb +++ b/spec/factories/webhooks.rb @@ -9,7 +9,7 @@ # password :string(255) # request_method :integer # content_type :integer -# enabled :boolean +# enabled :boolean default("0") # created_at :datetime not null # updated_at :datetime not null # @@ -20,9 +20,11 @@ FactoryGirl.define do factory :webhook do - url "http://localhost/webhook" + url "http://www.example.com" request_method "POST" content_type "application/json" enabled true + username "" + password "" end end diff --git a/spec/jobs/catalog_job_spec.rb b/spec/jobs/catalog_job_spec.rb index 8dfc0ce07..5535dc9f0 100644 --- a/spec/jobs/catalog_job_spec.rb +++ b/spec/jobs/catalog_job_spec.rb @@ -42,6 +42,8 @@ def update_registry!(catalog) end it "raises an exception when there has been a problem in /v2/_catalog" do + VCR.turn_on! + create(:registry, "hostname" => "registry.test.lan") VCR.use_cassette("registry/get_missing_catalog_endpoint", record: :none) do @@ -52,6 +54,8 @@ def update_registry!(catalog) end it "performs the job as expected" do + VCR.turn_on! + registry = create(:registry, "hostname" => "registry.test.lan") VCR.use_cassette("registry/get_registry_catalog", record: :none) do @@ -69,6 +73,8 @@ def update_registry!(catalog) end it "handles registries even if there some namespaces missing" do + VCR.turn_on! + registry = create(:registry, "hostname" => "registry.test.lan") VCR.use_cassette("registry/get_registry_catalog_namespace_missing", record: :none) do diff --git a/spec/lib/portus/registry_client_spec.rb b/spec/lib/portus/registry_client_spec.rb index 596a955c4..eca879042 100644 --- a/spec/lib/portus/registry_client_spec.rb +++ b/spec/lib/portus/registry_client_spec.rb @@ -67,6 +67,7 @@ def fetch_link_test(header) end it "fails if the registry has authentication enabled and no credentials are set" do + VCR.turn_on! path = "" registry = RegistryClientMissingCredentials.new(registry_server) VCR.use_cassette("registry/missing_credentials", record: :none) do diff --git a/spec/models/namespace_spec.rb b/spec/models/namespace_spec.rb index 5a030a443..5e1d4e5d2 100644 --- a/spec/models/namespace_spec.rb +++ b/spec/models/namespace_spec.rb @@ -97,7 +97,7 @@ let!(:registry) { create(:registry) } let!(:owner) { create(:user) } let!(:team) { create(:team, owners: [owner]) } - let!(:namespace) { create(:namespace, team: team) } + let!(:namespace) { create(:namespace, team: team, registry: registry) } let!(:repo) { create(:repository, namespace: namespace) } it "works for global namespaces" do @@ -112,5 +112,20 @@ expect(ns.id).to eq namespace.id expect(name).to eq repo.name end + + context "when providing a registry" do + it "works for global namespaces" do + ns = Namespace.find_by(global: true) + namespace, name = Namespace.get_from_name(repo.name, registry) + expect(namespace.id).to eq ns.id + expect(name).to eq repo.name + end + + it "works for user namespaces" do + ns, name = Namespace.get_from_name("#{namespace.name}/#{repo.name}", registry) + expect(ns.id).to eq namespace.id + expect(name).to eq repo.name + end + end end end diff --git a/spec/models/repository_spec.rb b/spec/models/repository_spec.rb index d1398f37c..368db34f1 100644 --- a/spec/models/repository_spec.rb +++ b/spec/models/repository_spec.rb @@ -75,6 +75,10 @@ def get_url(repo, tag) let(:registry) { create(:registry, hostname: "registry.test.lan") } let(:user) { create(:user) } + before :each do + VCR.turn_on! + end + context "adding an existing repo/tag" do it "does not add a new activity when an already existing repo/tag already existed" do event = { "actor" => { "name" => user.username } } diff --git a/spec/models/webhook_delivery_spec.rb b/spec/models/webhook_delivery_spec.rb index 8fc77dfcf..26392e4c9 100644 --- a/spec/models/webhook_delivery_spec.rb +++ b/spec/models/webhook_delivery_spec.rb @@ -5,7 +5,7 @@ # id :integer not null, primary key # webhook_id :integer # uuid :string(255) -# status :string(255) +# status :integer # request_header :text(65535) # request_body :text(65535) # response_header :text(65535) @@ -26,4 +26,62 @@ it { should belong_to(:webhook) } it { should validate_uniqueness_of(:uuid).scoped_to(:webhook_id) } + + describe "success?" do + let!(:registry) { create(:registry) } + let!(:owner) { create(:user) } + let!(:team) { create(:team, owners: [owner]) } + let!(:namespace) { create(:namespace, team: team) } + let!(:webhook) { create(:webhook, namespace: namespace) } + let!(:webhook_delivery) { create(:webhook_delivery, webhook: webhook) } + + it "returns true for HTTP code 200" do + webhook_delivery.status = 200 + expect(webhook_delivery.success?).to be true + end + + it "returns false for HTTP codes other than 200" do + webhook_delivery.status = 418 + expect(webhook_delivery.success?).to be false + end + end + + describe "retrigger" do + let!(:registry) { create(:registry) } + let!(:owner) { create(:user) } + let!(:team) { create(:team, owners: [owner]) } + let!(:namespace) { create(:namespace, team: team) } + let!(:webhook_noauth) { create(:webhook, namespace: namespace) } + let!(:webhook_auth) do + create(:webhook, namespace: namespace, username: "username", password: "password") + end + let!(:webhook_header) do + create(:webhook_header, webhook: webhook_noauth, name: "foo", value: "bar") + end + + before :each do + stub_request(:POST, "username:password@www.example.com") + .to_return( + status: 200, + body: %({"hello": "world"}), + headers: { "lorem" => "ipsum" } + ) + stub_request(:POST, "www.example.com").to_return( + status: 200, + body: %({"hello": "world"}), + headers: { "Lorem" => "ipsum" } + ) + end + + it "should resend HTTP requests" do + create :webhook_delivery, webhook: webhook_auth + create :webhook_delivery, webhook: webhook_noauth + WebhookDelivery.find_each do |delivery| + delivery.retrigger + expect(delivery.status).to eq 200 + expect(delivery.response_body).to eq %({"hello": "world"}) + expect(delivery.response_header).to eq "Lorem: ipsum" + end + end + end end diff --git a/spec/models/webhook_spec.rb b/spec/models/webhook_spec.rb index a6443b674..2d0711008 100644 --- a/spec/models/webhook_spec.rb +++ b/spec/models/webhook_spec.rb @@ -9,7 +9,7 @@ # password :string(255) # request_method :integer # content_type :integer -# enabled :boolean +# enabled :boolean default("0") # created_at :datetime not null # updated_at :datetime not null # @@ -30,8 +30,63 @@ it { should belong_to(:namespace) } it { should validate_presence_of(:url) } + it { should_not allow_value("won't work").for(:url) } it { should define_enum_for(:request_method) } it { should allow_value(*request_methods).for(:request_method) } it { should define_enum_for(:content_type) } it { should allow_value(*content_types).for(:content_type) } + + describe "push event" do + let!(:registry) { create(:registry) } + let!(:owner) { create(:user) } + let!(:team) { create(:team, owners: [owner]) } + let!(:namespace) { create(:namespace, team: team, registry: registry) } + let!(:repo) { create(:repository, namespace: namespace) } + let!(:event) do + { + "request" => { "host" => "#{registry.hostname}" }, + "target" => { "repository" => "#{namespace.name}/#{repo.name}" } + } + end + + before :each do + stub_request(:POST, "username:password@www.example.com") + .to_return(status: 200) + stub_request(:POST, "www.example.com").to_return(status: 200) + end + + context "triggering a webhook" do + let!(:webhook_noauth) { create(:webhook, namespace: namespace) } + let!(:webhook_auth) do + create(:webhook, namespace: namespace, username: "username", password: "password") + end + let!(:webhook_header) do + create(:webhook_header, webhook: webhook_auth, name: "foo", value: "bar") + end + + it "should work when given user credentials" do + Webhook.handle_push_event(event) + delivery = WebhookDelivery.find_by(webhook: webhook_auth) + expect(delivery.status).to eq 200 + expect(JSON.parse(delivery.request_body)).to eq event + end + + it "should work when providing no user credentials" do + Webhook.handle_push_event(event) + delivery = WebhookDelivery.find_by(webhook: webhook_noauth) + expect(delivery.status).to eq 200 + expect(JSON.parse(delivery.request_body)).to eq event + end + + it "should fail in the given namespace cannot be found" do + event["target"]["repository"] = "unknown_namespace/unknown_repo" + expect(Webhook.handle_push_event(event)).to be nil + end + end + + it "should skip disabled webhooks" do + Webhook.handle_push_event(event) + expect(WebhookDelivery.all).to be_empty + end + end end diff --git a/spec/policies/public_activity/activity_policy_spec.rb b/spec/policies/public_activity/activity_policy_spec.rb index b86da7839..cc329b5af 100644 --- a/spec/policies/public_activity/activity_policy_spec.rb +++ b/spec/policies/public_activity/activity_policy_spec.rb @@ -4,12 +4,15 @@ let(:user) { create(:user) } let(:another_user) { create(:user) } + let(:viewer) { create(:user) } + let(:contributor) { create(:user) } let(:activity_owner) { create(:user) } let(:registry) { create(:registry) } let(:namespace) { create(:namespace, registry: registry, team: team) } - let(:team) { create(:team, owners: [user]) } + let(:team) { create(:team, owners: [user], contributors: [contributor], viewers: [viewer]) } let(:repository) { create(:repository, namespace: namespace) } let(:tag) { create(:tag, repository: repository) } + let(:webhook) { create(:webhook, namespace: namespace, url: "http://example.com") } subject { described_class } @@ -124,6 +127,19 @@ expect(Pundit.policy_scope(user, PublicActivity::Activity).to_a).to match_array(activities) end + + it "returns pertinent webhook activities" do + activities = [ + create_activity_webhook_create(webhook, activity_owner), + create_activity_webhook_destroy(webhook, activity_owner, namespace) + ] + + expect(Pundit.policy_scope(user, PublicActivity::Activity).to_a).to match_array(activities) + expect(Pundit.policy_scope(contributor, PublicActivity::Activity).to_a) + .to match_array(activities) + expect(Pundit.policy_scope(viewer, PublicActivity::Activity).to_a).to match_array(activities) + expect(Pundit.policy_scope(another_user, PublicActivity::Activity).to_a).to be_empty + end end private @@ -165,4 +181,20 @@ def create_activity_application_token_created(owner) owner_id: owner.id) end + def create_activity_webhook_create(webhook, activity_owner) + create(:activity_webhook_create, + trackable_id: webhook.id, + owner_id: activity_owner.id) + end + + def create_activity_webhook_destroy(webhook, activity_owner, namespace) + create(:activity_webhook_destroy, + trackable_id: webhook.id, + owner_id: activity_owner.id, + parameters: { namespace_id: namespace.id, + namespace_name: namespace.name, + webhook_url: webhook.url, + webhook_host: webhook.host } + ) + end end diff --git a/spec/policies/webhook_header_policy_spec.rb b/spec/policies/webhook_header_policy_spec.rb deleted file mode 100644 index e257d114a..000000000 --- a/spec/policies/webhook_header_policy_spec.rb +++ /dev/null @@ -1,5 +0,0 @@ -require "rails_helper" - -describe WebhookHeaderPolicy do - pending -end diff --git a/spec/policies/webhook_policy_spec.rb b/spec/policies/webhook_policy_spec.rb index e5a191e75..153bd5e57 100644 --- a/spec/policies/webhook_policy_spec.rb +++ b/spec/policies/webhook_policy_spec.rb @@ -1,5 +1,81 @@ require "rails_helper" describe WebhookPolicy do - pending + subject { described_class } + + let(:user) { create(:user) } + let(:owner) { create(:user) } + let(:viewer) { create(:user) } + let(:contributor) { create(:user) } + let(:team) do + create(:team, + owners: [owner], + contributors: [contributor], + viewers: [viewer]) + end + let(:namespace) do + create( + :namespace, + description: "short test description.", + registry: @registry, + team: team) + end + let(:webhook) { create(:webhook, namespace: namespace) } + + before :each do + @admin = create(:admin) + @registry = create(:registry) + end + + permissions :toggle_enabled? do + it "allows admin to change it" do + expect(subject).to permit(@admin, webhook) + end + + it "allows owner to change it" do + expect(subject).to permit(owner, webhook) + end + + it "disallows contributor to change it" do + expect(subject).to_not permit(contributor, webhook) + end + + it "disallows user to change it" do + expect(subject).to_not permit(user, webhook) + end + + it "disallows viewer to change it" do + expect(subject).to_not permit(viewer, webhook) + end + end + + describe "scope" do + before :each do + webhook + end + + it "shows all webhooks" do + expected = Webhook.all + expect(Pundit.policy_scope(@admin, Webhook).to_a).to match_array(expected) + end + + it "shows webhooks to owner" do + expected = webhook + expect(Pundit.policy_scope(owner, Webhook).to_a).to match_array(expected) + end + + it "shows webhooks to contributor" do + expected = webhook + expect(Pundit.policy_scope(contributor, Webhook).to_a).to match_array(expected) + end + + it "shows webhooks to viewer" do + expected = webhook + expect(Pundit.policy_scope(viewer, Webhook).to_a).to match_array(expected) + end + + it "does not show webhooks to user" do + expect(Pundit.policy_scope(user, Webhook).to_a).to be_empty + end + end end