discourse/spec/jobs/emit_web_hook_event_spec.rb
Krzysztof Kotlarek c468110929
FEATURE: granular webhooks (#23070)
Before this change, webhooks could be only configured for specific groups like for example, all topic events.

We would like to have more granular control like for example topic_created or topic_destroyed.

Test are failing because plugins changed has to be merged as well:
discourse/discourse-assign#498
discourse/discourse-solved#248
discourse/discourse-topic-voting#159
2023-10-09 03:35:31 +00:00

350 lines
11 KiB
Ruby

# frozen_string_literal: true
require "excon"
RSpec.describe Jobs::EmitWebHookEvent do
subject(:job) { described_class.new }
fab!(:post_hook) { Fabricate(:web_hook) }
fab!(:inactive_hook) { Fabricate(:inactive_web_hook) }
fab!(:post) { Fabricate(:post) }
fab!(:user) { Fabricate(:user) }
it "raises an error when there is no web hook record" do
expect { job.execute(event_type: "post", payload: {}) }.to raise_error(
Discourse::InvalidParameters,
)
end
it "raises an error when there is no event type" do
expect { job.execute(web_hook_id: post_hook.id, payload: {}) }.to raise_error(
Discourse::InvalidParameters,
)
end
it "raises an error when there is no payload" do
expect { job.execute(web_hook_id: post_hook.id, event_type: "post") }.to raise_error(
Discourse::InvalidParameters,
)
end
it "should not destroy webhook event in case of error" do
stub_request(:post, post_hook.payload_url).to_return(status: 500)
job.execute(
web_hook_id: post_hook.id,
payload: { id: post.id }.to_json,
event_type: WebHookEventType::TYPES[:post_created],
)
expect(WebHookEvent.last.web_hook_id).to eq(post_hook.id)
end
context "when the web hook is failed" do
before { SiteSetting.retry_web_hook_events = true }
context "when the webhook has failed for 404 or 410" do
before do
stub_request(:post, post_hook.payload_url).to_return(
body: "Invalid Access",
status: response_status,
)
end
let(:response_status) { 410 }
it "disables the webhook" do
expect do
job.execute(
web_hook_id: post_hook.id,
event_type: described_class::PING_EVENT,
retry_count: described_class::MAX_RETRY_COUNT,
)
end.to change { post_hook.reload.active }.to(false)
end
it "logs webhook deactivation reason" do
job.execute(
web_hook_id: post_hook.id,
event_type: described_class::PING_EVENT,
retry_count: described_class::MAX_RETRY_COUNT,
)
user_history =
UserHistory.find_by(
action: UserHistory.actions[:web_hook_deactivate],
acting_user: Discourse.system_user,
)
expect(user_history).to be_present
expect(user_history.context).to eq(
["webhook_id: #{post_hook.id}", "webhook_response_status: #{response_status}"].to_s,
)
end
end
context "when the webhook has failed" do
before do
stub_request(:post, post_hook.payload_url).to_return(body: "Invalid Access", status: 403)
end
it "retry if site setting is enabled" do
expect do
job.execute(web_hook_id: post_hook.id, event_type: described_class::PING_EVENT)
end.to change { Jobs::EmitWebHookEvent.jobs.size }.by(1)
end
it "retries at most 5 times" do
Jobs.run_immediately!
expect(Jobs::EmitWebHookEvent::MAX_RETRY_COUNT + 1).to eq(5)
expect do
job.execute(web_hook_id: post_hook.id, event_type: described_class::PING_EVENT)
end.to change { WebHookEvent.count }.by(Jobs::EmitWebHookEvent::MAX_RETRY_COUNT + 1)
end
it "does not retry for more than maximum allowed times" do
expect do
job.execute(
web_hook_id: post_hook.id,
event_type: described_class::PING_EVENT,
retry_count: described_class::MAX_RETRY_COUNT,
)
end.to_not change { Jobs::EmitWebHookEvent.jobs.size }
end
it "does not retry if site setting is disabled" do
SiteSetting.retry_web_hook_events = false
expect do
job.execute(web_hook_id: post_hook.id, event_type: described_class::PING_EVENT)
end.not_to change { Jobs::EmitWebHookEvent.jobs.size }
end
it "properly logs error on rescue" do
stub_request(:post, post_hook.payload_url).to_raise("connection error")
job.execute(web_hook_id: post_hook.id, event_type: described_class::PING_EVENT)
event = WebHookEvent.last
expect(event.payload).to eq(MultiJson.dump(ping: "OK"))
expect(event.status).to eq(-1)
expect(MultiJson.load(event.response_headers)["error"]).to eq("connection error")
end
end
end
it "does not raise an error for a ping event without payload" do
stub_request(:post, post_hook.payload_url).to_return(body: "OK", status: 200)
job.execute(web_hook_id: post_hook.id, event_type: described_class::PING_EVENT)
end
it "doesn't emit when the hook is inactive" do
job.execute(
web_hook_id: inactive_hook.id,
event_type: "post",
payload: { test: "some payload" }.to_json,
)
end
it "emits normally with sufficient arguments" do
stub_request(:post, post_hook.payload_url).with(
body: "{\"post\":{\"test\":\"some payload\"}}",
).to_return(body: "OK", status: 200)
job.execute(
web_hook_id: post_hook.id,
event_type: "post",
payload: { test: "some payload" }.to_json,
)
end
it "doesn't emit if the payload URL resolves to a disallowed IP" do
FinalDestination::TestHelper.stub_to_fail do
job.execute(
web_hook_id: post_hook.id,
event_type: "post",
payload: { test: "some payload" }.to_json,
)
end
event = post_hook.web_hook_events.last
expect(event.response_headers).to eq(
{ error: I18n.t("webhooks.payload_url.blocked_or_internal") }.to_json,
)
expect(event.response_body).to eq(nil)
expect(event.status).to eq(-1)
end
context "with category filters" do
fab!(:category) { Fabricate(:category) }
fab!(:topic) { Fabricate(:topic) }
fab!(:topic_with_category) { Fabricate(:topic, category_id: category.id) }
fab!(:topic_hook) { Fabricate(:topic_web_hook, categories: [category]) }
it "doesn't emit when event is not related with defined categories" do
job.execute(
web_hook_id: topic_hook.id,
event_type: "topic",
category_id: topic.category.id,
payload: { test: "some payload" }.to_json,
)
end
it "emit when event is related with defined categories" do
stub_request(:post, post_hook.payload_url).with(
body: "{\"topic\":{\"test\":\"some payload\"}}",
).to_return(body: "OK", status: 200)
job.execute(
web_hook_id: topic_hook.id,
event_type: "topic",
category_id: topic_with_category.category.id,
payload: { test: "some payload" }.to_json,
)
end
end
context "with tag filters" do
fab!(:tag) { Fabricate(:tag) }
fab!(:topic) { Fabricate(:topic, tags: [tag]) }
fab!(:topic_hook) { Fabricate(:topic_web_hook, tags: [tag]) }
it "doesn't emit when event is not included any tags" do
job.execute(
web_hook_id: topic_hook.id,
event_type: "topic",
payload: { test: "some payload" }.to_json,
)
end
it "doesn't emit when event is not related with defined tags" do
job.execute(
web_hook_id: topic_hook.id,
event_type: "topic",
tag_ids: [Fabricate(:tag).id],
payload: { test: "some payload" }.to_json,
)
end
it "emit when event is related with defined tags" do
stub_request(:post, post_hook.payload_url).with(
body: "{\"topic\":{\"test\":\"some payload\"}}",
).to_return(body: "OK", status: 200)
job.execute(
web_hook_id: topic_hook.id,
event_type: "topic",
tag_ids: topic.tags.pluck(:id),
payload: { test: "some payload" }.to_json,
)
end
end
context "with group filters" do
fab!(:group) { Fabricate(:group) }
fab!(:user) { Fabricate(:user, groups: [group]) }
fab!(:like_hook) { Fabricate(:like_web_hook, groups: [group]) }
it "doesn't emit when event is not included any groups" do
job.execute(
web_hook_id: like_hook.id,
event_type: "like",
payload: { test: "some payload" }.to_json,
)
end
it "doesn't emit when event is not related with defined groups" do
job.execute(
web_hook_id: like_hook.id,
event_type: "like",
group_ids: [Fabricate(:group).id],
payload: { test: "some payload" }.to_json,
)
end
it "emit when event is related with defined groups" do
stub_request(:post, like_hook.payload_url).with(
body: "{\"like\":{\"test\":\"some payload\"}}",
).to_return(body: "OK", status: 200)
job.execute(
web_hook_id: like_hook.id,
event_type: "like",
group_ids: user.groups.pluck(:id),
payload: { test: "some payload" }.to_json,
)
end
end
describe "#send_webhook!" do
it "creates delivery event record" do
stub_request(:post, post_hook.payload_url).to_return(body: "OK", status: 200)
topic_event_type = WebHookEventType.all.first
web_hook_id = Fabricate("#{topic_event_type.name.gsub("_created", "")}_web_hook").id
expect do
job.execute(
web_hook_id: web_hook_id,
event_type: topic_event_type.name,
payload: { test: "some payload" }.to_json,
)
end.to change(WebHookEvent, :count).by(1)
end
it "sets up proper request headers" do
stub_request(:post, post_hook.payload_url).to_return(
headers: {
test: "string",
},
body: "OK",
status: 200,
)
job.execute(
web_hook_id: post_hook.id,
event_type: described_class::PING_EVENT,
event_name: described_class::PING_EVENT,
payload: { test: "this payload shouldn't appear" }.to_json,
)
event = WebHookEvent.last
headers = MultiJson.load(event.headers)
expect(headers["Content-Length"]).to eq("13")
expect(headers["Host"]).to eq("meta.discourse.org")
expect(headers["X-Discourse-Event-Id"]).to eq(event.id.to_s)
expect(headers["X-Discourse-Event-Type"]).to eq(described_class::PING_EVENT)
expect(headers["X-Discourse-Event"]).to eq(described_class::PING_EVENT)
expect(headers["X-Discourse-Event-Signature"]).to eq(
"sha256=162f107f6b5022353274eb1a7197885cfd35744d8d08e5bcea025d309386b7d6",
)
expect(event.payload).to eq(MultiJson.dump(ping: "OK"))
expect(event.status).to eq(200)
expect(MultiJson.load(event.response_headers)["test"]).to eq("string")
expect(event.response_body).to eq("OK")
end
it "sets up proper request headers when an error raised" do
stub_request(:post, post_hook.payload_url).to_raise("error")
job.execute(
web_hook_id: post_hook.id,
event_type: described_class::PING_EVENT,
event_name: described_class::PING_EVENT,
payload: { test: "this payload shouldn't appear" }.to_json,
)
event = WebHookEvent.last
headers = MultiJson.load(event.headers)
expect(headers["Content-Length"]).to eq("13")
expect(headers["Host"]).to eq("meta.discourse.org")
expect(headers["X-Discourse-Event-Id"]).to eq(event.id.to_s)
expect(headers["X-Discourse-Event-Type"]).to eq(described_class::PING_EVENT)
expect(headers["X-Discourse-Event"]).to eq(described_class::PING_EVENT)
expect(headers["X-Discourse-Event-Signature"]).to eq(
"sha256=162f107f6b5022353274eb1a7197885cfd35744d8d08e5bcea025d309386b7d6",
)
expect(event.payload).to eq(MultiJson.dump(ping: "OK"))
end
end
end