From 06d6b68d9d51bc293c57bce3e0758c5341303dae Mon Sep 17 00:00:00 2001 From: Michael Kohl Date: Tue, 28 Dec 2021 10:56:37 +0700 Subject: [PATCH] Remove webhooks and related code (#15827) --- Gemfile | 1 - Gemfile.lock | 2 - .../admin/webhook_endpoints_controller.rb | 11 -- app/controllers/api/v0/webhooks_controller.rb | 44 ----- app/models/admin_menu.rb | 1 - app/models/user.rb | 1 - app/models/webhook/endpoint.rb | 24 --- app/models/webhook/event.rb | 28 --- app/models/webhook_endpoint.rb | 7 - .../webhook/article_destroyed_serializer.rb | 6 - app/serializers/webhook/article_serializer.rb | 34 ---- app/serializers/webhook/event_serializer.rb | 8 - app/serializers/webhook/user_serializer.rb | 12 -- app/services/articles/creator.rb | 12 +- app/services/articles/destroyer.rb | 4 +- app/services/articles/updater.rb | 15 +- app/services/secrets/generator.rb | 10 - app/services/users/delete_activity.rb | 1 - app/services/webhook/dispatch_event.rb | 26 --- app/services/webhook/payload_adapter.rb | 30 --- app/views/api/v0/webhooks/index.json.jbuilder | 7 - app/views/api/v0/webhooks/show.json.jbuilder | 7 - app/workers/webhook/destroy_worker.rb | 11 -- app/workers/webhook/dispatch_event_worker.rb | 12 -- config/routes.rb | 1 - config/routes/admin.rb | 1 - spec/factories/webhook_endpoints.rb | 8 - spec/models/user_spec.rb | 1 - spec/models/webhook/endpoint_spec.rb | 59 ------ spec/models/webhook/event_spec.rb | 60 ------ spec/requests/api/v0/webhooks_spec.rb | 173 ------------------ .../requests/articles/articles_create_spec.rb | 18 -- .../requests/articles/articles_update_spec.rb | 9 - spec/services/articles/creator_spec.rb | 22 --- spec/services/articles/destroyer_spec.rb | 15 -- spec/services/articles/updater_spec.rb | 30 --- spec/services/secrets/generator_spec.rb | 18 -- spec/services/webhook/dispatch_event_spec.rb | 34 ---- spec/services/webhook/payload_adapter_spec.rb | 35 ---- spec/workers/webhook/destroy_worker_spec.rb | 16 -- .../webhook/dispatch_event_worker_spec.rb | 29 --- vendor/cache/ulid-1.3.0.gem | Bin 16896 -> 0 bytes 42 files changed, 5 insertions(+), 838 deletions(-) delete mode 100644 app/controllers/admin/webhook_endpoints_controller.rb delete mode 100644 app/controllers/api/v0/webhooks_controller.rb delete mode 100644 app/models/webhook/endpoint.rb delete mode 100644 app/models/webhook/event.rb delete mode 100644 app/models/webhook_endpoint.rb delete mode 100644 app/serializers/webhook/article_destroyed_serializer.rb delete mode 100644 app/serializers/webhook/article_serializer.rb delete mode 100644 app/serializers/webhook/event_serializer.rb delete mode 100644 app/serializers/webhook/user_serializer.rb delete mode 100644 app/services/secrets/generator.rb delete mode 100644 app/services/webhook/dispatch_event.rb delete mode 100644 app/services/webhook/payload_adapter.rb delete mode 100644 app/views/api/v0/webhooks/index.json.jbuilder delete mode 100644 app/views/api/v0/webhooks/show.json.jbuilder delete mode 100644 app/workers/webhook/destroy_worker.rb delete mode 100644 app/workers/webhook/dispatch_event_worker.rb delete mode 100644 spec/factories/webhook_endpoints.rb delete mode 100644 spec/models/webhook/endpoint_spec.rb delete mode 100644 spec/models/webhook/event_spec.rb delete mode 100644 spec/requests/api/v0/webhooks_spec.rb delete mode 100644 spec/services/secrets/generator_spec.rb delete mode 100644 spec/services/webhook/dispatch_event_spec.rb delete mode 100644 spec/services/webhook/payload_adapter_spec.rb delete mode 100644 spec/workers/webhook/destroy_worker_spec.rb delete mode 100644 spec/workers/webhook/dispatch_event_worker_spec.rb delete mode 100644 vendor/cache/ulid-1.3.0.gem diff --git a/Gemfile b/Gemfile index 85e843b44..aed818761 100644 --- a/Gemfile +++ b/Gemfile @@ -112,7 +112,6 @@ gem "stripe", "~> 5.42" # Ruby library for the Stripe API gem "strong_migrations", "~> 0.7" # Catch unsafe migrations gem "twitter", "~> 7.0" # A Ruby interface to the Twitter API gem "uglifier", "~> 4.2" # Uglifier minifies JavaScript files -gem "ulid", "~> 1.3" # Universally Unique Lexicographically Sortable Identifier implementation for Ruby gem "validate_url", "~> 1.0" # Library for validating urls in Rails gem "vault", "~> 0.16" # Used to store secrets gem "view_component", "~> 2.47" # View components for Rails diff --git a/Gemfile.lock b/Gemfile.lock index f0dd4292f..b9ac6ba40 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -853,7 +853,6 @@ GEM concurrent-ruby (~> 1.0) uglifier (4.2.0) execjs (>= 0.3.0, < 3) - ulid (1.3.0) unf (0.1.4) unf_ext unf_ext (0.0.8) @@ -1059,7 +1058,6 @@ DEPENDENCIES timecop (~> 0.9) twitter (~> 7.0) uglifier (~> 4.2) - ulid (~> 1.3) validate_url (~> 1.0) vault (~> 0.16) vcr (~> 6.0) diff --git a/app/controllers/admin/webhook_endpoints_controller.rb b/app/controllers/admin/webhook_endpoints_controller.rb deleted file mode 100644 index 2931ae2c3..000000000 --- a/app/controllers/admin/webhook_endpoints_controller.rb +++ /dev/null @@ -1,11 +0,0 @@ -module Admin - class WebhookEndpointsController < Admin::ApplicationController - layout "admin" - - def index - @endpoints = Webhook::Endpoint.includes(:user) - .page(params[:page]).per(50) - .order(created_at: :desc) - end - end -end diff --git a/app/controllers/api/v0/webhooks_controller.rb b/app/controllers/api/v0/webhooks_controller.rb deleted file mode 100644 index e5d94694a..000000000 --- a/app/controllers/api/v0/webhooks_controller.rb +++ /dev/null @@ -1,44 +0,0 @@ -module Api - module V0 - class WebhooksController < ApiController - before_action :authenticate! - - skip_before_action :verify_authenticity_token, only: %w[create destroy] - - ATTRIBUTES_FOR_SERIALIZATION = %i[id user_id source target_url events created_at].freeze - private_constant :ATTRIBUTES_FOR_SERIALIZATION - - def index - @webhooks = webhooks_scope.order(:id) - end - - def create - @webhook = @user.webhook_endpoints.new(webhook_params) - @webhook.save! - - render "show", status: :created - end - - def show - @webhook = webhooks_scope.find(params[:id]) - end - - def destroy - webhook = webhooks_scope.find(params[:id]) - webhook.destroy! - - head :no_content - end - - private - - def webhooks_scope - @user.webhook_endpoints.select(ATTRIBUTES_FOR_SERIALIZATION) - end - - def webhook_params - params.require(:webhook_endpoint).permit(:target_url, :source, events: []) - end - end - end -end diff --git a/app/models/admin_menu.rb b/app/models/admin_menu.rb index 8b9693071..806b72f83 100644 --- a/app/models/admin_menu.rb +++ b/app/models/admin_menu.rb @@ -50,7 +50,6 @@ class AdminMenu item(name: "developer tools", controller: "tools", children: [ item(name: "tools"), item(name: "vault secrets", controller: "secrets"), - item(name: "webhooks", controller: "webhook_endpoints"), item(name: "data update scripts", visible: false), ]), ] diff --git a/app/models/user.rb b/app/models/user.rb index aeec196d9..6f00691ba 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -111,7 +111,6 @@ class User < ApplicationRecord foreign_key: :subscriber_id, inverse_of: :subscriber, dependent: :destroy has_many :subscribers, through: :source_authored_user_subscriptions, dependent: :destroy has_many :tweets, dependent: :nullify - has_many :webhook_endpoints, class_name: "Webhook::Endpoint", inverse_of: :user, dependent: :delete_all has_many :devices, dependent: :delete_all has_many :sponsorships, dependent: :delete_all diff --git a/app/models/webhook/endpoint.rb b/app/models/webhook/endpoint.rb deleted file mode 100644 index afdc64200..000000000 --- a/app/models/webhook/endpoint.rb +++ /dev/null @@ -1,24 +0,0 @@ -module Webhook - class Endpoint < ApplicationRecord - belongs_to :user, inverse_of: :webhook_endpoints - - validates :events, presence: true - validates :source, presence: true - validates :target_url, presence: true, uniqueness: true, url: { schemes: %w[https] } - validates :user_id, presence: true - - attribute :events, :string, array: true, default: [] - - scope :for_events, ->(events) { where("events @> ARRAY[?]::varchar[]", Array(events)) } - scope :for_app, ->(app_id) { where(oauth_application_id: app_id) } - - def self.table_name_prefix - "webhook_" - end - - def events=(events) - events = Array(events).map { |event| event.to_s.underscore } - super(Webhook::Event::EVENT_TYPES & events) - end - end -end diff --git a/app/models/webhook/event.rb b/app/models/webhook/event.rb deleted file mode 100644 index 74baf9182..000000000 --- a/app/models/webhook/event.rb +++ /dev/null @@ -1,28 +0,0 @@ -module Webhook - class Event - EVENT_TYPES = %w[ - article_created - article_updated - article_destroyed - ].freeze - - attr_reader :event_type, :payload, :timestamp, :event_id - - def initialize(event_type:, payload: {}) - raise InvalidEvent unless EVENT_TYPES.include?(event_type) - - @event_type = event_type - @payload = payload - - now = Time.current - @timestamp = now.rfc3339 - @event_id = Secrets::Generator.sortable(now) - end - - def as_json(*_args) - Webhook::EventSerializer.new(self).serializable_hash - end - end - - class InvalidEvent < StandardError; end -end diff --git a/app/models/webhook_endpoint.rb b/app/models/webhook_endpoint.rb deleted file mode 100644 index 5e2712ccc..000000000 --- a/app/models/webhook_endpoint.rb +++ /dev/null @@ -1,7 +0,0 @@ -class WebhookEndpoint < ApplicationRecord - resourcify - # This class exists to take advantage of Rolify for limiting authorization - # on internal reports. - # NOTE: It is not backed by a database table and should not be expected to - # function like a traditional Rails model -end diff --git a/app/serializers/webhook/article_destroyed_serializer.rb b/app/serializers/webhook/article_destroyed_serializer.rb deleted file mode 100644 index 785180093..000000000 --- a/app/serializers/webhook/article_destroyed_serializer.rb +++ /dev/null @@ -1,6 +0,0 @@ -module Webhook - class ArticleDestroyedSerializer < ApplicationSerializer - set_type :article - attributes :title - end -end diff --git a/app/serializers/webhook/article_serializer.rb b/app/serializers/webhook/article_serializer.rb deleted file mode 100644 index 13fd9ed35..000000000 --- a/app/serializers/webhook/article_serializer.rb +++ /dev/null @@ -1,34 +0,0 @@ -module Webhook - class ArticleSerializer < ApplicationSerializer - set_type :article - attributes :title, :description, :readable_publish_date, :cached_tag_list, :cached_tag_list_array, - :slug, :path, :url, :comments_count, :public_reactions_count, :body_markdown - - attribute :canonical_url, &:processed_canonical_url - attribute :body_html, &:processed_html - attribute :created_at do |a| - a.created_at.utc.iso8601 - end - attribute :edited_at do |a| - a.edited_at&.utc&.iso8601 - end - attribute :crossposted_at do |a| - a.crossposted_at&.utc&.iso8601 - end - attribute :published_at do |a| - a.published_at&.utc&.iso8601 - end - attribute :last_comment_at do |a| - a.last_comment_at&.utc&.iso8601 - end - attribute :cover_image do |a| - CloudCoverUrl.new(a.url).call - end - attribute :social_image do |article| - Articles::SocialImage.new(article).url - end - attribute :user do |a| - UserSerializer.new(a.user).serializable_hash - end - end -end diff --git a/app/serializers/webhook/event_serializer.rb b/app/serializers/webhook/event_serializer.rb deleted file mode 100644 index 528e98e5c..000000000 --- a/app/serializers/webhook/event_serializer.rb +++ /dev/null @@ -1,8 +0,0 @@ -module Webhook - class EventSerializer < ApplicationSerializer - set_type :webhook_event - set_id :event_id - - attributes :event_type, :timestamp, :payload - end -end diff --git a/app/serializers/webhook/user_serializer.rb b/app/serializers/webhook/user_serializer.rb deleted file mode 100644 index e07ff9537..000000000 --- a/app/serializers/webhook/user_serializer.rb +++ /dev/null @@ -1,12 +0,0 @@ -module Webhook - class UserSerializer < ApplicationSerializer - attributes :name, :username, :twitter_username, :github_username - attribute :website_url, &:processed_website_url - attribute :profile_image do |user| - Images::Profile.call(user.profile_image_url, length: 640) - end - attribute :profile_image_90 do |user| - Images::Profile.call(user.profile_image_url, length: 90) - end - end -end diff --git a/app/services/articles/creator.rb b/app/services/articles/creator.rb index 593c28dcc..3728e4ead 100644 --- a/app/services/articles/creator.rb +++ b/app/services/articles/creator.rb @@ -1,9 +1,8 @@ module Articles class Creator - def initialize(user, article_params, event_dispatcher = Webhook::DispatchEvent) + def initialize(user, article_params) @user = user @article_params = article_params - @event_dispatcher = event_dispatcher end def self.call(...) @@ -22,7 +21,6 @@ module Articles # Send notifications to any mentioned users, followed by any users who follow the article's author. Notification.send_to_mentioned_users_and_followers(article) if article.published? - dispatch_event(article) end article @@ -30,7 +28,7 @@ module Articles private - attr_reader :user, :article_params, :event_dispatcher + attr_reader :user, :article_params def rate_limit! rate_limit_to_use = if user.decorate.considered_new? @@ -42,12 +40,6 @@ module Articles user.rate_limiter.check_limit!(rate_limit_to_use) end - def dispatch_event(article) - return unless article.published? - - event_dispatcher.call("article_created", article) - end - def save_article series = article_params[:series] tags = article_params[:tags] diff --git a/app/services/articles/destroyer.rb b/app/services/articles/destroyer.rb index 9dc18ef2e..1e764836a 100644 --- a/app/services/articles/destroyer.rb +++ b/app/services/articles/destroyer.rb @@ -2,7 +2,7 @@ module Articles module Destroyer module_function - def call(article, event_dispatcher = Webhook::DispatchEvent) + def call(article) # comments will automatically lose the connection to their article once `.destroy` is called, # due to the `dependent: nullify` clause, so to remove their notifications, # we need to cache the ids in advance @@ -15,8 +15,6 @@ module Articles if article_comments_ids.present? Notification.remove_all(notifiable_ids: article_comments_ids, notifiable_type: "Comment") end - - event_dispatcher.call("article_destroyed", article) if article.published? end end end diff --git a/app/services/articles/updater.rb b/app/services/articles/updater.rb index 0aab18361..61026503c 100644 --- a/app/services/articles/updater.rb +++ b/app/services/articles/updater.rb @@ -2,11 +2,10 @@ module Articles class Updater Result = Struct.new(:success, :article, keyword_init: true) - def initialize(user, article, article_params, event_dispatcher = Webhook::DispatchEvent) + def initialize(user, article, article_params) @user = user @article = article @article_params = article_params - @event_dispatcher = event_dispatcher end def self.call(...) @@ -16,9 +15,6 @@ module Articles def call user.rate_limiter.check_limit!(:article_update) - # Grab the state of the article's "publish" status before making any further updates to it. - was_previously_published = article.published - # updated edited time only if already published and not edited by an admin update_edited_at = article.user == user && article.published attrs = Articles::Attributes.new(article_params, article.user).for_update(update_edited_at: update_edited_at) @@ -52,19 +48,12 @@ module Articles notifiable_type: "Mention") end end - - # Do not notify if the article was previously already in a published state or is continually unpublished. - dispatch_event(article) if article.published || was_previously_published end Result.new(success: success, article: article.decorate) end private - attr_reader :user, :article, :article_params, :event_dispatcher - - def dispatch_event(article) - event_dispatcher.call("article_updated", article) - end + attr_reader :user, :article, :article_params end end diff --git a/app/services/secrets/generator.rb b/app/services/secrets/generator.rb deleted file mode 100644 index 80c9bf59a..000000000 --- a/app/services/secrets/generator.rb +++ /dev/null @@ -1,10 +0,0 @@ -module Secrets - module Generator - module_function - - # Generates a unique and lexicographically sortable ID - def sortable(time = Time.current) - ULID.generate(time) - end - end -end diff --git a/app/services/users/delete_activity.rb b/app/services/users/delete_activity.rb index 5be44e76f..9e05cf357 100644 --- a/app/services/users/delete_activity.rb +++ b/app/services/users/delete_activity.rb @@ -9,7 +9,6 @@ module Users user.created_podcasts.update_all(creator_id: nil) user.blocker_blocks.delete_all user.blocked_blocks.delete_all - user.webhook_endpoints.delete_all user.authored_notes.delete_all user.display_ad_events.delete_all user.email_messages.delete_all diff --git a/app/services/webhook/dispatch_event.rb b/app/services/webhook/dispatch_event.rb deleted file mode 100644 index 9c3fab000..000000000 --- a/app/services/webhook/dispatch_event.rb +++ /dev/null @@ -1,26 +0,0 @@ -module Webhook - class DispatchEvent - def initialize(event_type, record) - @event_type = event_type - @record = record - end - - def self.call(...) - new(...).call - end - - def call - endpoint_urls = Endpoint.for_events([event_type]).where(user_id: record.user_id).pluck(:target_url) - return if endpoint_urls.empty? - - event_json = Event.new(event_type: event_type, payload: PayloadAdapter.new(record).hash).to_json - endpoint_urls.each do |url| - DispatchEventWorker.perform_async(url, event_json) - end - end - - private - - attr_reader :event_type, :record - end -end diff --git a/app/services/webhook/payload_adapter.rb b/app/services/webhook/payload_adapter.rb deleted file mode 100644 index 67dac74b4..000000000 --- a/app/services/webhook/payload_adapter.rb +++ /dev/null @@ -1,30 +0,0 @@ -module Webhook - class PayloadAdapter - def initialize(object) - raise InvalidPayloadObject unless object.is_a?(Article) - - @object = object - end - - def hash - serializer.new(prepared_object).serializable_hash - end - - private - - attr_reader :object - - # decorate article before serializing - def prepared_object - return object unless object.is_a?(Article) && !object.decorated? - - object.decorate - end - - def serializer - object.destroyed? ? ArticleDestroyedSerializer : ArticleSerializer - end - end - - class InvalidPayloadObject < StandardError; end -end diff --git a/app/views/api/v0/webhooks/index.json.jbuilder b/app/views/api/v0/webhooks/index.json.jbuilder deleted file mode 100644 index c82ac6a4e..000000000 --- a/app/views/api/v0/webhooks/index.json.jbuilder +++ /dev/null @@ -1,7 +0,0 @@ -json.array! @webhooks.each do |endpoint| - json.type_of "webhook_endpoint" - - json.extract!(endpoint, :id, :source, :target_url, :events) - - json.created_at endpoint.created_at.rfc3339 -end diff --git a/app/views/api/v0/webhooks/show.json.jbuilder b/app/views/api/v0/webhooks/show.json.jbuilder deleted file mode 100644 index 886e74650..000000000 --- a/app/views/api/v0/webhooks/show.json.jbuilder +++ /dev/null @@ -1,7 +0,0 @@ -json.type_of "webhook_endpoint" - -json.call(@webhook, :id, :source, :target_url, :events) - -json.created_at @webhook.created_at.rfc3339 - -json.partial! "api/v0/shared/user", user: @webhook.user diff --git a/app/workers/webhook/destroy_worker.rb b/app/workers/webhook/destroy_worker.rb deleted file mode 100644 index c37d27915..000000000 --- a/app/workers/webhook/destroy_worker.rb +++ /dev/null @@ -1,11 +0,0 @@ -module Webhook - class DestroyWorker - include Sidekiq::Worker - - sidekiq_options queue: :low_priority, retry: 10 - - def perform(user_id, application_id) - Webhook::Endpoint.destroy_by(user_id: user_id, oauth_application_id: application_id) - end - end -end diff --git a/app/workers/webhook/dispatch_event_worker.rb b/app/workers/webhook/dispatch_event_worker.rb deleted file mode 100644 index 1321b4241..000000000 --- a/app/workers/webhook/dispatch_event_worker.rb +++ /dev/null @@ -1,12 +0,0 @@ -module Webhook - class DispatchEventWorker - include Sidekiq::Worker - - sidekiq_options queue: :medium_priority - - def perform(endpoint_url, event_json) - uri = Addressable::URI.parse(endpoint_url) - HTTParty.post(uri, headers: { "Content-Type" => "application/json" }, body: event_json, timeout: 10) - end - end -end diff --git a/config/routes.rb b/config/routes.rb index 46cccd218..de04875fa 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -64,7 +64,6 @@ Rails.application.routes.draw do get :organizations end resources :readinglist, only: [:index] - resources :webhooks, only: %i[index create show destroy] resources :listings, only: %i[index show create update] get "/listings/category/:category", to: "listings#index", as: :listings_category diff --git a/config/routes/admin.rb b/config/routes/admin.rb index 033f3bdbd..9ac0eedfa 100644 --- a/config/routes/admin.rb +++ b/config/routes/admin.rb @@ -143,7 +143,6 @@ namespace :admin do post "bust_cache" end end - resources :webhook_endpoints, only: :index # We do not expose the Data Update Scripts to all Forems by default. constraints(->(_request) { FeatureFlag.enabled?(:data_update_scripts) }) do diff --git a/spec/factories/webhook_endpoints.rb b/spec/factories/webhook_endpoints.rb deleted file mode 100644 index 06ce91bc2..000000000 --- a/spec/factories/webhook_endpoints.rb +++ /dev/null @@ -1,8 +0,0 @@ -FactoryBot.define do - factory :webhook_endpoint, class: "Webhook::Endpoint" do - target_url { Faker::Internet.url(scheme: "https") } - events { Webhook::Event::EVENT_TYPES } - user - source { "stackbit" } - end -end diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 5010a400d..76dd00371 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -110,7 +110,6 @@ RSpec.describe User, type: :model do it { is_expected.to have_many(:subscribed_to_user_subscriptions).dependent(:destroy) } it { is_expected.to have_many(:subscribers).dependent(:destroy) } it { is_expected.to have_many(:tweets).dependent(:nullify) } - it { is_expected.to have_many(:webhook_endpoints).class_name("Webhook::Endpoint").dependent(:delete_all) } # rubocop:disable RSpec/NamedSubject it do diff --git a/spec/models/webhook/endpoint_spec.rb b/spec/models/webhook/endpoint_spec.rb deleted file mode 100644 index 5570e662e..000000000 --- a/spec/models/webhook/endpoint_spec.rb +++ /dev/null @@ -1,59 +0,0 @@ -require "rails_helper" - -RSpec.describe Webhook::Endpoint, type: :model do - let!(:endpoint) do - create( - :webhook_endpoint, user: user, events: %w[article_created article_updated article_destroyed] - ) - end - let(:user) { create(:user) } - - describe "validations" do - it { is_expected.to belong_to(:user).inverse_of(:webhook_endpoints) } - - it { is_expected.to validate_presence_of(:events) } - it { is_expected.to validate_presence_of(:source) } - it { is_expected.to validate_presence_of(:target_url) } - it { is_expected.to validate_presence_of(:user_id) } - - it { is_expected.to validate_uniqueness_of(:target_url) } - - it { is_expected.to allow_value("https://foo.com").for(:target_url) } - it { is_expected.not_to allow_value("http://foo.com").for(:target_url) } - end - - it "is valid" do - expect(endpoint).to be_valid - end - - it "sets events according to the list" do - webhook = create( - :webhook_endpoint, events: %w[article_updated other_updated cool article_created] - ) - webhook.reload - expect(webhook.events.sort).to eq(%w[article_created article_updated]) - end - - context "when endpoints exist" do - let!(:epoint2) { create(:webhook_endpoint, events: %w[article_destroyed], user: user) } - let!(:epoint3) { create(:webhook_endpoint, events: %w[article_updated article_destroyed]) } - - before do - create(:webhook_endpoint, events: %w[article_created]) - end - - it "finds for events" do - d_points = described_class.for_events("article_destroyed") - expect(d_points.ids.sort).to eq([endpoint, epoint2, epoint3].map(&:id).sort) - end - - it "finds for_events array" do - endpoints = described_class.for_events(%w[article_created article_destroyed]) - expect(endpoints.ids).to eq([endpoint.id]) - end - - it "belongs to user" do - expect(user.webhook_endpoints.ids.sort).to eq([endpoint, epoint2].map(&:id).sort) - end - end -end diff --git a/spec/models/webhook/event_spec.rb b/spec/models/webhook/event_spec.rb deleted file mode 100644 index 5126681bc..000000000 --- a/spec/models/webhook/event_spec.rb +++ /dev/null @@ -1,60 +0,0 @@ -require "rails_helper" - -RSpec.describe Webhook::Event, type: :model do - let(:article) { create(:article) } - let(:payload) { Webhook::PayloadAdapter.new(article).hash } - - describe "validations" do - it "raises an exception with a unknown event type" do - expect do - described_class.new(event_type: "cool_event") - end.to raise_error(Webhook::InvalidEvent) - end - end - - describe "#as_json" do - it "provides correct json (sample)" do - event = described_class.new(event_type: "article_created", payload: { title: "Hello, world" }) - hash = event.as_json - - attributes = hash[:data][:attributes] - - expect(attributes[:event_type]).to eq("article_created") - expect(attributes[:timestamp]).to be_truthy - expect(attributes[:payload][:title]).to eq("Hello, world") - end - - it "provides correct json including article" do - event = described_class.new(event_type: "article_updated", payload: payload) - hash = event.as_json - attributes = hash[:data][:attributes] - expect(attributes[:event_type]).to eq("article_updated") - expect(attributes[:payload][:data][:attributes][:title]).to eq(article.title) - end - - it "provides an event_id dependent on time" do - event1 = described_class.new(event_type: "article_updated", payload: payload) - event1_id = event1.as_json.dig(:data, :id) - - event2 = nil - Timecop.freeze(1.month.ago) do - event2 = described_class.new(event_type: "article_updated", payload: payload) - end - event2_id = event2.as_json.dig(:data, :id) - - expect(event2_id < event1_id).to be(true) - end - end - - describe "#to_json" do - it "provides correct json including article" do - event = described_class.new(event_type: "article_updated", payload: payload) - json = event.to_json - hash = JSON.parse(json) - attributes = hash["data"]["attributes"] - - expect(attributes["event_type"]).to eq("article_updated") - expect(attributes["payload"]["data"]["attributes"]["title"]).to eq(article.title) - end - end -end diff --git a/spec/requests/api/v0/webhooks_spec.rb b/spec/requests/api/v0/webhooks_spec.rb deleted file mode 100644 index 6eb3e8bbc..000000000 --- a/spec/requests/api/v0/webhooks_spec.rb +++ /dev/null @@ -1,173 +0,0 @@ -require "rails_helper" - -RSpec.describe "Api::V0::Webhooks", type: :request do - let(:user) { create(:user) } - let!(:webhook) do - create(:webhook_endpoint, user: user, target_url: "https://api.example.com/go") - end - - describe "GET /api/v0/webhooks" do - let(:webhook2) do - create(:webhook_endpoint, user: user, target_url: "https://api.example.com/webhook") - end - - context "when accessing via cookie" do - before do - sign_in user - create(:webhook_endpoint) - end - - it "returns 200 on success" do - get api_webhooks_path - expect(response).to have_http_status(:ok) - end - - it "returns json on success" do - webhook2 - get api_webhooks_path - - expect(response.parsed_body).to include( - hash_including("id" => webhook.id, "target_url" => webhook.target_url), - hash_including("id" => webhook2.id, "target_url" => webhook2.target_url), - ) - end - - it "returns the correct json representation" do - get api_webhooks_path - - expect(response.parsed_body.first).to eq( - "created_at" => webhook.created_at.rfc3339, - "events" => webhook.events, - "id" => webhook.id, - "source" => webhook.source, - "target_url" => webhook.target_url, - "type_of" => "webhook_endpoint", - ) - end - end - end - - describe "GET /api/v0/webhooks/:id" do - before do - sign_in user - end - - it "returns 200 on success" do - get api_webhook_path(webhook.id) - - expect(response).to have_http_status(:ok) - end - - it "returns 404 if the webhook does not exist" do - get api_webhook_path(9999) - - expect(response).to have_http_status(:not_found) - end - - it "returns 404 if another user webhook is accessed" do - other_webhook = create(:webhook_endpoint, user: create(:user)) - - get api_webhook_path(other_webhook.id) - - expect(response).to have_http_status(:not_found) - end - - it "returns the correct json representation" do - get api_webhook_path(webhook.id) - - expect(response.parsed_body).to include( - "created_at" => webhook.created_at.rfc3339, - "events" => webhook.events, - "id" => webhook.id, - "source" => webhook.source, - "target_url" => webhook.target_url, - "type_of" => "webhook_endpoint", - ) - end - - it "returns the correct json representation for the webhook user" do - get api_webhook_path(webhook.id) - - response_webhook_user = response.parsed_body["user"] - - expect(response_webhook_user).to eq({ - "name" => webhook.user.name, - "username" => webhook.user.username, - "twitter_username" => webhook.user.twitter_username, - "github_username" => webhook.user.github_username, - "website_url" => webhook.user.processed_website_url, - "profile_image" => Images::Profile.call(webhook.user.profile_image_url, - length: 640), - "profile_image_90" => Images::Profile.call(webhook.user.profile_image_url, - length: 90) - }) - end - end - - describe "POST /api/v0/webhooks" do - let(:webhook_params) do - { - source: "stackbit", - target_url: Faker::Internet.url(scheme: "https"), - events: %w[article_created article_updated article_destroyed] - } - end - - before do - sign_in user - end - - it "creates a webhook" do - expect do - post api_webhooks_path, params: { webhook_endpoint: webhook_params } - end.to change(Webhook::Endpoint, :count).by(1) - end - - it "creates a webhook with events and data" do - post api_webhooks_path, params: { webhook_endpoint: webhook_params } - created_webhook = user.webhook_endpoints.last - expect(created_webhook.events).to eq(%w[article_created article_updated article_destroyed]) - expect(created_webhook.target_url).to eq(webhook_params[:target_url]) - expect(created_webhook.source).to eq(webhook_params[:source]) - expect(created_webhook.oauth_application_id).to eq(nil) - end - - it "returns :created and json response on success" do - post api_webhooks_path, params: { webhook_endpoint: webhook_params } - expect(response).to have_http_status(:created) - expect(response.media_type).to eq("application/json") - json = JSON.parse(response.body) - expect(json["target_url"]).to eq(webhook_params[:target_url]) - end - end - - describe "DELETE /api/v0/webhooks/:id" do - before do - sign_in user - end - - it "deletes the webhook" do - expect do - delete api_webhook_path(webhook.id) - end.to change(Webhook::Endpoint, :count).by(-1) - end - - it "returns 204 on success" do - delete api_webhook_path(webhook.id) - expect(response).to have_http_status(:no_content) - end - - it "doesn't allow to destroy other user webhook" do - other_webhook = create(:webhook_endpoint, user: create(:user)) - expect do - delete api_webhook_path(other_webhook.id) - end.not_to change(Webhook::Endpoint, :count) - end - - it "returns 404 if another user webhook is accessed" do - other_webhook = create(:webhook_endpoint, user: create(:user)) - delete api_webhook_path(other_webhook.id) - expect(response).to have_http_status(:not_found) - end - end -end diff --git a/spec/requests/articles/articles_create_spec.rb b/spec/requests/articles/articles_create_spec.rb index bdf381d5e..918a6f563 100644 --- a/spec/requests/articles/articles_create_spec.rb +++ b/spec/requests/articles/articles_create_spec.rb @@ -77,24 +77,6 @@ RSpec.describe "ArticlesCreate", type: :request do } end - before do - create(:webhook_endpoint, events: %w[article_created article_updated], target_url: url, user: user) - end - - it "doesn't schedule a dispatching event job (unpublished)" do - sidekiq_assert_no_enqueued_jobs(only: Webhook::DispatchEventWorker) do - post "/articles", params: article_params - end - end - - it "schedules a dispatching event job (published)" do - body_markdown = "---\ntitle: hey hey hahuu\npublished: true\nseries: helloyo\n---\nYo ho ho#{rand(100)}" - article_params[:article][:body_markdown] = body_markdown - sidekiq_assert_enqueued_jobs(1, only: Webhook::DispatchEventWorker) do - post "/articles", params: article_params - end - end - it "doesn't fail when executing jobs" do stub_request(:post, url).to_return(status: 200) sidekiq_perform_enqueued_jobs do diff --git a/spec/requests/articles/articles_update_spec.rb b/spec/requests/articles/articles_update_spec.rb index 551c90db4..14d076612 100644 --- a/spec/requests/articles/articles_update_spec.rb +++ b/spec/requests/articles/articles_update_spec.rb @@ -167,13 +167,4 @@ RSpec.describe "ArticlesUpdate", type: :request do expect(response).to redirect_to "#{article.path}/edit" expect(article.reload.video_thumbnail_url).to include "https://i.imgur.com/HPiu7N4.jpg" end - - it "schedules a dispatching event job" do - create(:webhook_endpoint, events: %w[article_created article_updated], user: user) - sidekiq_assert_enqueued_jobs(1, only: Webhook::DispatchEventWorker) do - put "/articles/#{article.id}", params: { - article: { title: "new_title", body_markdown: "Yo ho ho#{rand(100)}", tag_list: "yo" } - } - end - end end diff --git a/spec/services/articles/creator_spec.rb b/spec/services/articles/creator_spec.rb index b21ddaeeb..e164af68d 100644 --- a/spec/services/articles/creator_spec.rb +++ b/spec/services/articles/creator_spec.rb @@ -38,21 +38,6 @@ RSpec.describe Articles::Creator, type: :service do described_class.call(user, valid_attributes) end.to change(NotificationSubscription, :count).by(1) end - - it "calls an event dispatcher" do - event_dispatcher = double - allow(event_dispatcher).to receive(:call) - article = described_class.call(user, valid_attributes, event_dispatcher) - expect(event_dispatcher).to have_received(:call).with("article_created", article) - end - - it "doesn't call an event dispatcher when an article is unpublished" do - attributes = attributes_for(:article, published: false) - event_dispatcher = double - allow(event_dispatcher).to receive(:call) - article = described_class.call(user, attributes, event_dispatcher) - expect(event_dispatcher).not_to have_received(:call).with("article_created", article) - end end context "when invalid attributes" do @@ -94,12 +79,5 @@ RSpec.describe Articles::Creator, type: :service do described_class.call(user, invalid_body_attributes) end.not_to change(NotificationSubscription, :count) end - - it "doesn't call an event dispatcher" do - event_dispatcher = double - allow(event_dispatcher).to receive(:call) - described_class.call(user, invalid_body_attributes, event_dispatcher) - expect(event_dispatcher).not_to have_received(:call) - end end end diff --git a/spec/services/articles/destroyer_spec.rb b/spec/services/articles/destroyer_spec.rb index e014e4f03..9720d4d0f 100644 --- a/spec/services/articles/destroyer_spec.rb +++ b/spec/services/articles/destroyer_spec.rb @@ -4,10 +4,6 @@ RSpec.describe Articles::Destroyer, type: :service do let(:article) { create(:article) } let(:event_dispatcher) { double } - before do - allow(event_dispatcher).to receive(:call) - end - it "destroys an article" do described_class.call(article) expect(Article.find_by(id: article.id)).to be_nil @@ -19,15 +15,4 @@ RSpec.describe Articles::Destroyer, type: :service do described_class.call(article) end end - - it "calls events dispatcher" do - described_class.call(article, event_dispatcher) - expect(event_dispatcher).to have_received(:call).with("article_destroyed", article) - end - - it "doesn't call a dispatched when a draft is destroyed" do - draft = create(:article, published: false) - described_class.call(draft, event_dispatcher) - expect(event_dispatcher).not_to have_received(:call) - end end diff --git a/spec/services/articles/updater_spec.rb b/spec/services/articles/updater_spec.rb index e1b34b80c..fd1aea54c 100644 --- a/spec/services/articles/updater_spec.rb +++ b/spec/services/articles/updater_spec.rb @@ -138,34 +138,4 @@ RSpec.describe Articles::Updater, type: :service do end end end - - describe "events dispatcher" do - let(:event_dispatcher) { double } - - before do - allow(event_dispatcher).to receive(:call) - end - - it "calls the dispatcher" do - described_class.call(user, article, attributes, event_dispatcher) - expect(event_dispatcher).to have_received(:call).with("article_updated", article) - end - - it "doesn't call the dispatcher when unpublished => unpublished" do - described_class.call(user, draft, attributes, event_dispatcher) - expect(event_dispatcher).not_to have_received(:call) - end - - it "calls the dispatcher when unpublished => published" do - attributes[:published] = true - described_class.call(user, draft, attributes, event_dispatcher) - expect(event_dispatcher).to have_received(:call).with("article_updated", draft) - end - - it "calls the dispatcher when published => unpublished" do - attributes[:published] = false - described_class.call(user, article, attributes, event_dispatcher) - expect(event_dispatcher).to have_received(:call).with("article_updated", article) - end - end end diff --git a/spec/services/secrets/generator_spec.rb b/spec/services/secrets/generator_spec.rb deleted file mode 100644 index d441d1b52..000000000 --- a/spec/services/secrets/generator_spec.rb +++ /dev/null @@ -1,18 +0,0 @@ -require "rails_helper" - -RSpec.describe Secrets::Generator, type: :service do - describe ".sortable" do - it "generates unique identifiers" do - expect(described_class.sortable).not_to eq(described_class.sortable) - end - - it "generates sortable identifiers" do - now_id = described_class.sortable - a_month_from_now_id = described_class.sortable(1.month.from_now) - a_month_ago_id = described_class.sortable(1.month.ago) - - expected_ids = [a_month_ago_id, now_id, a_month_from_now_id] - expect([now_id, a_month_from_now_id, a_month_ago_id].sort).to eq(expected_ids) - end - end -end diff --git a/spec/services/webhook/dispatch_event_spec.rb b/spec/services/webhook/dispatch_event_spec.rb deleted file mode 100644 index 3f0d32941..000000000 --- a/spec/services/webhook/dispatch_event_spec.rb +++ /dev/null @@ -1,34 +0,0 @@ -require "rails_helper" - -RSpec.describe Webhook::DispatchEvent, type: :service do - let!(:user) { create(:user) } - let!(:article) { create(:article, user: user) } - - it "does nothing if there are no corresponding endpoints" do - create(:webhook_endpoint, events: %w[article_created], user: user) - sidekiq_assert_no_enqueued_jobs(only: Webhook::DispatchEventWorker) do - described_class.call("article_destroyed", article) - end - end - - it "schedules jobs" do - create(:webhook_endpoint, events: %w[article_created], user: user, - target_url: "https://create-webhooks.example.com/accept") - create(:webhook_endpoint, events: %w[article_created article_updated article_destroyed], user: user, - target_url: "https://all-webhooks.example.com/accept") - create(:webhook_endpoint, events: %w[article_destroyed], user: user, - target_url: "https://destroy-webhooks.example.com/accept") - sidekiq_assert_enqueued_jobs(2, only: Webhook::DispatchEventWorker) do - described_class.call("article_created", article) - end - end - - it "doesn't schedule jobs if the endpoints belong to another user" do - user2 = create(:user) - create(:webhook_endpoint, events: %w[article_created], user: user2, - target_url: "https://create-webhooks.example.com/accept") - sidekiq_assert_no_enqueued_jobs(only: Webhook::DispatchEventWorker) do - described_class.call("article_created", article) - end - end -end diff --git a/spec/services/webhook/payload_adapter_spec.rb b/spec/services/webhook/payload_adapter_spec.rb deleted file mode 100644 index 709f8ccad..000000000 --- a/spec/services/webhook/payload_adapter_spec.rb +++ /dev/null @@ -1,35 +0,0 @@ -require "rails_helper" - -RSpec.describe Webhook::PayloadAdapter, type: :service do - it "raises an exception when invalid object is passed" do - expect do - described_class.new(User.new).hash - end.to raise_error(Webhook::InvalidPayloadObject) - end - - describe "#hash" do - let(:user) { create(:user) } - let!(:article) { create(:article, title: "I'm super", user: user) } - - it "returns a hash for a persisted article" do - data = described_class.new(article).hash - expect(data).to be_kind_of(Hash) - expect(data[:data][:attributes][:title]).to eq(article.title) - expect(data[:data][:attributes][:body_markdown]).to be_truthy - end - - it "returns a hash with a user" do - data = described_class.new(article).hash - expect(data[:data][:attributes][:user][:data][:attributes][:username]).to eq(user.username) - end - - it "returns a hash for a destroyed article" do - article = create(:article, title: "hello") - article.destroy - data = described_class.new(article).hash - expect(data).to be_kind_of(Hash) - expect(data[:data][:attributes][:title]).to eq("hello") - expect(data[:data][:attributes][:body_markdown]).to be_falsey - end - end -end diff --git a/spec/workers/webhook/destroy_worker_spec.rb b/spec/workers/webhook/destroy_worker_spec.rb deleted file mode 100644 index 42f1d3027..000000000 --- a/spec/workers/webhook/destroy_worker_spec.rb +++ /dev/null @@ -1,16 +0,0 @@ -require "rails_helper" - -RSpec.describe Webhook::DestroyWorker, type: :worker do - let(:user) { create(:user) } - let!(:webhook_endpoint) { create(:webhook_endpoint, user: user) } - let!(:other_webhook_endpoint) { create(:webhook_endpoint) } - let(:worker) { subject } - - describe "#perform_now" do - xit "destroys webhook by user_id and app_id" do - worker.perform(user.id) - expect(Webhook::Endpoint.find_by(id: webhook_endpoint.id)).to be_nil - expect(other_webhook_endpoint.reload).to be_present - end - end -end diff --git a/spec/workers/webhook/dispatch_event_worker_spec.rb b/spec/workers/webhook/dispatch_event_worker_spec.rb deleted file mode 100644 index d76f8e3d5..000000000 --- a/spec/workers/webhook/dispatch_event_worker_spec.rb +++ /dev/null @@ -1,29 +0,0 @@ -require "rails_helper" - -RSpec.describe Webhook::DispatchEventWorker, type: :worker do - let(:worker) { subject } - - include_examples "#enqueues_on_correct_queue", "medium_priority", ["https://example.com", {}.to_json] - - describe "#perform_now" do - let(:article) { create(:article) } - let(:payload) { Webhook::PayloadAdapter.new(article).hash } - let(:json) { Webhook::Event.new(event_type: "article_updated", payload: payload).to_json } - let(:url) { Faker::Internet.url } - let(:client) { HTTParty } - - it "posts an event" do - allow(client).to receive(:post) - worker.perform(url, json) - expect(client).to have_received(:post).once - .with(Addressable::URI.parse(url), headers: { "Content-Type" => "application/json" }, - body: json, - timeout: 10) - end - - it "doesn't fail" do - stub_request(:post, url).to_return(status: 200) - worker.perform(url, json) - end - end -end diff --git a/vendor/cache/ulid-1.3.0.gem b/vendor/cache/ulid-1.3.0.gem deleted file mode 100644 index 43fd6d62ba0b3a09a1c7202745474c35bd4de079..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 16896 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