500 lines
14 KiB
Ruby
500 lines
14 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
require 'spec_helper'
|
|
|
|
RSpec.describe WebHook do
|
|
include AfterNextHelpers
|
|
|
|
let_it_be(:project) { create(:project) }
|
|
|
|
let(:hook) { build(:project_hook, project: project) }
|
|
|
|
around do |example|
|
|
if example.metadata[:skip_freeze_time]
|
|
example.run
|
|
else
|
|
freeze_time { example.run }
|
|
end
|
|
end
|
|
|
|
describe 'associations' do
|
|
it { is_expected.to have_many(:web_hook_logs) }
|
|
end
|
|
|
|
describe 'validations' do
|
|
it { is_expected.to validate_presence_of(:url) }
|
|
|
|
describe 'url' do
|
|
it { is_expected.to allow_value('http://example.com').for(:url) }
|
|
it { is_expected.to allow_value('https://example.com').for(:url) }
|
|
it { is_expected.to allow_value(' https://example.com ').for(:url) }
|
|
it { is_expected.to allow_value('http://test.com/api').for(:url) }
|
|
it { is_expected.to allow_value('http://test.com/api?key=abc').for(:url) }
|
|
it { is_expected.to allow_value('http://test.com/api?key=abc&type=def').for(:url) }
|
|
|
|
it { is_expected.not_to allow_value('example.com').for(:url) }
|
|
it { is_expected.not_to allow_value('ftp://example.com').for(:url) }
|
|
it { is_expected.not_to allow_value('herp-and-derp').for(:url) }
|
|
|
|
context 'when url is local' do
|
|
let(:url) { 'http://localhost:9000' }
|
|
|
|
it { is_expected.not_to allow_value(url).for(:url) }
|
|
|
|
it 'is valid if application settings allow local requests from web hooks' do
|
|
settings = ApplicationSetting.new(allow_local_requests_from_web_hooks_and_services: true)
|
|
allow(ApplicationSetting).to receive(:current).and_return(settings)
|
|
|
|
is_expected.to allow_value(url).for(:url)
|
|
end
|
|
end
|
|
|
|
it 'strips :url before saving it' do
|
|
hook.url = ' https://example.com '
|
|
hook.save!
|
|
|
|
expect(hook.url).to eq('https://example.com')
|
|
end
|
|
end
|
|
|
|
describe 'token' do
|
|
it { is_expected.to allow_value("foobar").for(:token) }
|
|
|
|
it { is_expected.not_to allow_values("foo\nbar", "foo\r\nbar").for(:token) }
|
|
end
|
|
|
|
describe 'push_events_branch_filter' do
|
|
it { is_expected.to allow_values("good_branch_name", "another/good-branch_name").for(:push_events_branch_filter) }
|
|
it { is_expected.to allow_values("").for(:push_events_branch_filter) }
|
|
it { is_expected.not_to allow_values("bad branch name", "bad~branchname").for(:push_events_branch_filter) }
|
|
|
|
it 'gets rid of whitespace' do
|
|
hook.push_events_branch_filter = ' branch '
|
|
hook.save!
|
|
|
|
expect(hook.push_events_branch_filter).to eq('branch')
|
|
end
|
|
|
|
it 'stores whitespace only as empty' do
|
|
hook.push_events_branch_filter = ' '
|
|
hook.save!
|
|
|
|
expect(hook.push_events_branch_filter).to eq('')
|
|
end
|
|
end
|
|
end
|
|
|
|
describe 'encrypted attributes' do
|
|
subject { described_class.encrypted_attributes.keys }
|
|
|
|
it { is_expected.to contain_exactly(:token, :url) }
|
|
end
|
|
|
|
describe 'execute' do
|
|
let(:data) { { key: 'value' } }
|
|
let(:hook_name) { 'project hook' }
|
|
|
|
it '#execute' do
|
|
expect_next(WebHookService).to receive(:execute)
|
|
|
|
hook.execute(data, hook_name)
|
|
end
|
|
|
|
it 'passes force: false to the web hook service by default' do
|
|
expect(WebHookService)
|
|
.to receive(:new).with(hook, data, hook_name, force: false).and_return(double(execute: :done))
|
|
|
|
expect(hook.execute(data, hook_name)).to eq :done
|
|
end
|
|
|
|
it 'passes force: true to the web hook service if required' do
|
|
expect(WebHookService)
|
|
.to receive(:new).with(hook, data, hook_name, force: true).and_return(double(execute: :forced))
|
|
|
|
expect(hook.execute(data, hook_name, force: true)).to eq :forced
|
|
end
|
|
|
|
it '#async_execute' do
|
|
expect_next(WebHookService).to receive(:async_execute)
|
|
|
|
hook.async_execute(data, hook_name)
|
|
end
|
|
|
|
it 'does not async execute non-executable hooks' do
|
|
hook.update!(disabled_until: 1.day.from_now)
|
|
|
|
expect(WebHookService).not_to receive(:new)
|
|
|
|
hook.async_execute(data, hook_name)
|
|
end
|
|
end
|
|
|
|
describe '#destroy' do
|
|
it 'cascades to web_hook_logs' do
|
|
web_hook = create(:project_hook)
|
|
create_list(:web_hook_log, 3, web_hook: web_hook)
|
|
|
|
expect { web_hook.destroy! }.to change(web_hook.web_hook_logs, :count).by(-3)
|
|
end
|
|
end
|
|
|
|
describe '.executable' do
|
|
let(:not_executable) do
|
|
[
|
|
[0, Time.current],
|
|
[0, 1.minute.from_now],
|
|
[1, 1.minute.from_now],
|
|
[3, 1.minute.from_now],
|
|
[4, nil],
|
|
[4, 1.day.ago],
|
|
[4, 1.minute.from_now]
|
|
].map do |(recent_failures, disabled_until)|
|
|
create(:project_hook, project: project, recent_failures: recent_failures, disabled_until: disabled_until)
|
|
end
|
|
end
|
|
|
|
let(:executables) do
|
|
[
|
|
[0, nil],
|
|
[0, 1.day.ago],
|
|
[1, nil],
|
|
[1, 1.day.ago],
|
|
[3, nil],
|
|
[3, 1.day.ago]
|
|
].map do |(recent_failures, disabled_until)|
|
|
create(:project_hook, project: project, recent_failures: recent_failures, disabled_until: disabled_until)
|
|
end
|
|
end
|
|
|
|
it 'finds the correct set of project hooks' do
|
|
expect(described_class.where(project_id: project.id).executable).to match_array executables
|
|
end
|
|
|
|
context 'when the feature flag is not enabled' do
|
|
before do
|
|
stub_feature_flags(web_hooks_disable_failed: false)
|
|
end
|
|
|
|
it 'is the same as all' do
|
|
expect(described_class.where(project_id: project.id).executable).to match_array(executables + not_executable)
|
|
end
|
|
end
|
|
end
|
|
|
|
describe '#executable?' do
|
|
let(:web_hook) { create(:project_hook, project: project) }
|
|
|
|
where(:recent_failures, :not_until, :executable) do
|
|
[
|
|
[0, :not_set, true],
|
|
[0, :past, true],
|
|
[0, :future, false],
|
|
[0, :now, false],
|
|
[1, :not_set, true],
|
|
[1, :past, true],
|
|
[1, :future, false],
|
|
[3, :not_set, true],
|
|
[3, :past, true],
|
|
[3, :future, false],
|
|
[4, :not_set, false],
|
|
[4, :past, false],
|
|
[4, :future, false]
|
|
]
|
|
end
|
|
|
|
with_them do
|
|
# Phasing means we cannot put these values in the where block,
|
|
# which is not subject to the frozen time context.
|
|
let(:disabled_until) do
|
|
case not_until
|
|
when :not_set
|
|
nil
|
|
when :past
|
|
1.minute.ago
|
|
when :future
|
|
1.minute.from_now
|
|
when :now
|
|
Time.current
|
|
end
|
|
end
|
|
|
|
before do
|
|
web_hook.update!(recent_failures: recent_failures, disabled_until: disabled_until)
|
|
end
|
|
|
|
it 'has the correct state' do
|
|
expect(web_hook.executable?).to eq(executable)
|
|
end
|
|
|
|
context 'when the feature flag is enabled for a project' do
|
|
before do
|
|
stub_feature_flags(web_hooks_disable_failed: project)
|
|
end
|
|
|
|
it 'has the expected value' do
|
|
expect(web_hook.executable?).to eq(executable)
|
|
end
|
|
end
|
|
|
|
context 'when the feature flag is not enabled' do
|
|
before do
|
|
stub_feature_flags(web_hooks_disable_failed: false)
|
|
end
|
|
|
|
it 'is executable' do
|
|
expect(web_hook).to be_executable
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
describe '#next_backoff' do
|
|
context 'when there was no last backoff' do
|
|
before do
|
|
hook.backoff_count = 0
|
|
end
|
|
|
|
it 'is 10 minutes' do
|
|
expect(hook.next_backoff).to eq(described_class::INITIAL_BACKOFF)
|
|
end
|
|
end
|
|
|
|
context 'when we have backed off once' do
|
|
before do
|
|
hook.backoff_count = 1
|
|
end
|
|
|
|
it 'is twice the initial value' do
|
|
expect(hook.next_backoff).to eq(20.minutes)
|
|
end
|
|
end
|
|
|
|
context 'when we have backed off 3 times' do
|
|
before do
|
|
hook.backoff_count = 3
|
|
end
|
|
|
|
it 'grows exponentially' do
|
|
expect(hook.next_backoff).to eq(80.minutes)
|
|
end
|
|
end
|
|
|
|
context 'when the previous backoff was large' do
|
|
before do
|
|
hook.backoff_count = 8 # last value before MAX_BACKOFF
|
|
end
|
|
|
|
it 'does not exceed the max backoff value' do
|
|
expect(hook.next_backoff).to eq(described_class::MAX_BACKOFF)
|
|
end
|
|
end
|
|
end
|
|
|
|
shared_examples 'is tolerant of invalid records' do
|
|
specify do
|
|
hook.url = nil
|
|
|
|
expect(hook).to be_invalid
|
|
run_expectation
|
|
end
|
|
end
|
|
|
|
describe '#enable!' do
|
|
it 'makes a hook executable if it was marked as failed' do
|
|
hook.recent_failures = 1000
|
|
|
|
expect { hook.enable! }.to change(hook, :executable?).from(false).to(true)
|
|
end
|
|
|
|
it 'makes a hook executable if it is currently backed off' do
|
|
hook.disabled_until = 1.hour.from_now
|
|
|
|
expect { hook.enable! }.to change(hook, :executable?).from(false).to(true)
|
|
end
|
|
|
|
it 'does not update hooks unless necessary' do
|
|
sql_count = ActiveRecord::QueryRecorder.new { hook.enable! }.count
|
|
|
|
expect(sql_count).to eq(0)
|
|
end
|
|
|
|
include_examples 'is tolerant of invalid records' do
|
|
def run_expectation
|
|
hook.recent_failures = 1000
|
|
|
|
expect { hook.enable! }.to change(hook, :executable?).from(false).to(true)
|
|
end
|
|
end
|
|
end
|
|
|
|
describe 'backoff!' do
|
|
it 'sets disabled_until to the next backoff' do
|
|
expect { hook.backoff! }.to change(hook, :disabled_until).to(hook.next_backoff.from_now)
|
|
end
|
|
|
|
it 'increments the backoff count' do
|
|
expect { hook.backoff! }.to change(hook, :backoff_count).by(1)
|
|
end
|
|
|
|
context 'when the hook is permanently disabled' do
|
|
before do
|
|
allow(hook).to receive(:permanently_disabled?).and_return(true)
|
|
end
|
|
|
|
it 'does not set disabled_until' do
|
|
expect { hook.backoff! }.not_to change(hook, :disabled_until)
|
|
end
|
|
|
|
it 'does not increment the backoff count' do
|
|
expect { hook.backoff! }.not_to change(hook, :backoff_count)
|
|
end
|
|
end
|
|
|
|
context 'when we have backed off MAX_FAILURES times' do
|
|
before do
|
|
stub_const("#{described_class}::MAX_FAILURES", 5)
|
|
5.times { hook.backoff! }
|
|
end
|
|
|
|
it 'does not let the backoff count exceed the maximum failure count' do
|
|
expect { hook.backoff! }.not_to change(hook, :backoff_count)
|
|
end
|
|
|
|
it 'does not change disabled_until', :skip_freeze_time do
|
|
travel_to(hook.disabled_until - 1.minute) do
|
|
expect { hook.backoff! }.not_to change(hook, :disabled_until)
|
|
end
|
|
end
|
|
|
|
it 'changes disabled_until when it has elapsed', :skip_freeze_time do
|
|
travel_to(hook.disabled_until + 1.minute) do
|
|
expect { hook.backoff! }.to change { hook.disabled_until }
|
|
expect(hook.backoff_count).to eq(described_class::MAX_FAILURES)
|
|
end
|
|
end
|
|
end
|
|
|
|
include_examples 'is tolerant of invalid records' do
|
|
def run_expectation
|
|
expect { hook.backoff! }.to change(hook, :backoff_count).by(1)
|
|
end
|
|
end
|
|
end
|
|
|
|
describe 'failed!' do
|
|
it 'increments the failure count' do
|
|
expect { hook.failed! }.to change(hook, :recent_failures).by(1)
|
|
end
|
|
|
|
it 'does not update the hook if the the failure count exceeds the maximum value' do
|
|
hook.recent_failures = described_class::MAX_FAILURES
|
|
|
|
sql_count = ActiveRecord::QueryRecorder.new { hook.failed! }.count
|
|
|
|
expect(sql_count).to eq(0)
|
|
end
|
|
|
|
include_examples 'is tolerant of invalid records' do
|
|
def run_expectation
|
|
expect { hook.failed! }.to change(hook, :recent_failures).by(1)
|
|
end
|
|
end
|
|
end
|
|
|
|
describe '#disable!' do
|
|
it 'disables a hook' do
|
|
expect { hook.disable! }.to change(hook, :executable?).from(true).to(false)
|
|
end
|
|
|
|
include_examples 'is tolerant of invalid records' do
|
|
def run_expectation
|
|
expect { hook.disable! }.to change(hook, :executable?).from(true).to(false)
|
|
end
|
|
end
|
|
end
|
|
|
|
describe '#temporarily_disabled?' do
|
|
it 'is false when not temporarily disabled' do
|
|
expect(hook).not_to be_temporarily_disabled
|
|
end
|
|
|
|
context 'when hook has been told to back off' do
|
|
before do
|
|
hook.backoff!
|
|
end
|
|
|
|
it 'is true' do
|
|
expect(hook).to be_temporarily_disabled
|
|
end
|
|
|
|
it 'is false when `web_hooks_disable_failed` flag is disabled' do
|
|
stub_feature_flags(web_hooks_disable_failed: false)
|
|
|
|
expect(hook).not_to be_temporarily_disabled
|
|
end
|
|
|
|
it 'can ignore the feature flag' do
|
|
stub_feature_flags(web_hooks_disable_failed: false)
|
|
|
|
expect(hook).to be_temporarily_disabled(ignore_flag: true)
|
|
end
|
|
end
|
|
end
|
|
|
|
describe '#permanently_disabled?' do
|
|
it 'is false when not disabled' do
|
|
expect(hook).not_to be_permanently_disabled
|
|
end
|
|
|
|
context 'when hook has been disabled' do
|
|
before do
|
|
hook.disable!
|
|
end
|
|
|
|
it 'is true' do
|
|
expect(hook).to be_permanently_disabled
|
|
end
|
|
|
|
it 'is false when `web_hooks_disable_failed` flag is disabled' do
|
|
stub_feature_flags(web_hooks_disable_failed: false)
|
|
|
|
expect(hook).not_to be_permanently_disabled
|
|
end
|
|
|
|
it 'can ignore the feature flag' do
|
|
stub_feature_flags(web_hooks_disable_failed: false)
|
|
|
|
expect(hook).to be_permanently_disabled(ignore_flag: true)
|
|
end
|
|
end
|
|
end
|
|
|
|
describe '#rate_limited?' do
|
|
context 'when there are rate limits' do
|
|
before do
|
|
allow(hook).to receive(:rate_limit).and_return(3)
|
|
end
|
|
|
|
it 'is false when hook has not been rate limited' do
|
|
expect(Gitlab::ApplicationRateLimiter).to receive(:peek).and_return(false)
|
|
expect(hook).not_to be_rate_limited
|
|
end
|
|
|
|
it 'is true when hook has been rate limited' do
|
|
expect(Gitlab::ApplicationRateLimiter).to receive(:peek).and_return(true)
|
|
expect(hook).to be_rate_limited
|
|
end
|
|
end
|
|
|
|
context 'when there are no rate limits' do
|
|
before do
|
|
allow(hook).to receive(:rate_limit).and_return(nil)
|
|
end
|
|
|
|
it 'does not call Gitlab::ApplicationRateLimiter, and is false' do
|
|
expect(Gitlab::ApplicationRateLimiter).not_to receive(:peek)
|
|
expect(hook).not_to be_rate_limited
|
|
end
|
|
end
|
|
end
|
|
end
|