# frozen_string_literal: true RSpec.describe WebHook do it { is_expected.to validate_presence_of :payload_url } it { is_expected.to validate_presence_of :content_type } it { is_expected.to validate_presence_of :last_delivery_status } it { is_expected.to validate_presence_of :web_hook_event_types } describe '#content_types' do subject { WebHook.content_types } it "'json' (application/json) should be at 1st position" do expect(subject['application/json']).to eq(1) end it "'url_encoded' (application/x-www-form-urlencoded) should be at 2st position" do expect(subject['application/x-www-form-urlencoded']).to eq(2) end end describe '#last_delivery_statuses' do subject { WebHook.last_delivery_statuses } it "inactive should be at 1st position" do expect(subject[:inactive]).to eq(1) end it "failed should be at 2st position" do expect(subject[:failed]).to eq(2) end it "successful should be at 3st position" do expect(subject[:successful]).to eq(3) end end context 'with web hooks' do fab!(:post_hook) { Fabricate(:web_hook, payload_url: " https://example.com ") } fab!(:topic_hook) { Fabricate(:topic_web_hook) } it "removes whitespace from payload_url before saving" do expect(post_hook.payload_url).to eq("https://example.com") end it "excludes disabled plugin web_hooks" do web_hook_event_types = WebHookEventType.active.find_by(name: 'solved') expect(web_hook_event_types).to eq(nil) end it "includes non-plugin web_hooks" do web_hook_event_types = WebHookEventType.active.where(name: 'topic') expect(web_hook_event_types.count).to eq(1) end it "includes enabled plugin web_hooks" do SiteSetting.stubs(:solved_enabled).returns(true) web_hook_event_types = WebHookEventType.active.where(name: 'solved') expect(web_hook_event_types.count).to eq(1) end describe '#active_web_hooks' do it "returns unique hooks" do post_hook.web_hook_event_types << WebHookEventType.find_by(name: 'topic') post_hook.update!(wildcard_web_hook: true) expect(WebHook.active_web_hooks(:post)).to eq([post_hook]) end it 'find relevant hooks' do expect(WebHook.active_web_hooks(:post)).to eq([post_hook]) expect(WebHook.active_web_hooks(:topic)).to eq([topic_hook]) end it 'excludes inactive hooks' do post_hook.update!(active: false) expect(WebHook.active_web_hooks(:post)).to eq([]) expect(WebHook.active_web_hooks(:topic)).to eq([topic_hook]) end describe 'wildcard web hooks' do fab!(:wildcard_hook) { Fabricate(:wildcard_web_hook) } it 'should include wildcard hooks' do expect(WebHook.active_web_hooks(:wildcard)).to eq([wildcard_hook]) expect(WebHook.active_web_hooks(:post)).to contain_exactly( post_hook, wildcard_hook ) expect(WebHook.active_web_hooks(:topic)).to contain_exactly( topic_hook, wildcard_hook ) end end end describe '#enqueue_hooks' do it 'accepts additional parameters' do payload = { test: 'some payload' }.to_json WebHook.enqueue_hooks(:post, :post_created, payload: payload) job_args = Jobs::EmitWebHookEvent.jobs.first["args"].first expect(job_args["web_hook_id"]).to eq(post_hook.id) expect(job_args["event_type"]).to eq('post') expect(job_args["payload"]).to eq(payload) end context 'when including wildcard hooks' do fab!(:wildcard_hook) { Fabricate(:wildcard_web_hook) } describe '#enqueue_hooks' do it 'enqueues hooks with ids' do WebHook.enqueue_hooks(:post, :post_created) job_args = Jobs::EmitWebHookEvent.jobs.first["args"].first expect(job_args["web_hook_id"]).to eq(post_hook.id) expect(job_args["event_type"]).to eq('post') job_args = Jobs::EmitWebHookEvent.jobs.last["args"].first expect(job_args["web_hook_id"]).to eq(wildcard_hook.id) expect(job_args["event_type"]).to eq('post') end end end end end describe 'enqueues hooks' do let(:user) { Fabricate(:user) } let(:admin) { Fabricate(:admin) } let(:topic) { Fabricate(:topic, user: user) } let(:post) { Fabricate(:post, topic: topic, user: user) } let(:topic_web_hook) { Fabricate(:topic_web_hook) } let(:tag) { Fabricate(:tag) } before do topic_web_hook end describe 'when there are no active hooks' do it 'should not generate payload and enqueue anything for topic events' do topic_web_hook.destroy! post = PostCreator.create(user, raw: 'post', title: 'topic', skip_validations: true) expect(Jobs::EmitWebHookEvent.jobs.length).to eq(0) WebHook.expects(:generate_payload).times(0) PostDestroyer.new(admin, post).destroy expect(Jobs::EmitWebHookEvent.jobs.length).to eq(0) end it 'should not enqueue anything for tag events' do tag = Fabricate(:tag) tag.destroy! expect(Jobs::EmitWebHookEvent.jobs.length).to eq(0) end end it 'should enqueue the right hooks for topic events' do post = PostCreator.create(user, raw: 'post', title: 'topic', skip_validations: true) topic_id = post.topic.id job_args = Jobs::EmitWebHookEvent.jobs.last["args"].first expect(job_args["event_name"]).to eq("topic_created") payload = JSON.parse(job_args["payload"]) expect(payload["id"]).to eq(topic_id) PostDestroyer.new(user, post).destroy job_args = Jobs::EmitWebHookEvent.jobs.last["args"].first expect(job_args["event_name"]).to eq("topic_destroyed") payload = JSON.parse(job_args["payload"]) expect(payload["id"]).to eq(topic_id) PostDestroyer.new(user, post).recover job_args = Jobs::EmitWebHookEvent.jobs.last["args"].first expect(job_args["event_name"]).to eq("topic_recovered") payload = JSON.parse(job_args["payload"]) expect(payload["id"]).to eq(topic_id) %w{archived closed visible}.each do |status| post.topic.update_status(status, true, topic.user) job_args = Jobs::EmitWebHookEvent.jobs.last["args"].first expect(job_args["event_name"]).to eq("topic_#{status}_status_updated") payload = JSON.parse(job_args["payload"]) expect(payload["id"]).to eq(topic_id) end category = Fabricate(:category) expect do PostRevisor.new(post, post.topic).revise!( post.user, { category_id: category.id }, { skip_validations: true }, ) end.to change { Jobs::EmitWebHookEvent.jobs.length }.by(1) job_args = Jobs::EmitWebHookEvent.jobs.last["args"].first expect(job_args["event_name"]).to eq("topic_edited") payload = JSON.parse(job_args["payload"]) expect(payload["id"]).to eq(topic_id) expect(payload["category_id"]).to eq(category.id) expect do successfully_saved_post_and_topic = PostRevisor.new(post, post.topic).revise!( post.user, { tags: [tag.name] }, { skip_validations: true }, ) end.to change { Jobs::EmitWebHookEvent.jobs.length }.by(1) job_args = Jobs::EmitWebHookEvent.jobs.last["args"].first expect(job_args["event_name"]).to eq("topic_edited") payload = JSON.parse(job_args["payload"]) expect(payload["id"]).to eq(topic_id) expect(payload["tags"]).to contain_exactly(tag.name) end describe 'when topic has been deleted' do it 'should not enqueue a post/topic edited hooks' do topic.trash! post.reload PostRevisor.new(post, topic).revise!( post.user, { category_id: Category.last.id, raw: "#{post.raw} new" }, {} ) expect(Jobs::EmitWebHookEvent.jobs.count).to eq(0) end end it 'should enqueue the right hooks for post events' do Fabricate(:web_hook) post = PostCreator.create!(user, raw: 'post', topic_id: topic.id, reply_to_post_number: 1, skip_validations: true ) job_args = Jobs::EmitWebHookEvent.jobs.last["args"].first expect(job_args["event_name"]).to eq("post_created") payload = JSON.parse(job_args["payload"]) expect(payload["id"]).to eq(post.id) Jobs::EmitWebHookEvent.jobs.clear # post destroy or recover triggers a moderator post expect { PostDestroyer.new(user, post).destroy } .to change { Jobs::EmitWebHookEvent.jobs.count }.by(3) job_args = Jobs::EmitWebHookEvent.jobs[0]["args"].first expect(job_args["event_name"]).to eq("post_edited") payload = JSON.parse(job_args["payload"]) expect(payload["id"]).to eq(post.id) job_args = Jobs::EmitWebHookEvent.jobs[1]["args"].first expect(job_args["event_name"]).to eq("post_destroyed") payload = JSON.parse(job_args["payload"]) expect(payload["id"]).to eq(post.id) job_args = Jobs::EmitWebHookEvent.jobs[2]["args"].first expect(job_args["event_name"]).to eq("topic_destroyed") payload = JSON.parse(job_args["payload"]) expect(payload["id"]).to eq(post.topic.id) Jobs::EmitWebHookEvent.jobs.clear expect { PostDestroyer.new(user, post).recover } .to change { Jobs::EmitWebHookEvent.jobs.count }.by(3) job_args = Jobs::EmitWebHookEvent.jobs[0]["args"].first expect(job_args["event_name"]).to eq("post_edited") payload = JSON.parse(job_args["payload"]) expect(payload["id"]).to eq(post.id) job_args = Jobs::EmitWebHookEvent.jobs[1]["args"].first expect(job_args["event_name"]).to eq("post_recovered") payload = JSON.parse(job_args["payload"]) expect(payload["id"]).to eq(post.id) job_args = Jobs::EmitWebHookEvent.jobs[2]["args"].first expect(job_args["event_name"]).to eq("topic_recovered") payload = JSON.parse(job_args["payload"]) expect(payload["id"]).to eq(post.topic.id) end it 'should enqueue the destroyed hooks with tag filter for post events' do tag = Fabricate(:tag) Fabricate(:web_hook, tags: [tag]) post = PostCreator.create!(user, raw: 'post', topic_id: topic.id, reply_to_post_number: 1, skip_validations: true ) topic.tags = [tag] topic.save! Jobs::EmitWebHookEvent.jobs.clear PostDestroyer.new(user, post).destroy job = Jobs::EmitWebHookEvent.new job.expects(:send_webhook!).times(2) args = Jobs::EmitWebHookEvent.jobs[1]["args"].first job.execute(args.with_indifferent_access) args = Jobs::EmitWebHookEvent.jobs[2]["args"].first job.execute(args.with_indifferent_access) end it 'should enqueue the right hooks for user events' do SiteSetting.must_approve_users = true Fabricate(:user_web_hook, active: true) user Jobs::CreateUserReviewable.new.execute(user_id: user.id) job_args = Jobs::EmitWebHookEvent.jobs.last["args"].first expect(job_args["event_name"]).to eq("user_created") payload = JSON.parse(job_args["payload"]) expect(payload["id"]).to eq(user.id) email_token = Fabricate(:email_token, user: user) EmailToken.confirm(email_token.token) job_args = Jobs::EmitWebHookEvent.jobs.last["args"].first expect(job_args["event_name"]).to eq("user_confirmed_email") payload = JSON.parse(job_args["payload"]) expect(payload["id"]).to eq(user.id) admin job_args = Jobs::EmitWebHookEvent.jobs.last["args"].first expect(job_args["event_name"]).to eq("user_created") payload = JSON.parse(job_args["payload"]) expect(payload["id"]).to eq(admin.id) ReviewableUser.find_by(target: user).perform(admin, :approve_user) job_args = Jobs::EmitWebHookEvent.jobs.last["args"].first expect(job_args["event_name"]).to eq("user_approved") payload = JSON.parse(job_args["payload"]) expect(payload["id"]).to eq(user.id) UserUpdater.new(admin, user).update(username: 'testing123') job_args = Jobs::EmitWebHookEvent.jobs.last["args"].first expect(job_args["event_name"]).to eq("user_updated") payload = JSON.parse(job_args["payload"]) expect(payload["id"]).to eq(user.id) user.logged_out job_args = Jobs::EmitWebHookEvent.jobs.last["args"].first expect(job_args["event_name"]).to eq("user_logged_out") payload = JSON.parse(job_args["payload"]) expect(payload["id"]).to eq(user.id) user.logged_in job_args = Jobs::EmitWebHookEvent.jobs.last["args"].first expect(job_args["event_name"]).to eq("user_logged_in") payload = JSON.parse(job_args["payload"]) expect(payload["id"]).to eq(user.id) email = user.email user.reload UserDestroyer.new(Discourse.system_user).destroy(user) job_args = Jobs::EmitWebHookEvent.jobs.last["args"].first expect(job_args["event_name"]).to eq("user_destroyed") payload = JSON.parse(job_args["payload"]) expect(payload["id"]).to eq(user.id) expect(payload["email"]).to eq(email) # Reflects runtime change to user field user_field = Fabricate(:user_field, show_on_profile: true) user.logged_in job_args = Jobs::EmitWebHookEvent.jobs.last["args"].first expect(job_args["event_name"]).to eq("user_logged_in") payload = JSON.parse(job_args["payload"]) expect(payload["user_fields"].size).to eq(1) end it 'should enqueue the right hooks for category events' do Fabricate(:category_web_hook) category = Fabricate(:category) job_args = Jobs::EmitWebHookEvent.jobs.last["args"].first expect(job_args["event_name"]).to eq("category_created") payload = JSON.parse(job_args["payload"]) expect(payload["id"]).to eq(category.id) category.update!(slug: 'testing') job_args = Jobs::EmitWebHookEvent.jobs.last["args"].first expect(job_args["event_name"]).to eq("category_updated") payload = JSON.parse(job_args["payload"]) expect(payload["id"]).to eq(category.id) expect(payload["slug"]).to eq('testing') category.destroy! job_args = Jobs::EmitWebHookEvent.jobs.last["args"].first expect(job_args["event_name"]).to eq("category_destroyed") payload = JSON.parse(job_args["payload"]) expect(payload["id"]).to eq(category.id) end it 'should enqueue the right hooks for group events' do Fabricate(:group_web_hook) group = Fabricate(:group) job_args = Jobs::EmitWebHookEvent.jobs.last["args"].first expect(job_args["event_name"]).to eq("group_created") payload = JSON.parse(job_args["payload"]) expect(payload["id"]).to eq(group.id) group.update!(full_name: 'testing') job_args = Jobs::EmitWebHookEvent.jobs.last["args"].first expect(job_args["event_name"]).to eq("group_updated") payload = JSON.parse(job_args["payload"]) expect(payload["id"]).to eq(group.id) expect(payload["full_name"]).to eq('testing') group.destroy! job_args = Jobs::EmitWebHookEvent.jobs.last["args"].first expect(job_args["event_name"]).to eq("group_destroyed") payload = JSON.parse(job_args["payload"]) expect(payload["full_name"]).to eq('testing') end it 'should enqueue the right hooks for tag events' do Fabricate(:tag_web_hook) tag = Fabricate(:tag) job_args = Jobs::EmitWebHookEvent.jobs.last["args"].first expect(job_args["event_name"]).to eq("tag_created") payload = JSON.parse(job_args["payload"]) expect(payload["id"]).to eq(tag.id) tag.update!(name: 'testing') job_args = Jobs::EmitWebHookEvent.jobs.last["args"].first expect(job_args["event_name"]).to eq("tag_updated") payload = JSON.parse(job_args["payload"]) expect(payload["id"]).to eq(tag.id) expect(payload["name"]).to eq('testing') tag.destroy! job_args = Jobs::EmitWebHookEvent.jobs.last["args"].first expect(job_args["event_name"]).to eq("tag_destroyed") payload = JSON.parse(job_args["payload"]) expect(payload["id"]).to eq(tag.id) end it 'should enqueue the right hooks for notifications' do Fabricate(:notification_web_hook) notification = Fabricate(:notification) job_args = Jobs::EmitWebHookEvent.jobs.last["args"].first expect(job_args["event_name"]).to eq("notification_created") payload = JSON.parse(job_args["payload"]) expect(payload["id"]).to eq(notification.id) end it 'should enqueue the right hooks for reviewables' do Fabricate(:reviewable_web_hook) reviewable = Fabricate(:reviewable) job_args = Jobs::EmitWebHookEvent.jobs.last["args"].first expect(job_args["event_name"]).to eq("reviewable_created") payload = JSON.parse(job_args["payload"]) expect(payload["id"]).to eq(reviewable.id) reviewable.add_score( Discourse.system_user, ReviewableScore.types[:off_topic], reason: "test" ) job_args = Jobs::EmitWebHookEvent.jobs.last["args"].first expect(job_args["event_name"]).to eq("reviewable_score_updated") payload = JSON.parse(job_args["payload"]) expect(payload["id"]).to eq(reviewable.id) reviewable.perform(Discourse.system_user, :delete_user) job_args = Jobs::EmitWebHookEvent.jobs.last["args"].first expect(job_args["event_name"]).to eq("reviewable_transitioned_to") payload = JSON.parse(job_args["payload"]) expect(payload["id"]).to eq(reviewable.id) end it 'should enqueue the right hooks for badge grants' do Fabricate(:user_badge_web_hook) badge = Fabricate(:badge) badge.multiple_grant = true badge.show_posts = true badge.save now = Time.now freeze_time now BadgeGranter.grant(badge, user, granted_by: admin, post_id: post.id) job_args = Jobs::EmitWebHookEvent.jobs.last["args"].first expect(job_args["event_name"]).to eq("user_badge_granted") payload = JSON.parse(job_args["payload"]) expect(payload["badge_id"]).to eq(badge.id) expect(payload["user_id"]).to eq(user.id) expect(payload["granted_by_id"]).to eq(admin.id) # be_within required because rounding occurs expect(Time.zone.parse(payload["granted_at"]).to_f).to be_within(0.001).of(now.to_f) expect(payload["post_id"]).to eq(post.id) # Future work: revoke badge hook end it 'should enqueue the right hooks for group user addition' do Fabricate(:group_user_web_hook) group = Fabricate(:group) now = Time.now freeze_time now group.add(user) job_args = Jobs::EmitWebHookEvent.jobs.last["args"].first expect(job_args["event_name"]).to eq("user_added_to_group") payload = JSON.parse(job_args["payload"]) expect(payload["group_id"]).to eq(group.id) expect(payload["user_id"]).to eq(user.id) expect(payload["notification_level"]).to eq(group.default_notification_level) expect(Time.zone.parse(payload["created_at"]).to_f).to be_within(0.001).of(now.to_f) end it 'should enqueue the right hooks for group user deletion' do Fabricate(:group_user_web_hook) group = Fabricate(:group) group_user = Fabricate(:group_user, group: group, user: user) now = Time.now freeze_time now group.remove(user) job_args = Jobs::EmitWebHookEvent.jobs.last["args"].first expect(job_args["event_name"]).to eq("user_removed_from_group") payload = JSON.parse(job_args["payload"]) expect(payload["group_id"]).to eq(group.id) expect(payload["user_id"]).to eq(user.id) end context 'with user promoted hooks' do fab!(:user_promoted_web_hook) { Fabricate(:user_promoted_web_hook) } fab!(:another_user) { Fabricate(:user, trust_level: 2) } it 'should pass the user to the webhook job when a user is promoted' do another_user.change_trust_level!(another_user.trust_level + 1) job_args = Jobs::EmitWebHookEvent.jobs.last["args"].first expect(job_args["event_name"]).to eq("user_promoted") payload = JSON.parse(job_args["payload"]) expect(payload["id"]).to eq(another_user.id) end it 'shouldn’t trigger when the user is demoted' do expect { another_user.change_trust_level!(another_user.trust_level - 1) }.not_to change { Jobs::EmitWebHookEvent.jobs.length } end end context 'with like created hooks' do fab!(:like_web_hook) { Fabricate(:like_web_hook) } fab!(:another_user) { Fabricate(:user) } it 'should pass the group id to the emit webhook job' do group = Fabricate(:group) group_user = Fabricate(:group_user, group: group, user: user) post = Fabricate(:post, user: another_user) like = Fabricate(:post_action, post: post, user: user, post_action_type_id: PostActionType.types[:like]) now = Time.now freeze_time now DiscourseEvent.trigger(:like_created, like) assert_hook_was_queued_with(post, user, group_ids: [group.id]) end it 'should pass the category id to the emit webhook job' do category = Fabricate(:category) topic.update!(category: category) like = Fabricate(:post_action, post: post, user: another_user, post_action_type_id: PostActionType.types[:like]) DiscourseEvent.trigger(:like_created, like) assert_hook_was_queued_with(post, another_user, category_id: category.id) end it 'should pass the tag id to the emit webhook job' do tag = Fabricate(:tag) topic.update!(tags: [tag]) like = Fabricate(:post_action, post: post, user: another_user, post_action_type_id: PostActionType.types[:like]) DiscourseEvent.trigger(:like_created, like) assert_hook_was_queued_with(post, another_user, tag_ids: [tag.id]) end def assert_hook_was_queued_with(post, user, group_ids: nil, category_id: nil, tag_ids: nil) job_args = Jobs::EmitWebHookEvent.jobs.last["args"].first expect(job_args["event_name"]).to eq("post_liked") payload = JSON.parse(job_args["payload"]) expect(payload["post"]["id"]).to eq(post.id) expect(payload["user"]["id"]).to eq(user.id) expect(job_args["category_id"]).to eq(category_id) if category_id expect(job_args["group_ids"]).to contain_exactly(*group_ids) if group_ids expect(job_args["tag_ids"]).to contain_exactly(*tag_ids) if tag_ids end end end describe '#payload_url_safety' do fab!(:post_hook) { Fabricate(:web_hook, payload_url: "https://example.com") } it 'errors if payload_url resolves to a blocked IP' do SiteSetting.blocked_ip_blocks = "92.110.0.0/16" FinalDestination::SSRFDetector.stubs(:lookup_ips).with { |h| h == "badhostname.com" }.returns(["92.110.44.17"]) post_hook.payload_url = "https://badhostname.com" post_hook.save expect(post_hook.errors.full_messages).to contain_exactly( I18n.t("webhooks.payload_url.blocked_or_internal") ) end it 'errors if payload_url resolves to an internal IP' do FinalDestination::SSRFDetector.stubs(:lookup_ips).with { |h| h == "badhostname.com" }.returns(["172.18.11.39"]) post_hook.payload_url = "https://badhostname.com" post_hook.save expect(post_hook.errors.full_messages).to contain_exactly( I18n.t("webhooks.payload_url.blocked_or_internal") ) end it "doesn't error if payload_url resolves to an allowed IP" do FinalDestination::SSRFDetector.stubs(:lookup_ips).with { |h| h == "goodhostname.com" }.returns(["172.32.11.39"]) post_hook.payload_url = "https://goodhostname.com" post_hook.save! end end end