diff options
Diffstat (limited to 'spec/models')
251 files changed, 9483 insertions, 5455 deletions
diff --git a/spec/models/abuse/trust_score_spec.rb b/spec/models/abuse/trust_score_spec.rb new file mode 100644 index 00000000000..755309ac699 --- /dev/null +++ b/spec/models/abuse/trust_score_spec.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Abuse::TrustScore, feature_category: :instance_resiliency do + let_it_be(:user) { create(:user) } + + let(:correlation_id) { nil } + + let(:abuse_trust_score) do + create(:abuse_trust_score, user: user, correlation_id_value: correlation_id) + end + + describe 'associations' do + it { is_expected.to belong_to(:user) } + end + + describe 'validations' do + it { is_expected.to validate_presence_of(:user) } + it { is_expected.to validate_presence_of(:score) } + it { is_expected.to validate_presence_of(:source) } + end + + describe 'create' do + subject { abuse_trust_score } + + before do + allow(Labkit::Correlation::CorrelationId).to receive(:current_id).and_return('123abc') + stub_const('Abuse::TrustScore::MAX_EVENTS', 2) + end + + context 'if correlation ID is nil' do + it 'adds the correlation id' do + expect(subject.correlation_id_value).to eq('123abc') + end + end + + context 'if correlation ID is set' do + let(:correlation_id) { 'already-set' } + + it 'does not change the correlation id' do + expect(subject.correlation_id_value).to eq('already-set') + end + end + + context 'if max events is exceeded' do + it 'removes the oldest events' do + first = create(:abuse_trust_score, user: user) + create(:abuse_trust_score, user: user) + create(:abuse_trust_score, user: user) + + expect(user.abuse_trust_scores.count).to eq(2) + expect(described_class.find_by_id(first.id)).to eq(nil) + end + end + end +end diff --git a/spec/models/abuse_report_spec.rb b/spec/models/abuse_report_spec.rb index 7995cc36383..edfac39728f 100644 --- a/spec/models/abuse_report_spec.rb +++ b/spec/models/abuse_report_spec.rb @@ -3,6 +3,8 @@ require 'spec_helper' RSpec.describe AbuseReport, feature_category: :insider_threat do + include Gitlab::Routing.url_helpers + let_it_be(:report, reload: true) { create(:abuse_report) } let_it_be(:user, reload: true) { create(:admin) } @@ -13,6 +15,7 @@ RSpec.describe AbuseReport, feature_category: :insider_threat do describe 'associations' do it { is_expected.to belong_to(:reporter).class_name('User') } it { is_expected.to belong_to(:user) } + it { is_expected.to have_many(:events).class_name('ResourceEvents::AbuseReportEvent').inverse_of(:abuse_report) } it "aliases reporter to author" do expect(subject.author).to be(subject.reporter) @@ -68,6 +71,53 @@ RSpec.describe AbuseReport, feature_category: :insider_threat do "https://gitlab.com/#{SecureRandom.alphanumeric(494)}" ]).for(:links_to_spam) } + + context 'for screenshot' do + let(:txt_file) { fixture_file_upload('spec/fixtures/doc_sample.txt', 'text/plain') } + let(:img_file) { fixture_file_upload('spec/fixtures/rails_sample.jpg', 'image/jpg') } + + it { is_expected.not_to allow_value(txt_file).for(:screenshot) } + it { is_expected.to allow_value(img_file).for(:screenshot) } + + it { is_expected.to allow_value(nil).for(:screenshot) } + it { is_expected.to allow_value('').for(:screenshot) } + end + end + + describe 'scopes' do + let_it_be(:reporter) { create(:user) } + let_it_be(:report1) { create(:abuse_report, reporter: reporter) } + let_it_be(:report2) { create(:abuse_report, :closed, category: 'phishing') } + + describe '.by_reporter_id' do + subject(:results) { described_class.by_reporter_id(reporter.id) } + + it 'returns reports with reporter_id equal to the given user id' do + expect(subject).to match_array([report1]) + end + end + + describe '.open' do + subject(:results) { described_class.open } + + it 'returns reports without resolved_at value' do + expect(subject).to match_array([report, report1]) + end + end + + describe '.closed' do + subject(:results) { described_class.closed } + + it 'returns reports with resolved_at value' do + expect(subject).to match_array([report2]) + end + end + + describe '.by_category' do + it 'returns abuse reports with the specified category' do + expect(described_class.by_category('phishing')).to match_array([report2]) + end + end end describe 'before_validation' do @@ -109,6 +159,168 @@ RSpec.describe AbuseReport, feature_category: :insider_threat do end end + describe '#screenshot_path' do + let(:report) { create(:abuse_report, :with_screenshot) } + + context 'with asset host configured' do + let(:asset_host) { 'https://gitlab-assets.example.com' } + + before do + allow(ActionController::Base).to receive(:asset_host) { asset_host } + end + + it 'returns a full URL with the asset host and system path' do + expect(report.screenshot_path).to eq("#{asset_host}#{report.screenshot.url}") + end + end + + context 'when no asset path configured' do + let(:base_url) { Gitlab.config.gitlab.base_url } + + it 'returns a full URL with the base url and system path' do + expect(report.screenshot_path).to eq("#{base_url}#{report.screenshot.url}") + end + end + end + + describe '#report_type' do + let(:report) { build_stubbed(:abuse_report, reported_from_url: url) } + let_it_be(:issue) { create(:issue) } + let_it_be(:merge_request) { create(:merge_request) } + let_it_be(:user) { create(:user) } + + subject { report.report_type } + + context 'when reported from an issue' do + let(:url) { project_issue_url(issue.project, issue) } + + it { is_expected.to eq :issue } + end + + context 'when reported from a merge request' do + let(:url) { project_merge_request_url(merge_request.project, merge_request) } + + it { is_expected.to eq :merge_request } + end + + context 'when reported from a profile' do + let(:url) { user_url(user) } + + it { is_expected.to eq :profile } + end + + describe 'comment type' do + context 'when reported from an issue comment' do + let(:url) { project_issue_url(issue.project, issue, anchor: 'note_123') } + + it { is_expected.to eq :comment } + end + + context 'when reported from a merge request comment' do + let(:url) { project_merge_request_url(merge_request.project, merge_request, anchor: 'note_123') } + + it { is_expected.to eq :comment } + end + + context 'when anchor exists not from an issue or merge request URL' do + let(:url) { user_url(user, anchor: 'note_123') } + + it { is_expected.to eq :profile } + end + + context 'when note id is invalid' do + let(:url) { project_merge_request_url(merge_request.project, merge_request, anchor: 'note_12x') } + + it { is_expected.to eq :merge_request } + end + end + + context 'when URL cannot be matched' do + let(:url) { '/xxx' } + + it { is_expected.to be_nil } + end + end + + describe '#reported_content' do + let(:report) { build_stubbed(:abuse_report, reported_from_url: url) } + let_it_be(:issue) { create(:issue, description: 'issue description') } + let_it_be(:merge_request) { create(:merge_request, description: 'mr description') } + let_it_be(:user) { create(:user) } + + subject { report.reported_content } + + context 'when reported from an issue' do + let(:url) { project_issue_url(issue.project, issue) } + + it { is_expected.to eq issue.description_html } + end + + context 'when reported from a merge request' do + let(:url) { project_merge_request_url(merge_request.project, merge_request) } + + it { is_expected.to eq merge_request.description_html } + end + + context 'when reported from a merge request with an invalid note ID' do + let(:url) do + "#{project_merge_request_url(merge_request.project, merge_request)}#note_[]" + end + + it { is_expected.to eq merge_request.description_html } + end + + context 'when reported from a profile' do + let(:url) { user_url(user) } + + it { is_expected.to be_nil } + end + + context 'when reported from an unknown URL' do + let(:url) { '/xxx' } + + it { is_expected.to be_nil } + end + + context 'when reported from an invalid URL' do + let(:url) { 'http://example.com/[]' } + + it { is_expected.to be_nil } + end + + context 'when reported from an issue comment' do + let(:note) { create(:note, noteable: issue, project: issue.project, note: 'comment in issue') } + let(:url) { project_issue_url(issue.project, issue, anchor: "note_#{note.id}") } + + it { is_expected.to eq note.note_html } + end + + context 'when reported from a merge request comment' do + let(:note) { create(:note, noteable: merge_request, project: merge_request.project, note: 'comment in mr') } + let(:url) { project_merge_request_url(merge_request.project, merge_request, anchor: "note_#{note.id}") } + + it { is_expected.to eq note.note_html } + end + + context 'when report type cannot be determined, because the comment does not exist' do + let(:url) do + project_merge_request_url(merge_request.project, merge_request, anchor: "note_#{non_existing_record_id}") + end + + it { is_expected.to be_nil } + end + end + + describe '#other_reports_for_user' do + let(:report) { create(:abuse_report) } + let(:another_user_report) { create(:abuse_report, user: report.user) } + let(:another_report) { create(:abuse_report) } + + it 'returns other reports for the same user' do + expect(report.other_reports_for_user).to match_array(another_user_report) + end + end + describe 'enums' do let(:categories) do { diff --git a/spec/models/achievements/user_achievement_spec.rb b/spec/models/achievements/user_achievement_spec.rb index 9d88bfdd477..41eaadafa67 100644 --- a/spec/models/achievements/user_achievement_spec.rb +++ b/spec/models/achievements/user_achievement_spec.rb @@ -7,7 +7,34 @@ RSpec.describe Achievements::UserAchievement, type: :model, feature_category: :u it { is_expected.to belong_to(:achievement).inverse_of(:user_achievements).required } it { is_expected.to belong_to(:user).inverse_of(:user_achievements).required } - it { is_expected.to belong_to(:awarded_by_user).class_name('User').inverse_of(:awarded_user_achievements).optional } + it { is_expected.to belong_to(:awarded_by_user).class_name('User').inverse_of(:awarded_user_achievements).required } it { is_expected.to belong_to(:revoked_by_user).class_name('User').inverse_of(:revoked_user_achievements).optional } + + describe '#revoked?' do + subject { achievement.revoked? } + + context 'when revoked' do + let_it_be(:achievement) { create(:user_achievement, :revoked) } + + it { is_expected.to be true } + end + + context 'when not revoked' do + let_it_be(:achievement) { create(:user_achievement) } + + it { is_expected.to be false } + end + end + end + + describe 'scopes' do + let_it_be(:user_achievement) { create(:user_achievement) } + let_it_be(:revoked_user_achievement) { create(:user_achievement, :revoked) } + + describe '.not_revoked' do + it 'only returns user achievements which have not been revoked' do + expect(described_class.not_revoked).to contain_exactly(user_achievement) + end + end end end diff --git a/spec/models/active_session_spec.rb b/spec/models/active_session_spec.rb index 3665f13015e..8717b2a1075 100644 --- a/spec/models/active_session_spec.rb +++ b/spec/models/active_session_spec.rb @@ -190,8 +190,7 @@ RSpec.describe ActiveSession, :clean_gitlab_redis_sessions do Gitlab::Redis::Sessions.with do |redis| expect(redis.scan_each.to_a).to include( - described_class.key_name(user.id, session_id), # current session - described_class.key_name_v1(user.id, session_id), # support for mixed deployment + described_class.key_name(user.id, session_id), # current session lookup_key ) end @@ -217,19 +216,6 @@ RSpec.describe ActiveSession, :clean_gitlab_redis_sessions do end end - it 'is possible to log in only using the old session key' do - session_id = "2::418729c72310bbf349a032f0bb6e3fce9f5a69df8f000d8ae0ac5d159d8f21ae" - ActiveSession.set(user, request) - - Gitlab::Redis::SharedState.with do |redis| - redis.del(described_class.key_name(user.id, session_id)) - end - - sessions = ActiveSession.list(user) - - expect(sessions).to be_present - end - it 'keeps the created_at from the login on consecutive requests' do created_at = Time.zone.parse('2018-03-12 09:06') updated_at = created_at + 1.minute @@ -593,7 +579,7 @@ RSpec.describe ActiveSession, :clean_gitlab_redis_sessions do let(:active_count) { 3 } before do - Gitlab::Redis::SharedState.with do |redis| + Gitlab::Redis::Sessions.with do |redis| active_count.times do |number| redis.set( key_name(user.id, number), @@ -608,13 +594,13 @@ RSpec.describe ActiveSession, :clean_gitlab_redis_sessions do end it 'removes obsolete lookup entries' do - active = Gitlab::Redis::SharedState.with do |redis| + active = Gitlab::Redis::Sessions.with do |redis| ActiveSession.cleaned_up_lookup_entries(redis, user) end expect(active.count).to eq(active_count) - Gitlab::Redis::SharedState.with do |redis| + Gitlab::Redis::Sessions.with do |redis| lookup_entries = redis.smembers(lookup_key) expect(lookup_entries.count).to eq(active_count) @@ -627,7 +613,7 @@ RSpec.describe ActiveSession, :clean_gitlab_redis_sessions do it 'reports the removed entries' do removed = [] - Gitlab::Redis::SharedState.with do |redis| + Gitlab::Redis::Sessions.with do |redis| ActiveSession.cleaned_up_lookup_entries(redis, user, removed) end @@ -663,4 +649,26 @@ RSpec.describe ActiveSession, :clean_gitlab_redis_sessions do it_behaves_like 'cleaning up lookup entries' end end + + describe '.set_active_user_cookie' do + let(:auth) { double(cookies: {}) } + + it 'sets marketing cookie' do + ActiveSession.set_active_user_cookie(auth) + expect(auth.cookies[:about_gitlab_active_user][:value]).to be_truthy + end + end + + describe '.unset_active_user_cookie' do + let(:auth) { double(cookies: {}) } + + before do + ActiveSession.set_active_user_cookie(auth) + end + + it 'unsets marketing cookie' do + ActiveSession.unset_active_user_cookie(auth) + expect(auth.cookies[:about_gitlab_active_user]).to be_nil + end + end end diff --git a/spec/models/airflow/dags_spec.rb b/spec/models/airflow/dags_spec.rb deleted file mode 100644 index ff3c4522779..00000000000 --- a/spec/models/airflow/dags_spec.rb +++ /dev/null @@ -1,17 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe Airflow::Dags, feature_category: :dataops do - describe 'associations' do - it { is_expected.to belong_to(:project) } - end - - describe 'validations' do - it { is_expected.to validate_presence_of(:project) } - it { is_expected.to validate_presence_of(:dag_name) } - it { is_expected.to validate_length_of(:dag_name).is_at_most(255) } - it { is_expected.to validate_length_of(:schedule).is_at_most(255) } - it { is_expected.to validate_length_of(:fileloc).is_at_most(255) } - end -end diff --git a/spec/models/alert_management/alert_assignee_spec.rb b/spec/models/alert_management/alert_assignee_spec.rb index c50a3ec0d01..647195380b3 100644 --- a/spec/models/alert_management/alert_assignee_spec.rb +++ b/spec/models/alert_management/alert_assignee_spec.rb @@ -5,7 +5,11 @@ require 'spec_helper' RSpec.describe AlertManagement::AlertAssignee do describe 'associations' do it { is_expected.to belong_to(:alert) } - it { is_expected.to belong_to(:assignee) } + + it do + is_expected.to belong_to(:assignee).class_name('User') + .with_foreign_key(:user_id).inverse_of(:alert_assignees) + end end describe 'validations' do diff --git a/spec/models/alert_management/alert_spec.rb b/spec/models/alert_management/alert_spec.rb index 685ed81ec84..ff77ca2ab64 100644 --- a/spec/models/alert_management/alert_spec.rb +++ b/spec/models/alert_management/alert_spec.rb @@ -16,9 +16,13 @@ RSpec.describe AlertManagement::Alert do it { is_expected.to belong_to(:prometheus_alert).optional } it { is_expected.to belong_to(:environment).optional } it { is_expected.to have_many(:assignees).through(:alert_assignees) } - it { is_expected.to have_many(:notes) } - it { is_expected.to have_many(:ordered_notes) } - it { is_expected.to have_many(:user_mentions) } + it { is_expected.to have_many(:notes).inverse_of(:noteable) } + it { is_expected.to have_many(:ordered_notes).class_name('Note').inverse_of(:noteable) } + + it do + is_expected.to have_many(:user_mentions).class_name('AlertManagement::AlertUserMention') + .with_foreign_key(:alert_management_alert_id).inverse_of(:alert) + end end describe 'validations' do diff --git a/spec/models/alert_management/alert_user_mention_spec.rb b/spec/models/alert_management/alert_user_mention_spec.rb index 27c3d290dde..083bf667bea 100644 --- a/spec/models/alert_management/alert_user_mention_spec.rb +++ b/spec/models/alert_management/alert_user_mention_spec.rb @@ -4,7 +4,11 @@ require 'spec_helper' RSpec.describe AlertManagement::AlertUserMention do describe 'associations' do - it { is_expected.to belong_to(:alert_management_alert) } + it do + is_expected.to belong_to(:alert).class_name('::AlertManagement::Alert') + .with_foreign_key(:alert_management_alert_id).inverse_of(:user_mentions) + end + it { is_expected.to belong_to(:note) } end diff --git a/spec/models/analytics/cycle_analytics/stage_spec.rb b/spec/models/analytics/cycle_analytics/stage_spec.rb index 57748f8942e..960d8d3e964 100644 --- a/spec/models/analytics/cycle_analytics/stage_spec.rb +++ b/spec/models/analytics/cycle_analytics/stage_spec.rb @@ -32,24 +32,24 @@ RSpec.describe Analytics::CycleAnalytics::Stage, feature_category: :value_stream before do # event identifiers are the same create(:cycle_analytics_stage, name: 'Stage A1', namespace: group, - start_event_identifier: :merge_request_created, end_event_identifier: :merge_request_merged) + start_event_identifier: :merge_request_created, end_event_identifier: :merge_request_merged) create(:cycle_analytics_stage, name: 'Stage A2', namespace: sub_group, - start_event_identifier: :merge_request_created, end_event_identifier: :merge_request_merged) + start_event_identifier: :merge_request_created, end_event_identifier: :merge_request_merged) create(:cycle_analytics_stage, name: 'Stage A3', namespace: sub_group, - start_event_identifier: :merge_request_created, end_event_identifier: :merge_request_merged) + start_event_identifier: :merge_request_created, end_event_identifier: :merge_request_merged) create(:cycle_analytics_stage, name: 'Stage A4', project: project, - start_event_identifier: :merge_request_created, end_event_identifier: :merge_request_merged) + start_event_identifier: :merge_request_created, end_event_identifier: :merge_request_merged) create(:cycle_analytics_stage, - name: 'Stage B1', - namespace: group, - start_event_identifier: :merge_request_last_build_started, - end_event_identifier: :merge_request_last_build_finished) + name: 'Stage B1', + namespace: group, + start_event_identifier: :merge_request_last_build_started, + end_event_identifier: :merge_request_last_build_finished) create(:cycle_analytics_stage, name: 'Stage C1', project: project, - start_event_identifier: :issue_created, end_event_identifier: :issue_deployed_to_production) + start_event_identifier: :issue_created, end_event_identifier: :issue_deployed_to_production) create(:cycle_analytics_stage, name: 'Stage C2', project: project, - start_event_identifier: :issue_created, end_event_identifier: :issue_deployed_to_production) + start_event_identifier: :issue_created, end_event_identifier: :issue_deployed_to_production) end it 'returns distinct stages by the event identifiers' do @@ -69,14 +69,11 @@ RSpec.describe Analytics::CycleAnalytics::Stage, feature_category: :value_stream end end - describe 'events tracking' do - let(:category) { described_class.to_s } - let(:label) { described_class.table_name } + it_behaves_like 'database events tracking' do let(:namespace) { create(:group) } - let(:action) { "database_event_#{property}" } let(:value_stream) { create(:cycle_analytics_value_stream) } - let(:feature_flag_name) { :product_intelligence_database_event_tracking } - let(:stage) { described_class.create!(stage_params) } + let(:record) { described_class.create!(stage_params) } + let(:update_params) { { name: 'st 2' } } let(:stage_params) do { namespace: namespace, @@ -86,50 +83,5 @@ RSpec.describe Analytics::CycleAnalytics::Stage, feature_category: :value_stream group_value_stream_id: value_stream.id } end - - let(:record_tracked_attributes) do - { - "id" => stage.id, - "created_at" => stage.created_at, - "updated_at" => stage.updated_at, - "relative_position" => stage.relative_position, - "start_event_identifier" => stage.start_event_identifier, - "end_event_identifier" => stage.end_event_identifier, - "group_id" => stage.group_id, - "start_event_label_id" => stage.start_event_label_id, - "end_event_label_id" => stage.end_event_label_id, - "hidden" => stage.hidden, - "custom" => stage.custom, - "name" => stage.name, - "group_value_stream_id" => stage.group_value_stream_id - } - end - - describe '#create' do - it_behaves_like 'Snowplow event tracking' do - let(:property) { 'create' } - let(:extra) { record_tracked_attributes } - - subject(:new_group_stage) { stage } - end - end - - describe '#update', :freeze_time do - it_behaves_like 'Snowplow event tracking' do - subject(:create_group_stage) { stage.update!(name: 'st 2') } - - let(:extra) { record_tracked_attributes.merge('name' => 'st 2') } - let(:property) { 'update' } - end - end - - describe '#destroy' do - it_behaves_like 'Snowplow event tracking' do - subject(:delete_stage_group) { stage.destroy! } - - let(:extra) { record_tracked_attributes } - let(:property) { 'destroy' } - end - end end end diff --git a/spec/models/application_record_spec.rb b/spec/models/application_record_spec.rb index c1cd44e9007..ee3065cf8f2 100644 --- a/spec/models/application_record_spec.rb +++ b/spec/models/application_record_spec.rb @@ -106,12 +106,12 @@ RSpec.describe ApplicationRecord do describe '.where_not_exists' do it 'produces a WHERE NOT EXISTS query' do - create(:user, :two_factor_via_u2f) + create(:user, :two_factor_via_webauthn) user_2 = create(:user) expect( User.where_not_exists( - U2fRegistration.where(U2fRegistration.arel_table[:user_id].eq(User.arel_table[:id]))) + WebauthnRegistration.where(WebauthnRegistration.arel_table[:user_id].eq(User.arel_table[:id]))) ).to match_array([user_2]) end end @@ -258,7 +258,7 @@ RSpec.describe ApplicationRecord do before do ApplicationRecord.connection.execute(<<~SQL) - create table tests ( + create table _test_tests ( id bigserial primary key not null, ignore_me text ) @@ -267,7 +267,7 @@ RSpec.describe ApplicationRecord do context 'without an ignored column' do let(:test_model) do Class.new(ApplicationRecord) do - self.table_name = 'tests' + self.table_name = :_test_tests end end @@ -278,7 +278,7 @@ RSpec.describe ApplicationRecord do let(:test_model) do Class.new(ApplicationRecord) do include IgnorableColumns - self.table_name = 'tests' + self.table_name = :_test_tests ignore_columns :ignore_me, remove_after: '2100-01-01', remove_with: '99.12' end diff --git a/spec/models/application_setting_spec.rb b/spec/models/application_setting_spec.rb index 5b99c68ec80..98bfb3366d2 100644 --- a/spec/models/application_setting_spec.rb +++ b/spec/models/application_setting_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe ApplicationSetting, feature_category: :not_owned, type: :model do +RSpec.describe ApplicationSetting, feature_category: :shared, type: :model do using RSpec::Parameterized::TableSyntax subject(:setting) { described_class.create_from_defaults } @@ -31,6 +31,20 @@ RSpec.describe ApplicationSetting, feature_category: :not_owned, type: :model do let(:ftp) { 'ftp://example.com' } let(:javascript) { 'javascript:alert(window.opener.document.location)' } + let_it_be(:valid_database_apdex_settings) do + { + prometheus_api_url: 'Prometheus URL', + apdex_sli_query: { + main: 'Apdex SLI query main', + ci: 'Apdex SLI query ci' + }, + apdex_slo: { + main: 0.99, + ci: 0.98 + } + } + end + it { is_expected.to allow_value(nil).for(:home_page_url) } it { is_expected.to allow_value(http).for(:home_page_url) } it { is_expected.to allow_value(https).for(:home_page_url) } @@ -132,6 +146,9 @@ RSpec.describe ApplicationSetting, feature_category: :not_owned, type: :model do it { is_expected.to allow_value(false).for(:user_defaults_to_private_profile) } it { is_expected.not_to allow_value(nil).for(:user_defaults_to_private_profile) } + it { is_expected.to allow_values([true, false]).for(:deny_all_requests_except_allowed) } + it { is_expected.not_to allow_value(nil).for(:deny_all_requests_except_allowed) } + it 'ensures max_pages_size is an integer greater than 0 (or equal to 0 to indicate unlimited/maximum)' do is_expected.to validate_numericality_of(:max_pages_size).only_integer.is_greater_than_or_equal_to(0) .is_less_than(::Gitlab::Pages::MAX_SIZE / 1.megabyte) @@ -182,7 +199,8 @@ RSpec.describe ApplicationSetting, feature_category: :not_owned, type: :model do it { is_expected.not_to allow_value('default' => 101).for(:repository_storages_weighted).with_message("value for 'default' must be between 0 and 100") } it { is_expected.not_to allow_value('default' => 100, shouldntexist: 50).for(:repository_storages_weighted).with_message("can't include: shouldntexist") } - %i[notes_create_limit search_rate_limit search_rate_limit_unauthenticated users_get_by_id_limit].each do |setting| + %i[notes_create_limit search_rate_limit search_rate_limit_unauthenticated users_get_by_id_limit + projects_api_rate_limit_unauthenticated].each do |setting| it { is_expected.to allow_value(400).for(setting) } it { is_expected.not_to allow_value('two').for(setting) } it { is_expected.not_to allow_value(nil).for(setting) } @@ -209,6 +227,12 @@ RSpec.describe ApplicationSetting, feature_category: :not_owned, type: :model do it { is_expected.to allow_value('disabled').for(:whats_new_variant) } it { is_expected.not_to allow_value(nil).for(:whats_new_variant) } + it { is_expected.to allow_value('http://example.com/').for(:public_runner_releases_url) } + it { is_expected.not_to allow_value(nil).for(:public_runner_releases_url) } + + it { is_expected.to allow_value([true, false]).for(:update_runner_versions_enabled) } + it { is_expected.not_to allow_value(nil).for(:update_runner_versions_enabled) } + it { is_expected.not_to allow_value(['']).for(:valid_runner_registrars) } it { is_expected.not_to allow_value(['OBVIOUSLY_WRONG']).for(:valid_runner_registrars) } it { is_expected.not_to allow_value(%w(project project)).for(:valid_runner_registrars) } @@ -228,6 +252,27 @@ RSpec.describe ApplicationSetting, feature_category: :not_owned, type: :model do it { is_expected.to allow_value(false).for(:allow_runner_registration_token) } it { is_expected.not_to allow_value(nil).for(:allow_runner_registration_token) } + it { is_expected.to allow_value(true).for(:gitlab_dedicated_instance) } + it { is_expected.to allow_value(false).for(:gitlab_dedicated_instance) } + it { is_expected.not_to allow_value(nil).for(:gitlab_dedicated_instance) } + + it { is_expected.not_to allow_value(random: :value).for(:database_apdex_settings) } + it { is_expected.to allow_value(nil).for(:database_apdex_settings) } + it { is_expected.to allow_value(valid_database_apdex_settings).for(:database_apdex_settings) } + + it { is_expected.to allow_value([true, false]).for(:silent_mode_enabled) } + it { is_expected.not_to allow_value(nil).for(:silent_mode_enabled) } + + it { is_expected.to allow_value(0).for(:ci_max_includes) } + it { is_expected.to allow_value(200).for(:ci_max_includes) } + it { is_expected.not_to allow_value('abc').for(:ci_max_includes) } + it { is_expected.not_to allow_value(nil).for(:ci_max_includes) } + it { is_expected.not_to allow_value(10.5).for(:ci_max_includes) } + it { is_expected.not_to allow_value(-1).for(:ci_max_includes) } + + it { is_expected.to allow_value([true, false]).for(:remember_me_enabled) } + it { is_expected.not_to allow_value(nil).for(:remember_me_enabled) } + context 'when deactivate_dormant_users is enabled' do before do stub_application_setting(deactivate_dormant_users: true) @@ -268,6 +313,17 @@ RSpec.describe ApplicationSetting, feature_category: :not_owned, type: :model do end end + context 'import_sources validation' do + before do + subject.import_sources = %w[github bitbucket gitlab git gitlab_project gitea manifest phabricator] + end + + it 'removes phabricator as an import source' do + subject.validate + expect(subject.import_sources).to eq(%w[github bitbucket git gitlab_project gitea manifest]) + end + end + context 'grafana_url validations' do before do subject.instance_variable_set(:@parsed_grafana_url, nil) @@ -318,7 +374,7 @@ RSpec.describe ApplicationSetting, feature_category: :not_owned, type: :model do end end - describe 'default_branch_name validaitions' do + describe 'default_branch_name validations' do context "when javascript tags get sanitized properly" do it "gets sanitized properly" do setting.update!(default_branch_name: "hello<script>alert(1)</script>") @@ -506,6 +562,13 @@ RSpec.describe ApplicationSetting, feature_category: :not_owned, type: :model do .is_less_than(65536) end + specify do + is_expected.to validate_numericality_of(:archive_builds_in_seconds) + .only_integer + .is_greater_than_or_equal_to(1.day.seconds.to_i) + .with_message('must be at least 1 day') + end + describe 'usage_ping_enabled setting' do shared_examples 'usage ping enabled' do it do @@ -585,6 +648,23 @@ RSpec.describe ApplicationSetting, feature_category: :not_owned, type: :model do end end + describe 'setting validated as `addressable_url` configured with external URI' do + before do + # Use any property that has the `addressable_url` validation. + setting.help_page_documentation_base_url = 'http://example.com' + end + + it 'is valid by default' do + expect(setting).to be_valid + end + + it 'is invalid when unpersisted `deny_all_requests_except_allowed` property is true' do + setting.deny_all_requests_except_allowed = true + + expect(setting).not_to be_valid + end + end + context 'key restrictions' do it 'does not allow all key types to be disabled' do Gitlab::SSHPublicKey.supported_types.each do |type| @@ -1124,6 +1204,11 @@ RSpec.describe ApplicationSetting, feature_category: :not_owned, type: :model do it { is_expected.to allow_value(*Gitlab::I18n.available_locales).for(:default_preferred_language) } it { is_expected.not_to allow_value(nil, '', 'invalid_locale').for(:default_preferred_language) } end + + context 'for default_syntax_highlighting_theme' do + it { is_expected.to allow_value(*Gitlab::ColorSchemes.valid_ids).for(:default_syntax_highlighting_theme) } + it { is_expected.not_to allow_value(nil, 0, Gitlab::ColorSchemes.available_schemes.size + 1).for(:default_syntax_highlighting_theme) } + end end context 'restrict creating duplicates' do @@ -1144,6 +1229,17 @@ RSpec.describe ApplicationSetting, feature_category: :not_owned, type: :model do end end + describe 'ADDRESSABLE_URL_VALIDATION_OPTIONS' do + it 'is applied to all addressable_url validated properties' do + url_validators = described_class.validators.select { |validator| validator.is_a?(AddressableUrlValidator) } + + url_validators.each do |validator| + expect(validator.options).to match(hash_including(described_class::ADDRESSABLE_URL_VALIDATION_OPTIONS)), + "#{validator.attributes} should use ADDRESSABLE_URL_VALIDATION_OPTIONS" + end + end + end + describe '#disabled_oauth_sign_in_sources=' do before do allow(Devise).to receive(:omniauth_providers).and_return([:github]) @@ -1451,7 +1547,7 @@ RSpec.describe ApplicationSetting, feature_category: :not_owned, type: :model do it { is_expected.to validate_numericality_of(:inactive_projects_min_size_mb).is_greater_than_or_equal_to(0) } it "deletes the redis key used for tracking inactive projects deletion warning emails when setting is updated", - :clean_gitlab_redis_shared_state do + :clean_gitlab_redis_shared_state do Gitlab::Redis::SharedState.with do |redis| redis.hset("inactive_projects_deletion_warning_email_notified", "project:1", "2020-01-01") end diff --git a/spec/models/audit_event_spec.rb b/spec/models/audit_event_spec.rb index 9f2724cebee..9e667836b45 100644 --- a/spec/models/audit_event_spec.rb +++ b/spec/models/audit_event_spec.rb @@ -3,6 +3,10 @@ require 'spec_helper' RSpec.describe AuditEvent do + describe 'associations' do + it { is_expected.to belong_to(:user).with_foreign_key(:author_id).inverse_of(:audit_events) } + end + describe 'validations' do include_examples 'validates IP address' do let(:attribute) { :ip_address } diff --git a/spec/models/authentication_event_spec.rb b/spec/models/authentication_event_spec.rb index 23e253c2a28..17fe10b5b4e 100644 --- a/spec/models/authentication_event_spec.rb +++ b/spec/models/authentication_event_spec.rb @@ -71,4 +71,19 @@ RSpec.describe AuthenticationEvent do it { is_expected.to eq(false) } end end + + describe '.most_used_ip_address_for_user' do + let_it_be(:user) { create(:user) } + let_it_be(:most_used_ip_address) { '::1' } + let_it_be(:another_ip_address) { '127.0.0.1' } + + subject { described_class.most_used_ip_address_for_user(user) } + + before do + create_list(:authentication_event, 2, user: user, ip_address: most_used_ip_address) + create(:authentication_event, user: user, ip_address: another_ip_address) + end + + it { is_expected.to eq(most_used_ip_address) } + end end diff --git a/spec/models/award_emoji_spec.rb b/spec/models/award_emoji_spec.rb index 2593c9b3595..99006f8ddce 100644 --- a/spec/models/award_emoji_spec.rb +++ b/spec/models/award_emoji_spec.rb @@ -63,22 +63,43 @@ RSpec.describe AwardEmoji do let_it_be(:user) { create(:user) } let_it_be(:group) { create(:group) } let_it_be(:emoji) { create(:custom_emoji, name: 'partyparrot', namespace: group) } + let_it_be(:project) { create(:project, namespace: group) } - before do + before_all do group.add_maintainer(user) end - %i[issue merge_request note_on_issue snippet].each do |awardable_type| - let_it_be(:project) { create(:project, namespace: group) } - let(:awardable) { create(awardable_type, project: project) } - - it "is accepted on #{awardable_type}" do + shared_examples 'awardable' do + it 'is accepted' do new_award = build(:award_emoji, user: user, awardable: awardable, name: emoji.name) - expect(new_award).to be_valid end end + context 'with issue' do + let(:awardable) { create(:issue, project: project) } + + include_examples 'awardable' + end + + context 'with merge_request' do + let(:awardable) { create(:merge_request, source_project: project) } + + include_examples 'awardable' + end + + context 'with note_on_issue' do + let(:awardable) { create(:note_on_issue, project: project) } + + include_examples 'awardable' + end + + context 'with snippet' do + let(:awardable) { create(:snippet, project: project) } + + include_examples 'awardable' + end + it 'is accepted on subgroup issue' do subgroup = create(:group, parent: group) project = create(:project, namespace: subgroup) diff --git a/spec/models/awareness_session_spec.rb b/spec/models/awareness_session_spec.rb deleted file mode 100644 index 854ce5957f7..00000000000 --- a/spec/models/awareness_session_spec.rb +++ /dev/null @@ -1,163 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe AwarenessSession, :clean_gitlab_redis_shared_state do - subject { AwarenessSession.for(session_id) } - - let!(:user) { create(:user) } - let(:session_id) { 1 } - - describe "when initiating a session" do - it "provides a string representation of the model instance" do - expected = "awareness_session=6b86b273ff34fce" - - expect(subject.to_s).to eql(expected) - end - - it "provides a parameterized version of the session identifier" do - expected = "6b86b273ff34fce" - - expect(subject.to_param).to eql(expected) - end - end - - describe "when a user joins a session" do - let(:user2) { create(:user) } - - let(:presence_ttl) { 15.minutes } - - it "changes number of session members" do - expect { subject.join(user) }.to change(subject, :size).by(1) - end - - it "returns user as member of session with last_activity timestamp" do - freeze_time do - subject.join(user) - - session_users = subject.users_with_last_activity - session_user, last_activity = session_users.first - - expect(session_user.id).to be(user.id) - expect(last_activity).to be_eql(Time.now.utc) - end - end - - it "maintains user ID and last_activity pairs" do - now = Time.zone.now - - travel_to now - 1.minute do - subject.join(user2) - end - - travel_to now do - subject.join(user) - end - - session_users = subject.users_with_last_activity - - expect(session_users[0].first.id).to eql(user.id) - expect(session_users[0].last.to_i).to eql(now.to_i) - - expect(session_users[1].first.id).to eql(user2.id) - expect(session_users[1].last.to_i).to eql((now - 1.minute).to_i) - end - - it "reports user as present" do - freeze_time do - subject.join(user) - - expect(subject.present?(user, threshold: presence_ttl)).to be true - end - end - - it "reports user as away after a certain time on inactivity" do - subject.join(user) - - travel_to((presence_ttl + 1.minute).from_now) do - expect(subject.away?(user, threshold: presence_ttl)).to be true - end - end - - it "reports user as present still when there was some activity" do - subject.join(user) - - travel_to((presence_ttl - 1.minute).from_now) do - subject.touch!(user) - end - - travel_to((presence_ttl + 1.minute).from_now) do - expect(subject.present?(user, threshold: presence_ttl)).to be true - end - end - - it "creates user and session awareness keys in store" do - subject.join(user) - - Gitlab::Redis::SharedState.with do |redis| - keys = redis.scan_each(match: "gitlab:awareness:*").to_a - - expect(keys.size).to be(2) - end - end - - it "sets a timeout for user and session key" do - subject.join(user) - subject_id = Digest::SHA256.hexdigest(session_id.to_s)[0, 15] - - Gitlab::Redis::SharedState.with do |redis| - ttl_session = redis.ttl("gitlab:awareness:session:#{subject_id}:users") - ttl_user = redis.ttl("gitlab:awareness:user:#{user.id}:sessions") - - expect(ttl_session).to be > 0 - expect(ttl_user).to be > 0 - end - end - - it "fetches user(s) from database" do - subject.join(user) - - expect(subject.users.first).to eql(user) - end - - it "fetches and filters online user(s) from database" do - subject.join(user) - - travel 2.hours do - subject.join(user2) - - online_users = subject.online_users_with_last_activity - online_user, _ = online_users.first - - expect(online_users.size).to be 1 - expect(online_user).to eql(user2) - end - end - end - - describe "when a user leaves a session" do - it "changes number of session members" do - subject.join(user) - - expect { subject.leave(user) }.to change(subject, :size).by(-1) - end - - it "destroys the session when it was the last user" do - subject.join(user) - - expect { subject.leave(user) }.to change(subject, :id).to(nil) - end - end - - describe "when last user leaves a session" do - it "session and user keys are removed" do - subject.join(user) - - Gitlab::Redis::SharedState.with do |redis| - expect { subject.leave(user) } - .to change { redis.scan_each(match: "gitlab:awareness:*").to_a.size } - .to(0) - end - end - end -end diff --git a/spec/models/blob_viewer/metrics_dashboard_yml_spec.rb b/spec/models/blob_viewer/metrics_dashboard_yml_spec.rb index d28fa0bbe97..c9ac13eefc0 100644 --- a/spec/models/blob_viewer/metrics_dashboard_yml_spec.rb +++ b/spec/models/blob_viewer/metrics_dashboard_yml_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe BlobViewer::MetricsDashboardYml do +RSpec.describe BlobViewer::MetricsDashboardYml, feature_category: :metrics do include FakeBlobHelpers include RepoHelpers @@ -119,4 +119,18 @@ RSpec.describe BlobViewer::MetricsDashboardYml do expect(viewer.errors).to eq ["YAML syntax: The parsed YAML is too big"] end end + + describe '.can_render?' do + subject { described_class.can_render?(blob) } + + it { is_expected.to be false } + + context 'when metrics dashboard feature is available' do + before do + stub_feature_flags(remove_monitor_metrics: false) + end + + it { is_expected.to be true } + end + end end diff --git a/spec/models/blob_viewer/package_json_spec.rb b/spec/models/blob_viewer/package_json_spec.rb index 1dcba3bcb4f..781623c0d3d 100644 --- a/spec/models/blob_viewer/package_json_spec.rb +++ b/spec/models/blob_viewer/package_json_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe BlobViewer::PackageJson do +RSpec.describe BlobViewer::PackageJson, feature_category: :source_code_management do include FakeBlobHelpers let(:project) { build_stubbed(:project) } @@ -59,6 +59,17 @@ RSpec.describe BlobViewer::PackageJson do expect(subject.manager_url).to eq("https://yarnpkg.com/") end end + + context 'when json is an array' do + let(:data) { '[]' } + + it 'does not raise an error', :aggregate_failures do + expect(subject).to receive(:prepare!) + + expect { subject.yarn? }.not_to raise_error + expect(subject.yarn?).to be_falsey + end + end end context 'npm' do diff --git a/spec/models/board_spec.rb b/spec/models/board_spec.rb index 6017298e85b..f469dee5ba1 100644 --- a/spec/models/board_spec.rb +++ b/spec/models/board_spec.rb @@ -8,7 +8,13 @@ RSpec.describe Board do describe 'relationships' do it { is_expected.to belong_to(:project) } - it { is_expected.to have_many(:lists).order(list_type: :asc, position: :asc).dependent(:delete_all) } + + it do + is_expected.to have_many(:lists).order(list_type: :asc, position: :asc).dependent(:delete_all) + .inverse_of(:board) + end + + it { is_expected.to have_many(:destroyable_lists).order(list_type: :asc, position: :asc).inverse_of(:board) } end describe 'validations' do diff --git a/spec/models/broadcast_message_spec.rb b/spec/models/broadcast_message_spec.rb index 8fdc9852f6e..5fcf6813b0a 100644 --- a/spec/models/broadcast_message_spec.rb +++ b/spec/models/broadcast_message_spec.rb @@ -24,7 +24,11 @@ RSpec.describe BroadcastMessage do it { is_expected.to allow_value(1).for(:broadcast_type) } it { is_expected.not_to allow_value(nil).for(:broadcast_type) } it { is_expected.not_to allow_value(nil).for(:target_access_levels) } - it { is_expected.to validate_inclusion_of(:target_access_levels).in_array(described_class::ALLOWED_TARGET_ACCESS_LEVELS) } + + it do + is_expected.to validate_inclusion_of(:target_access_levels) + .in_array(described_class::ALLOWED_TARGET_ACCESS_LEVELS) + end end describe 'default values' do @@ -187,32 +191,6 @@ RSpec.describe BroadcastMessage do shared_examples "matches with user access level" do |broadcast_type| let_it_be(:target_access_levels) { [Gitlab::Access::GUEST] } - let(:feature_flag_state) { true } - - before do - stub_feature_flags(role_targeted_broadcast_messages: feature_flag_state) - end - - context 'when feature flag is disabled' do - let(:feature_flag_state) { false } - - context 'when message is role-targeted' do - let_it_be(:message) { create(:broadcast_message, target_access_levels: target_access_levels, broadcast_type: broadcast_type) } - - it 'does not return the message' do - expect(subject.call(nil, Gitlab::Access::GUEST)).to be_empty - end - end - - context 'when message is not role-targeted' do - let_it_be(:message) { create(:broadcast_message, target_access_levels: [], broadcast_type: broadcast_type) } - - it 'returns the message' do - expect(subject.call(nil, Gitlab::Access::GUEST)).to include(message) - end - end - end - context 'when target_access_levels is empty' do let_it_be(:message) { create(:broadcast_message, target_access_levels: [], broadcast_type: broadcast_type) } @@ -226,7 +204,9 @@ RSpec.describe BroadcastMessage do end context 'when target_access_levels is not empty' do - let_it_be(:message) { create(:broadcast_message, target_access_levels: target_access_levels, broadcast_type: broadcast_type) } + let_it_be(:message) do + create(:broadcast_message, target_access_levels: target_access_levels, broadcast_type: broadcast_type) + end it "does not return the message if user access level is nil" do expect(subject.call).to be_empty @@ -250,26 +230,18 @@ RSpec.describe BroadcastMessage do before do cache.write(described_class::BANNER_CACHE_KEY, [message]) - allow(BroadcastMessage).to receive(:cache) { cache } + allow(described_class).to receive(:cache) { cache } end it 'does not raise error (e.g. NoMethodError from nil.empty?)' do expect { subject.call }.not_to raise_error end - - context 'when feature flag is disabled' do - it 'does not raise error (e.g. NoMethodError from nil.empty?)' do - stub_feature_flags(role_targeted_broadcast_messages: false) - - expect { subject.call }.not_to raise_error - end - end end end describe '.current', :use_clean_rails_memory_store_caching do subject do - -> (path = nil, user_access_level = nil) do + ->(path = nil, user_access_level = nil) do described_class.current(current_path: path, user_access_level: user_access_level) end end @@ -301,7 +273,7 @@ RSpec.describe BroadcastMessage do describe '.current_banner_messages', :use_clean_rails_memory_store_caching do subject do - -> (path = nil, user_access_level = nil) do + ->(path = nil, user_access_level = nil) do described_class.current_banner_messages(current_path: path, user_access_level: user_access_level) end end @@ -331,7 +303,7 @@ RSpec.describe BroadcastMessage do describe '.current_notification_messages', :use_clean_rails_memory_store_caching do subject do - -> (path = nil, user_access_level = nil) do + ->(path = nil, user_access_level = nil) do described_class.current_notification_messages(current_path: path, user_access_level: user_access_level) end end diff --git a/spec/models/bulk_import_spec.rb b/spec/models/bulk_import_spec.rb index 3430da43f62..acb1f4a2ef7 100644 --- a/spec/models/bulk_import_spec.rb +++ b/spec/models/bulk_import_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe BulkImport, type: :model do +RSpec.describe BulkImport, type: :model, feature_category: :importers do let_it_be(:created_bulk_import) { create(:bulk_import, :created) } let_it_be(:started_bulk_import) { create(:bulk_import, :started) } let_it_be(:finished_bulk_import) { create(:bulk_import, :finished) } @@ -48,4 +48,31 @@ RSpec.describe BulkImport, type: :model do expect(bulk_import.source_version_info.to_s).to eq(bulk_import.source_version) end end + + describe '#update_has_failures' do + let(:import) { create(:bulk_import, :started) } + let(:entity) { create(:bulk_import_entity, bulk_import: import) } + + context 'when entity has failures' do + it 'sets has_failures flag to true' do + expect(import.has_failures).to eq(false) + + entity.update!(has_failures: true) + import.fail_op! + + expect(import.has_failures).to eq(true) + end + end + + context 'when entity does not have failures' do + it 'sets has_failures flag to false' do + expect(import.has_failures).to eq(false) + + entity.update!(has_failures: false) + import.fail_op! + + expect(import.has_failures).to eq(false) + end + end + end end diff --git a/spec/models/bulk_imports/batch_tracker_spec.rb b/spec/models/bulk_imports/batch_tracker_spec.rb new file mode 100644 index 00000000000..336943228c7 --- /dev/null +++ b/spec/models/bulk_imports/batch_tracker_spec.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe BulkImports::BatchTracker, type: :model, feature_category: :importers do + describe 'associations' do + it { is_expected.to belong_to(:tracker) } + end + + describe 'validations' do + subject { build(:bulk_import_batch_tracker) } + + it { is_expected.to validate_presence_of(:batch_number) } + it { is_expected.to validate_uniqueness_of(:batch_number).scoped_to(:tracker_id) } + end +end diff --git a/spec/models/bulk_imports/entity_spec.rb b/spec/models/bulk_imports/entity_spec.rb index 56796aa1fe4..c7ace3d2b78 100644 --- a/spec/models/bulk_imports/entity_spec.rb +++ b/spec/models/bulk_imports/entity_spec.rb @@ -3,11 +3,18 @@ require 'spec_helper' RSpec.describe BulkImports::Entity, type: :model, feature_category: :importers do + subject { described_class.new(group: Group.new) } + describe 'associations' do it { is_expected.to belong_to(:bulk_import).required } it { is_expected.to belong_to(:parent) } - it { is_expected.to belong_to(:group) } + it { is_expected.to belong_to(:group).optional.with_foreign_key(:namespace_id).inverse_of(:bulk_import_entities) } it { is_expected.to belong_to(:project) } + + it do + is_expected.to have_many(:trackers).class_name('BulkImports::Tracker') + .with_foreign_key(:bulk_import_entity_id).inverse_of(:entity) + end end describe 'validations' do @@ -18,35 +25,8 @@ RSpec.describe BulkImports::Entity, type: :model, feature_category: :importers d it { is_expected.to define_enum_for(:source_type).with_values(%i[group_entity project_entity]) } context 'when formatting with regexes' do - subject { described_class.new(group: Group.new) } - - it { is_expected.to allow_values('namespace', 'parent/namespace', 'parent/group/subgroup', '').for(:destination_namespace) } - it { is_expected.not_to allow_values('parent/namespace/', '/namespace', 'parent group/subgroup', '@namespace').for(:destination_namespace) } - it { is_expected.to allow_values('source', 'source/path', 'source/full/path').for(:source_full_path) } it { is_expected.not_to allow_values('/source', 'http://source/path', 'sou rce/full/path', '').for(:source_full_path) } - - it { is_expected.to allow_values('destination', 'destination-slug', 'new-destination-slug').for(:destination_slug) } - - # it { is_expected.not_to allow_values('destination/slug', '/destination-slug', 'destination slug').for(:destination_slug) } <-- this test should - # succeed but it's failing possibly due to rspec caching. To ensure this case is covered see the more cumbersome test below: - context 'when destination_slug is invalid' do - let(:invalid_slugs) { ['destination/slug', '/destination-slug', 'destination slug'] } - let(:error_message) do - 'cannot start with a non-alphanumeric character except for periods or underscores, ' \ - 'can contain only alphanumeric characters, periods, and underscores, ' \ - 'cannot end with a period or forward slash, and has no ' \ - 'leading or trailing forward slashes' - end - - it 'raises an error' do - invalid_slugs.each do |slug| - entity = build(:bulk_import_entity, :group_entity, group: build(:group), project: nil, destination_slug: slug) - expect(entity).not_to be_valid - expect(entity.errors.errors[0].message).to include(error_message) - end - end - end end context 'when associated with a group and project' do @@ -93,8 +73,6 @@ RSpec.describe BulkImports::Entity, type: :model, feature_category: :importers d end it 'is invalid as a project_entity' do - stub_feature_flags(bulk_import_projects: true) - entity = build(:bulk_import_entity, :project_entity, group: build(:group), project: nil) expect(entity).not_to be_valid @@ -104,8 +82,6 @@ RSpec.describe BulkImports::Entity, type: :model, feature_category: :importers d context 'when associated with a project and no group' do it 'is valid' do - stub_feature_flags(bulk_import_projects: true) - entity = build(:bulk_import_entity, :project_entity, group: nil, project: build(:project)) expect(entity).to be_valid @@ -135,8 +111,6 @@ RSpec.describe BulkImports::Entity, type: :model, feature_category: :importers d context 'when the parent is a project import' do it 'is invalid' do - stub_feature_flags(bulk_import_projects: true) - entity = build(:bulk_import_entity, parent: build(:bulk_import_entity, :project_entity)) expect(entity).not_to be_valid @@ -178,38 +152,13 @@ RSpec.describe BulkImports::Entity, type: :model, feature_category: :importers d end end - context 'when bulk_import_projects feature flag is disabled and source_type is a project_entity' do - it 'is invalid' do - stub_feature_flags(bulk_import_projects: false) - - entity = build(:bulk_import_entity, :project_entity) - - expect(entity).not_to be_valid - expect(entity.errors[:base]).to include('invalid entity source type') - end - end - - context 'when bulk_import_projects feature flag is enabled and source_type is a project_entity' do + context 'when source_type is a project_entity' do it 'is valid' do - stub_feature_flags(bulk_import_projects: true) - entity = build(:bulk_import_entity, :project_entity) expect(entity).to be_valid end end - - context 'when bulk_import_projects feature flag is enabled on root ancestor level and source_type is a project_entity' do - it 'is valid' do - top_level_namespace = create(:group) - - stub_feature_flags(bulk_import_projects: top_level_namespace) - - entity = build(:bulk_import_entity, :project_entity, destination_namespace: top_level_namespace.full_path) - - expect(entity).to be_valid - end - end end describe '#encoded_source_full_path' do @@ -412,4 +361,30 @@ RSpec.describe BulkImports::Entity, type: :model, feature_category: :importers d end end end + + describe '#update_has_failures' do + let(:entity) { create(:bulk_import_entity) } + + context 'when entity has failures' do + it 'sets has_failures flag to true' do + expect(entity.has_failures).to eq(false) + + create(:bulk_import_failure, entity: entity) + + entity.fail_op! + + expect(entity.has_failures).to eq(true) + end + end + + context 'when entity does not have failures' do + it 'sets has_failures flag to false' do + expect(entity.has_failures).to eq(false) + + entity.fail_op! + + expect(entity.has_failures).to eq(false) + end + end + end end diff --git a/spec/models/bulk_imports/export_batch_spec.rb b/spec/models/bulk_imports/export_batch_spec.rb new file mode 100644 index 00000000000..43209921b9c --- /dev/null +++ b/spec/models/bulk_imports/export_batch_spec.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe BulkImports::ExportBatch, type: :model, feature_category: :importers do + describe 'associations' do + it { is_expected.to belong_to(:export) } + it { is_expected.to have_one(:upload) } + end + + describe 'validations' do + subject { build(:bulk_import_export_batch) } + + it { is_expected.to validate_presence_of(:batch_number) } + it { is_expected.to validate_uniqueness_of(:batch_number).scoped_to(:export_id) } + end +end diff --git a/spec/models/bulk_imports/export_spec.rb b/spec/models/bulk_imports/export_spec.rb index d85b77d599b..7173d032bc2 100644 --- a/spec/models/bulk_imports/export_spec.rb +++ b/spec/models/bulk_imports/export_spec.rb @@ -2,11 +2,12 @@ require 'spec_helper' -RSpec.describe BulkImports::Export, type: :model do +RSpec.describe BulkImports::Export, type: :model, feature_category: :importers do describe 'associations' do it { is_expected.to belong_to(:group) } it { is_expected.to belong_to(:project) } it { is_expected.to have_one(:upload) } + it { is_expected.to have_many(:batches) } end describe 'validations' do diff --git a/spec/models/bulk_imports/file_transfer/group_config_spec.rb b/spec/models/bulk_imports/file_transfer/group_config_spec.rb index 8660114b719..e50f52c728f 100644 --- a/spec/models/bulk_imports/file_transfer/group_config_spec.rb +++ b/spec/models/bulk_imports/file_transfer/group_config_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe BulkImports::FileTransfer::GroupConfig do +RSpec.describe BulkImports::FileTransfer::GroupConfig, feature_category: :importers do let_it_be(:exportable) { create(:group) } let_it_be(:hex) { '123' } @@ -49,4 +49,51 @@ RSpec.describe BulkImports::FileTransfer::GroupConfig do expect(subject.relation_excluded_keys('group')).to include('owner_id') end end + + describe '#batchable_relation?' do + context 'when relation is batchable' do + it 'returns true' do + expect(subject.batchable_relation?('labels')).to eq(true) + end + end + + context 'when relation is not batchable' do + it 'returns false' do + expect(subject.batchable_relation?('namespace_settings')).to eq(false) + end + end + + context 'when relation is not listed as portable' do + it 'returns false' do + expect(subject.batchable_relation?('foo')).to eq(false) + end + end + end + + describe '#batchable_relations' do + it 'returns a list of collection associations for a group' do + expect(subject.batchable_relations).to include('labels', 'boards', 'milestones') + expect(subject.batchable_relations).not_to include('namespace_settings') + end + end + + describe '#export_service_for' do + context 'when relation is a tree' do + it 'returns TreeExportService' do + expect(subject.export_service_for('labels')).to eq(BulkImports::TreeExportService) + end + end + + context 'when relation is a file' do + it 'returns FileExportService' do + expect(subject.export_service_for('uploads')).to eq(BulkImports::FileExportService) + end + end + + context 'when relation is unknown' do + it 'raises' do + expect { subject.export_service_for('foo') }.to raise_error(BulkImports::Error, 'Unsupported export relation') + end + end + end end diff --git a/spec/models/bulk_imports/file_transfer/project_config_spec.rb b/spec/models/bulk_imports/file_transfer/project_config_spec.rb index 0f02c5c546f..014f624165c 100644 --- a/spec/models/bulk_imports/file_transfer/project_config_spec.rb +++ b/spec/models/bulk_imports/file_transfer/project_config_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe BulkImports::FileTransfer::ProjectConfig do +RSpec.describe BulkImports::FileTransfer::ProjectConfig, feature_category: :importers do let_it_be(:exportable) { create(:project) } let_it_be(:hex) { '123' } @@ -42,6 +42,18 @@ RSpec.describe BulkImports::FileTransfer::ProjectConfig do it 'returns relation tree of a top level relation' do expect(subject.top_relation_tree('labels')).to eq('priorities' => {}) end + + it 'returns relation tree with merged with deprecated tree' do + expect(subject.top_relation_tree('ci_pipelines')).to match( + a_hash_including( + { + 'external_pull_request' => {}, + 'merge_request' => {}, + 'stages' => { 'bridges' => {}, 'builds' => {}, 'generic_commit_statuses' => {}, 'statuses' => {} } + } + ) + ) + end end describe '#relation_excluded_keys' do @@ -97,4 +109,31 @@ RSpec.describe BulkImports::FileTransfer::ProjectConfig do expect(subject.file_relations).to contain_exactly('uploads', 'lfs_objects', 'repository', 'design') end end + + describe '#batchable_relation?' do + context 'when relation is batchable' do + it 'returns true' do + expect(subject.batchable_relation?('issues')).to eq(true) + end + end + + context 'when relation is not batchable' do + it 'returns false' do + expect(subject.batchable_relation?('project_feature')).to eq(false) + end + end + + context 'when relation is not listed as portable' do + it 'returns false' do + expect(subject.batchable_relation?('foo')).to eq(false) + end + end + end + + describe '#batchable_relations' do + it 'returns a list of collection associations for a project' do + expect(subject.batchable_relations).to include('issues', 'merge_requests', 'milestones') + expect(subject.batchable_relations).not_to include('project_feature', 'ci_cd_settings') + end + end end diff --git a/spec/models/bulk_imports/tracker_spec.rb b/spec/models/bulk_imports/tracker_spec.rb index 1516ab106cb..a618a12df6b 100644 --- a/spec/models/bulk_imports/tracker_spec.rb +++ b/spec/models/bulk_imports/tracker_spec.rb @@ -4,7 +4,10 @@ require 'spec_helper' RSpec.describe BulkImports::Tracker, type: :model do describe 'associations' do - it { is_expected.to belong_to(:entity).required } + it do + is_expected.to belong_to(:entity).required.class_name('BulkImports::Entity') + .with_foreign_key(:bulk_import_entity_id).inverse_of(:trackers) + end end describe 'validations' do diff --git a/spec/models/chat_name_spec.rb b/spec/models/chat_name_spec.rb index 0838c232872..9d6b1a56458 100644 --- a/spec/models/chat_name_spec.rb +++ b/spec/models/chat_name_spec.rb @@ -7,7 +7,6 @@ RSpec.describe ChatName, feature_category: :integrations do subject { chat_name } - it { is_expected.to belong_to(:integration) } it { is_expected.to belong_to(:user) } it { is_expected.to validate_presence_of(:user) } @@ -16,12 +15,6 @@ RSpec.describe ChatName, feature_category: :integrations do it { is_expected.to validate_uniqueness_of(:chat_id).scoped_to(:team_id) } - it 'is not removed when the project is deleted' do - expect { subject.reload.integration.project.delete }.not_to change { ChatName.count } - - expect(ChatName.where(id: subject.id)).to exist - end - describe '#update_last_used_at', :clean_gitlab_redis_shared_state do it 'updates the last_used_at timestamp' do expect(subject.last_used_at).to be_nil diff --git a/spec/models/ci/bridge_spec.rb b/spec/models/ci/bridge_spec.rb index 7b307de87c7..ac994735928 100644 --- a/spec/models/ci/bridge_spec.rb +++ b/spec/models/ci/bridge_spec.rb @@ -12,9 +12,7 @@ RSpec.describe Ci::Bridge, feature_category: :continuous_integration do end let(:bridge) do - create(:ci_bridge, :variables, status: :created, - options: options, - pipeline: pipeline) + create(:ci_bridge, :variables, status: :created, options: options, pipeline: pipeline) end let(:options) do @@ -40,16 +38,6 @@ RSpec.describe Ci::Bridge, feature_category: :continuous_integration do it 'returns true' do expect(bridge.retryable?).to eq(true) end - - context 'without ci_recreate_downstream_pipeline ff' do - before do - stub_feature_flags(ci_recreate_downstream_pipeline: false) - end - - it 'returns false' do - expect(bridge.retryable?).to eq(false) - end - end end context 'when there is a pipeline loop detected' do @@ -172,7 +160,9 @@ RSpec.describe Ci::Bridge, feature_category: :continuous_integration do where(:downstream_status, :upstream_status) do [ %w[success success], - *::Ci::Pipeline.completed_statuses.without(:success).map { |status| [status.to_s, 'failed'] } + %w[canceled canceled], + %w[failed failed], + %w[skipped failed] ] end @@ -564,11 +554,13 @@ RSpec.describe Ci::Bridge, feature_category: :continuous_integration do let!(:prepare2) { create(:ci_build, name: 'prepare2', pipeline: pipeline, stage_idx: 0) } let!(:prepare3) { create(:ci_build, name: 'prepare3', pipeline: pipeline, stage_idx: 0) } let!(:bridge) do - create(:ci_bridge, pipeline: pipeline, - stage_idx: 1, - scheduling_type: 'dag', - needs_attributes: [{ name: 'prepare1', artifacts: true }, - { name: 'prepare2', artifacts: false }]) + create( + :ci_bridge, + pipeline: pipeline, + stage_idx: 1, + scheduling_type: 'dag', + needs_attributes: [{ name: 'prepare1', artifacts: true }, { name: 'prepare2', artifacts: false }] + ) end let!(:job_variable_1) { create(:ci_job_variable, :dotenv_source, job: prepare1) } @@ -581,7 +573,7 @@ RSpec.describe Ci::Bridge, feature_category: :continuous_integration do end end - describe 'metadata partitioning', :ci_partitioning do + describe 'metadata partitioning', :ci_partitionable do let(:pipeline) { create(:ci_pipeline, project: project, partition_id: ci_testing_partition_id) } let(:bridge) do diff --git a/spec/models/ci/build_dependencies_spec.rb b/spec/models/ci/build_dependencies_spec.rb index 1dd0386060d..0709aa47ff1 100644 --- a/spec/models/ci/build_dependencies_spec.rb +++ b/spec/models/ci/build_dependencies_spec.rb @@ -7,10 +7,13 @@ RSpec.describe Ci::BuildDependencies do let_it_be(:project, reload: true) { create(:project, :repository) } let_it_be(:pipeline, reload: true) do - create(:ci_pipeline, project: project, - sha: project.commit.id, - ref: project.default_branch, - status: 'success') + create( + :ci_pipeline, + project: project, + sha: project.commit.id, + ref: project.default_branch, + status: 'success' + ) end let(:build_stage) { create(:ci_stage, name: 'build', pipeline: pipeline) } diff --git a/spec/models/ci/build_metadata_spec.rb b/spec/models/ci/build_metadata_spec.rb index fb50ba89cd3..8ed0e50e4b0 100644 --- a/spec/models/ci/build_metadata_spec.rb +++ b/spec/models/ci/build_metadata_spec.rb @@ -7,10 +7,13 @@ RSpec.describe Ci::BuildMetadata do let_it_be(:group) { create(:group) } let_it_be(:project) { create(:project, :repository, group: group, build_timeout: 2000) } let_it_be(:pipeline) do - create(:ci_pipeline, project: project, - sha: project.commit.id, - ref: project.default_branch, - status: 'success') + create( + :ci_pipeline, + project: project, + sha: project.commit.id, + ref: project.default_branch, + status: 'success' + ) end let_it_be_with_reload(:runner) { create(:ci_runner) } @@ -22,7 +25,6 @@ RSpec.describe Ci::BuildMetadata do it { is_expected.to belong_to(:build) } it { is_expected.to belong_to(:project) } - it { is_expected.to belong_to(:runner_machine) } describe '#update_timeout_state' do subject { metadata } diff --git a/spec/models/ci/build_need_spec.rb b/spec/models/ci/build_need_spec.rb index aa1c57d1788..e46a2b8cf85 100644 --- a/spec/models/ci/build_need_spec.rb +++ b/spec/models/ci/build_need_spec.rb @@ -74,7 +74,7 @@ RSpec.describe Ci::BuildNeed, model: true, feature_category: :continuous_integra stub_current_partition_id end - it 'creates build needs successfully', :aggregate_failures do + it 'creates build needs successfully', :aggregate_failures, :ci_partitionable do ci_build.needs_attributes = [ { name: "build", artifacts: true }, { name: "build2", artifacts: true }, diff --git a/spec/models/ci/build_pending_state_spec.rb b/spec/models/ci/build_pending_state_spec.rb index bff0b35f878..c978e3dba36 100644 --- a/spec/models/ci/build_pending_state_spec.rb +++ b/spec/models/ci/build_pending_state_spec.rb @@ -3,10 +3,15 @@ require 'spec_helper' RSpec.describe Ci::BuildPendingState, feature_category: :continuous_integration do + describe 'associations' do + it do + is_expected.to belong_to(:build).class_name('Ci::Build').with_foreign_key(:build_id).inverse_of(:pending_state) + end + end + describe 'validations' do subject(:pending_state) { build(:ci_build_pending_state) } - it { is_expected.to belong_to(:build) } it { is_expected.to validate_presence_of(:build) } end diff --git a/spec/models/ci/build_report_result_spec.rb b/spec/models/ci/build_report_result_spec.rb index 90b23d3e824..90426f60c73 100644 --- a/spec/models/ci/build_report_result_spec.rb +++ b/spec/models/ci/build_report_result_spec.rb @@ -33,6 +33,19 @@ RSpec.describe Ci::BuildReportResult do expect(build_report_result.errors.full_messages).to eq(["Data must be a valid json schema"]) end end + + context 'when data tests is invalid' do + it 'returns errors' do + build_report_result.data = { + 'tests' => { + 'invalid' => 'invalid' + } + } + + expect(build_report_result).to be_invalid + expect(build_report_result.errors.full_messages).to eq(["Data must be a valid json schema"]) + end + end end describe '#tests_name' do diff --git a/spec/models/ci/build_runner_session_spec.rb b/spec/models/ci/build_runner_session_spec.rb index 5e1a489ed8b..002aff25593 100644 --- a/spec/models/ci/build_runner_session_spec.rb +++ b/spec/models/ci/build_runner_session_spec.rb @@ -175,7 +175,7 @@ RSpec.describe Ci::BuildRunnerSession, model: true, feature_category: :continuou end end - describe 'partitioning' do + describe 'partitioning', :ci_partitionable do include Ci::PartitioningHelpers let(:new_pipeline) { create(:ci_pipeline) } diff --git a/spec/models/ci/build_spec.rb b/spec/models/ci/build_spec.rb index 80d6693e08e..e3e78acb7e5 100644 --- a/spec/models/ci/build_spec.rb +++ b/spec/models/ci/build_spec.rb @@ -11,10 +11,13 @@ RSpec.describe Ci::Build, feature_category: :continuous_integration, factory_def let_it_be(:project, reload: true) { create_default(:project, :repository, group: group) } let_it_be(:pipeline, reload: true) do - create_default(:ci_pipeline, project: project, - sha: project.commit.id, - ref: project.default_branch, - status: 'success') + create_default( + :ci_pipeline, + project: project, + sha: project.commit.id, + ref: project.default_branch, + status: 'success' + ) end let_it_be(:build, refind: true) { create(:ci_build, pipeline: pipeline) } @@ -22,20 +25,35 @@ RSpec.describe Ci::Build, feature_category: :continuous_integration, factory_def it { is_expected.to belong_to(:runner) } it { is_expected.to belong_to(:trigger_request) } it { is_expected.to belong_to(:erased_by) } + it { is_expected.to belong_to(:pipeline).inverse_of(:builds) } it { is_expected.to have_many(:needs).with_foreign_key(:build_id) } - it { is_expected.to have_many(:sourced_pipelines).with_foreign_key(:source_job_id) } - it { is_expected.to have_one(:sourced_pipeline).with_foreign_key(:source_job_id) } + + it do + is_expected.to have_many(:sourced_pipelines).class_name('Ci::Sources::Pipeline').with_foreign_key(:source_job_id) + .inverse_of(:build) + end + it { is_expected.to have_many(:job_variables).with_foreign_key(:job_id) } it { is_expected.to have_many(:report_results).with_foreign_key(:build_id) } it { is_expected.to have_many(:pages_deployments).with_foreign_key(:ci_build_id) } it { is_expected.to have_one(:deployment) } - it { is_expected.to have_one(:runner_machine).through(:metadata) } + it { is_expected.to have_one(:runner_manager).through(:runner_manager_build) } it { is_expected.to have_one(:runner_session).with_foreign_key(:build_id) } it { is_expected.to have_one(:trace_metadata).with_foreign_key(:build_id) } it { is_expected.to have_one(:runtime_metadata).with_foreign_key(:build_id) } - it { is_expected.to have_one(:pending_state).with_foreign_key(:build_id) } + it { is_expected.to have_one(:pending_state).with_foreign_key(:build_id).inverse_of(:build) } + + it do + is_expected.to have_one(:queuing_entry).class_name('Ci::PendingBuild').with_foreign_key(:build_id).inverse_of(:build) + end + + it do + is_expected.to have_one(:runtime_metadata).class_name('Ci::RunningBuild').with_foreign_key(:build_id) + .inverse_of(:build) + end + it { is_expected.to have_many(:terraform_state_versions).inverse_of(:build).with_foreign_key(:ci_build_id) } it { is_expected.to validate_presence_of(:ref) } @@ -1040,7 +1058,7 @@ RSpec.describe Ci::Build, feature_category: :continuous_integration, factory_def end context 'non public artifacts' do - let(:build) { create(:ci_build, :artifacts, :non_public_artifacts, pipeline: pipeline) } + let(:build) { create(:ci_build, :artifacts, :with_private_artifacts_config, pipeline: pipeline) } it { is_expected.to be_falsey } end @@ -1134,6 +1152,12 @@ RSpec.describe Ci::Build, feature_category: :continuous_integration, factory_def { cache: [{ key: "key", paths: ["public"], policy: "pull-push" }] } end + let(:options_with_fallback_keys) do + { cache: [ + { key: "key", paths: ["public"], policy: "pull-push", fallback_keys: %w(key1 key2) } + ] } + end + subject { build.cache } context 'when build has cache' do @@ -1149,6 +1173,13 @@ RSpec.describe Ci::Build, feature_category: :continuous_integration, factory_def ] } end + let(:options_with_fallback_keys) do + { cache: [ + { key: "key", paths: ["public"], policy: "pull-push", fallback_keys: %w(key3 key4) }, + { key: "key2", paths: ["public"], policy: "pull-push", fallback_keys: %w(key5 key6) } + ] } + end + before do allow_any_instance_of(Project).to receive(:jobs_cache_index).and_return(1) end @@ -1160,8 +1191,21 @@ RSpec.describe Ci::Build, feature_category: :continuous_integration, factory_def allow(build.pipeline).to receive(:protected_ref?).and_return(true) end - it do - is_expected.to all(a_hash_including(key: a_string_matching(/-protected$/))) + context 'without the `unprotect` option' do + it do + is_expected.to all(a_hash_including(key: a_string_matching(/-protected$/))) + end + + context 'and the caches have fallback keys' do + let(:options) { options_with_fallback_keys } + + it do + is_expected.to all(a_hash_including({ + key: a_string_matching(/-protected$/), + fallback_keys: array_including(a_string_matching(/-protected$/)) + })) + end + end end context 'and the cache has the `unprotect` option' do @@ -1175,6 +1219,20 @@ RSpec.describe Ci::Build, feature_category: :continuous_integration, factory_def it do is_expected.to all(a_hash_including(key: a_string_matching(/-non_protected$/))) end + + context 'and the caches have fallback keys' do + let(:options) do + options_with_fallback_keys[:cache].each { |entry| entry[:unprotect] = true } + options_with_fallback_keys + end + + it do + is_expected.to all(a_hash_including({ + key: a_string_matching(/-non_protected$/), + fallback_keys: array_including(a_string_matching(/-non_protected$/)) + })) + end + end end end @@ -1186,6 +1244,17 @@ RSpec.describe Ci::Build, feature_category: :continuous_integration, factory_def it do is_expected.to all(a_hash_including(key: a_string_matching(/-non_protected$/))) end + + context 'and the caches have fallback keys' do + let(:options) { options_with_fallback_keys } + + it do + is_expected.to all(a_hash_including({ + key: a_string_matching(/-non_protected$/), + fallback_keys: array_including(a_string_matching(/-non_protected$/)) + })) + end + end end context 'when separated caches are disabled' do @@ -1201,6 +1270,23 @@ RSpec.describe Ci::Build, feature_category: :continuous_integration, factory_def it 'is expected to have no type suffix' do is_expected.to match([a_hash_including(key: 'key-1'), a_hash_including(key: 'key2-1')]) end + + context 'and the caches have fallback keys' do + let(:options) { options_with_fallback_keys } + + it do + is_expected.to match([ + a_hash_including({ + key: 'key-1', + fallback_keys: %w(key3-1 key4-1) + }), + a_hash_including({ + key: 'key2-1', + fallback_keys: %w(key5-1 key6-1) + }) + ]) + end + end end context 'running on not protected ref' do @@ -1211,6 +1297,23 @@ RSpec.describe Ci::Build, feature_category: :continuous_integration, factory_def it 'is expected to have no type suffix' do is_expected.to match([a_hash_including(key: 'key-1'), a_hash_including(key: 'key2-1')]) end + + context 'and the caches have fallback keys' do + let(:options) { options_with_fallback_keys } + + it do + is_expected.to match([ + a_hash_including({ + key: 'key-1', + fallback_keys: %w(key3-1 key4-1) + }), + a_hash_including({ + key: 'key2-1', + fallback_keys: %w(key5-1 key6-1) + }) + ]) + end + end end end end @@ -1221,6 +1324,17 @@ RSpec.describe Ci::Build, feature_category: :continuous_integration, factory_def end it { is_expected.to be_an(Array).and all(include(key: a_string_matching(/^key-1-(?>protected|non_protected)/))) } + + context 'and the cache have fallback keys' do + let(:options) { options_with_fallback_keys } + + it do + is_expected.to be_an(Array).and all(include({ + key: a_string_matching(/^key-1-(?>protected|non_protected)/), + fallback_keys: array_including(a_string_matching(/^key\d-1-(?>protected|non_protected)/)) + })) + end + end end context 'when project does not have jobs_cache_index' do @@ -1231,6 +1345,21 @@ RSpec.describe Ci::Build, feature_category: :continuous_integration, factory_def it do is_expected.to eq(options[:cache].map { |entry| entry.merge(key: "#{entry[:key]}-non_protected") }) end + + context 'and the cache have fallback keys' do + let(:options) { options_with_fallback_keys } + + it do + is_expected.to eq( + options[:cache].map do |entry| + entry[:key] = "#{entry[:key]}-non_protected" + entry[:fallback_keys].map! { |key| "#{key}-non_protected" } + + entry + end + ) + end + end end end @@ -1243,6 +1372,29 @@ RSpec.describe Ci::Build, feature_category: :continuous_integration, factory_def end end + describe '#fallback_cache_keys_defined?' do + subject { build } + + it 'returns false when fallback keys are not defined' do + expect(subject.fallback_cache_keys_defined?).to be false + end + + context "with fallbacks keys" do + before do + allow(build).to receive(:options).and_return({ + cache: [{ + key: "key1", + fallback_keys: %w(key2) + }] + }) + end + + it 'returns true when fallback keys are defined' do + expect(subject.fallback_cache_keys_defined?).to be true + end + end + end + describe '#triggered_by?' do subject { build.triggered_by?(user) } @@ -1422,11 +1574,7 @@ RSpec.describe Ci::Build, feature_category: :continuous_integration, factory_def let(:build) { create(:ci_build, trait, pipeline: pipeline) } let(:event) { state } - context "when transitioning to #{params[:state]}" do - before do - allow(Gitlab).to receive(:com?).and_return(true) - end - + context "when transitioning to #{params[:state]}", :saas do it 'increments build_completed_report_type metric' do expect( ::Gitlab::Ci::Artifacts::Metrics @@ -1463,7 +1611,7 @@ RSpec.describe Ci::Build, feature_category: :continuous_integration, factory_def ActiveRecord::QueryRecorder.new { subject } end - index_for_build = recorded.log.index { |l| l.include?("UPDATE \"ci_builds\"") } + index_for_build = recorded.log.index { |l| l.include?("UPDATE #{described_class.quoted_table_name}") } index_for_deployment = recorded.log.index { |l| l.include?("UPDATE \"deployments\"") } expect(index_for_build).to be < index_for_deployment @@ -1688,10 +1836,12 @@ RSpec.describe Ci::Build, feature_category: :continuous_integration, factory_def context 'when environment uses $CI_COMMIT_REF_NAME' do let(:build) do - create(:ci_build, - ref: 'master', - environment: 'review/$CI_COMMIT_REF_NAME', - pipeline: pipeline) + create( + :ci_build, + ref: 'master', + environment: 'review/$CI_COMMIT_REF_NAME', + pipeline: pipeline + ) end it { is_expected.to eq('review/master') } @@ -1699,10 +1849,12 @@ RSpec.describe Ci::Build, feature_category: :continuous_integration, factory_def context 'when environment uses yaml_variables containing symbol keys' do let(:build) do - create(:ci_build, - yaml_variables: [{ key: :APP_HOST, value: 'host' }], - environment: 'review/$APP_HOST', - pipeline: pipeline) + create( + :ci_build, + yaml_variables: [{ key: :APP_HOST, value: 'host' }], + environment: 'review/$APP_HOST', + pipeline: pipeline + ) end it 'returns an expanded environment name with a list of variables' do @@ -1724,12 +1876,26 @@ RSpec.describe Ci::Build, feature_category: :continuous_integration, factory_def context 'when using persisted variables' do let(:build) do - create(:ci_build, environment: 'review/x$CI_BUILD_ID', pipeline: pipeline) + create(:ci_build, environment: 'review/x$CI_JOB_ID', pipeline: pipeline) end it { is_expected.to eq('review/x') } end + context 'when FF `ci_remove_legacy_predefined_variables` is disabled' do + before do + stub_feature_flags(ci_remove_legacy_predefined_variables: false) + end + + context 'when using persisted variables' do + let(:build) do + create(:ci_build, environment: 'review/x$CI_BUILD_ID', pipeline: pipeline) + end + + it { is_expected.to eq('review/x') } + end + end + context 'when environment name uses a nested variable' do let(:yaml_variables) do [ @@ -1738,11 +1904,13 @@ RSpec.describe Ci::Build, feature_category: :continuous_integration, factory_def end let(:build) do - create(:ci_build, - ref: 'master', - yaml_variables: yaml_variables, - environment: 'review/$ENVIRONMENT_NAME', - pipeline: pipeline) + create( + :ci_build, + ref: 'master', + yaml_variables: yaml_variables, + environment: 'review/$ENVIRONMENT_NAME', + pipeline: pipeline + ) end it { is_expected.to eq('review/master') } @@ -1926,62 +2094,6 @@ RSpec.describe Ci::Build, feature_category: :continuous_integration, factory_def end end - describe '#failed_but_allowed?' do - subject { build.failed_but_allowed? } - - context 'when build is not allowed to fail' do - before do - build.allow_failure = false - end - - context 'and build.status is success' do - before do - build.status = 'success' - end - - it { is_expected.to be_falsey } - end - - context 'and build.status is failed' do - before do - build.status = 'failed' - end - - it { is_expected.to be_falsey } - end - end - - context 'when build is allowed to fail' do - before do - build.allow_failure = true - end - - context 'and build.status is success' do - before do - build.status = 'success' - end - - it { is_expected.to be_falsey } - end - - context 'and build status is failed' do - before do - build.status = 'failed' - end - - it { is_expected.to be_truthy } - end - - context 'when build is a manual action' do - before do - build.status = 'manual' - end - - it { is_expected.to be_falsey } - end - end - end - describe 'flags' do describe '#cancelable?' do subject { build } @@ -2058,14 +2170,14 @@ RSpec.describe Ci::Build, feature_category: :continuous_integration, factory_def end end - describe '#runner_machine' do + describe '#runner_manager' do let_it_be(:runner) { create(:ci_runner) } - let_it_be(:runner_machine) { create(:ci_runner_machine, runner: runner) } - let_it_be(:build) { create(:ci_build, runner_machine: runner_machine) } + let_it_be(:runner_manager) { create(:ci_runner_machine, runner: runner) } + let_it_be(:build) { create(:ci_build, runner_manager: runner_manager) } - subject(:build_runner_machine) { described_class.find(build.id).runner_machine } + subject(:build_runner_manager) { described_class.find(build.id).runner_manager } - it { is_expected.to eq(runner_machine) } + it { is_expected.to eq(runner_manager) } end describe '#tag_list' do @@ -2130,8 +2242,14 @@ RSpec.describe Ci::Build, feature_category: :continuous_integration, factory_def describe 'build auto retry feature' do context 'with deployment job' do let(:build) do - create(:ci_build, :deploy_to_production, :with_deployment, - user: user, pipeline: pipeline, project: project) + create( + :ci_build, + :deploy_to_production, + :with_deployment, + user: user, + pipeline: pipeline, + project: project + ) end before do @@ -2762,6 +2880,89 @@ RSpec.describe Ci::Build, feature_category: :continuous_integration, factory_def { key: 'CI_JOB_URL', value: project.web_url + "/-/jobs/#{build.id}", public: true, masked: false }, { key: 'CI_JOB_TOKEN', value: 'my-token', public: false, masked: true }, { key: 'CI_JOB_STARTED_AT', value: build.started_at&.iso8601, public: true, masked: false }, + { key: 'CI_REGISTRY_USER', value: 'gitlab-ci-token', public: true, masked: false }, + { key: 'CI_REGISTRY_PASSWORD', value: 'my-token', public: false, masked: true }, + { key: 'CI_REPOSITORY_URL', value: build.repo_url, public: false, masked: false }, + { key: 'CI_DEPENDENCY_PROXY_USER', value: 'gitlab-ci-token', public: true, masked: false }, + { key: 'CI_DEPENDENCY_PROXY_PASSWORD', value: 'my-token', public: false, masked: true }, + { key: 'CI_JOB_JWT', value: 'ci.job.jwt', public: false, masked: true }, + { key: 'CI_JOB_JWT_V1', value: 'ci.job.jwt', public: false, masked: true }, + { key: 'CI_JOB_JWT_V2', value: 'ci.job.jwtv2', public: false, masked: true }, + { key: 'CI_JOB_NAME', value: 'test', public: true, masked: false }, + { key: 'CI_JOB_NAME_SLUG', value: 'test', public: true, masked: false }, + { key: 'CI_JOB_STAGE', value: 'test', public: true, masked: false }, + { key: 'CI_NODE_TOTAL', value: '1', public: true, masked: false }, + { key: 'CI', value: 'true', public: true, masked: false }, + { key: 'GITLAB_CI', value: 'true', public: true, masked: false }, + { key: 'CI_SERVER_URL', value: Gitlab.config.gitlab.url, public: true, masked: false }, + { key: 'CI_SERVER_HOST', value: Gitlab.config.gitlab.host, public: true, masked: false }, + { key: 'CI_SERVER_PORT', value: Gitlab.config.gitlab.port.to_s, public: true, masked: false }, + { key: 'CI_SERVER_PROTOCOL', value: Gitlab.config.gitlab.protocol, public: true, masked: false }, + { key: 'CI_SERVER_SHELL_SSH_HOST', value: Gitlab.config.gitlab_shell.ssh_host.to_s, public: true, masked: false }, + { key: 'CI_SERVER_SHELL_SSH_PORT', value: Gitlab.config.gitlab_shell.ssh_port.to_s, public: true, masked: false }, + { key: 'CI_SERVER_NAME', value: 'GitLab', public: true, masked: false }, + { key: 'CI_SERVER_VERSION', value: Gitlab::VERSION, public: true, masked: false }, + { key: 'CI_SERVER_VERSION_MAJOR', value: Gitlab.version_info.major.to_s, public: true, masked: false }, + { key: 'CI_SERVER_VERSION_MINOR', value: Gitlab.version_info.minor.to_s, public: true, masked: false }, + { key: 'CI_SERVER_VERSION_PATCH', value: Gitlab.version_info.patch.to_s, public: true, masked: false }, + { key: 'CI_SERVER_REVISION', value: Gitlab.revision, public: true, masked: false }, + { key: 'GITLAB_FEATURES', value: project.licensed_features.join(','), public: true, masked: false }, + { key: 'CI_PROJECT_ID', value: project.id.to_s, public: true, masked: false }, + { key: 'CI_PROJECT_NAME', value: project.path, public: true, masked: false }, + { key: 'CI_PROJECT_TITLE', value: project.title, public: true, masked: false }, + { key: 'CI_PROJECT_DESCRIPTION', value: project.description, public: true, masked: false }, + { key: 'CI_PROJECT_PATH', value: project.full_path, public: true, masked: false }, + { key: 'CI_PROJECT_PATH_SLUG', value: project.full_path_slug, public: true, masked: false }, + { key: 'CI_PROJECT_NAMESPACE', value: project.namespace.full_path, public: true, masked: false }, + { key: 'CI_PROJECT_NAMESPACE_ID', value: project.namespace.id.to_s, public: true, masked: false }, + { key: 'CI_PROJECT_ROOT_NAMESPACE', value: project.namespace.root_ancestor.path, public: true, masked: false }, + { key: 'CI_PROJECT_URL', value: project.web_url, public: true, masked: false }, + { key: 'CI_PROJECT_VISIBILITY', value: 'private', public: true, masked: false }, + { key: 'CI_PROJECT_REPOSITORY_LANGUAGES', value: project.repository_languages.map(&:name).join(',').downcase, public: true, masked: false }, + { key: 'CI_PROJECT_CLASSIFICATION_LABEL', value: project.external_authorization_classification_label, public: true, masked: false }, + { key: 'CI_DEFAULT_BRANCH', value: project.default_branch, public: true, masked: false }, + { key: 'CI_CONFIG_PATH', value: project.ci_config_path_or_default, public: true, masked: false }, + { key: 'CI_PAGES_DOMAIN', value: Gitlab.config.pages.host, public: true, masked: false }, + { key: 'CI_PAGES_URL', value: project.pages_url, public: true, masked: false }, + { key: 'CI_DEPENDENCY_PROXY_SERVER', value: Gitlab.host_with_port, public: true, masked: false }, + { key: 'CI_DEPENDENCY_PROXY_GROUP_IMAGE_PREFIX', + value: "#{Gitlab.host_with_port}/#{project.namespace.root_ancestor.path.downcase}#{DependencyProxy::URL_SUFFIX}", + public: true, + masked: false }, + { key: 'CI_DEPENDENCY_PROXY_DIRECT_GROUP_IMAGE_PREFIX', + value: "#{Gitlab.host_with_port}/#{project.namespace.full_path.downcase}#{DependencyProxy::URL_SUFFIX}", + public: true, + masked: false }, + { key: 'CI_API_V4_URL', value: 'http://localhost/api/v4', public: true, masked: false }, + { key: 'CI_API_GRAPHQL_URL', value: 'http://localhost/api/graphql', public: true, masked: false }, + { key: 'CI_TEMPLATE_REGISTRY_HOST', value: template_registry_host, public: true, masked: false }, + { key: 'CI_PIPELINE_IID', value: pipeline.iid.to_s, public: true, masked: false }, + { key: 'CI_PIPELINE_SOURCE', value: pipeline.source, public: true, masked: false }, + { key: 'CI_PIPELINE_CREATED_AT', value: pipeline.created_at.iso8601, public: true, masked: false }, + { key: 'CI_COMMIT_SHA', value: build.sha, public: true, masked: false }, + { key: 'CI_COMMIT_SHORT_SHA', value: build.short_sha, public: true, masked: false }, + { key: 'CI_COMMIT_BEFORE_SHA', value: build.before_sha, public: true, masked: false }, + { key: 'CI_COMMIT_REF_NAME', value: build.ref, public: true, masked: false }, + { key: 'CI_COMMIT_REF_SLUG', value: build.ref_slug, public: true, masked: false }, + { key: 'CI_COMMIT_BRANCH', value: build.ref, public: true, masked: false }, + { key: 'CI_COMMIT_MESSAGE', value: pipeline.git_commit_message, public: true, masked: false }, + { key: 'CI_COMMIT_TITLE', value: pipeline.git_commit_title, public: true, masked: false }, + { key: 'CI_COMMIT_DESCRIPTION', value: pipeline.git_commit_description, public: true, masked: false }, + { key: 'CI_COMMIT_REF_PROTECTED', value: (!!pipeline.protected_ref?).to_s, public: true, masked: false }, + { key: 'CI_COMMIT_TIMESTAMP', value: pipeline.git_commit_timestamp, public: true, masked: false }, + { key: 'CI_COMMIT_AUTHOR', value: pipeline.git_author_full_text, public: true, masked: false } + ] + end + + # Remove this definition when FF `ci_remove_legacy_predefined_variables` is removed + let(:predefined_with_legacy_variables) do + [ + { key: 'CI_PIPELINE_ID', value: pipeline.id.to_s, public: true, masked: false }, + { key: 'CI_PIPELINE_URL', value: project.web_url + "/-/pipelines/#{pipeline.id}", public: true, masked: false }, + { key: 'CI_JOB_ID', value: build.id.to_s, public: true, masked: false }, + { key: 'CI_JOB_URL', value: project.web_url + "/-/jobs/#{build.id}", public: true, masked: false }, + { key: 'CI_JOB_TOKEN', value: 'my-token', public: false, masked: true }, + { key: 'CI_JOB_STARTED_AT', value: build.started_at&.iso8601, public: true, masked: false }, { key: 'CI_BUILD_ID', value: build.id.to_s, public: true, masked: false }, { key: 'CI_BUILD_TOKEN', value: 'my-token', public: false, masked: true }, { key: 'CI_REGISTRY_USER', value: 'gitlab-ci-token', public: true, masked: false }, @@ -2784,6 +2985,8 @@ RSpec.describe Ci::Build, feature_category: :continuous_integration, factory_def { key: 'CI_SERVER_HOST', value: Gitlab.config.gitlab.host, public: true, masked: false }, { key: 'CI_SERVER_PORT', value: Gitlab.config.gitlab.port.to_s, public: true, masked: false }, { key: 'CI_SERVER_PROTOCOL', value: Gitlab.config.gitlab.protocol, public: true, masked: false }, + { key: 'CI_SERVER_SHELL_SSH_HOST', value: Gitlab.config.gitlab_shell.ssh_host.to_s, public: true, masked: false }, + { key: 'CI_SERVER_SHELL_SSH_PORT', value: Gitlab.config.gitlab_shell.ssh_port.to_s, public: true, masked: false }, { key: 'CI_SERVER_NAME', value: 'GitLab', public: true, masked: false }, { key: 'CI_SERVER_VERSION', value: Gitlab::VERSION, public: true, masked: false }, { key: 'CI_SERVER_VERSION_MAJOR', value: Gitlab.version_info.major.to_s, public: true, masked: false }, @@ -2818,6 +3021,7 @@ RSpec.describe Ci::Build, feature_category: :continuous_integration, factory_def public: true, masked: false }, { key: 'CI_API_V4_URL', value: 'http://localhost/api/v4', public: true, masked: false }, + { key: 'CI_API_GRAPHQL_URL', value: 'http://localhost/api/graphql', public: true, masked: false }, { key: 'CI_TEMPLATE_REGISTRY_HOST', value: template_registry_host, public: true, masked: false }, { key: 'CI_PIPELINE_IID', value: pipeline.iid.to_s, public: true, masked: false }, { key: 'CI_PIPELINE_SOURCE', value: pipeline.source, public: true, masked: false }, @@ -2851,6 +3055,14 @@ RSpec.describe Ci::Build, feature_category: :continuous_integration, factory_def it { is_expected.to be_instance_of(Gitlab::Ci::Variables::Collection) } it { expect(subject.to_runner_variables).to eq(predefined_variables) } + context 'when FF `ci_remove_legacy_predefined_variables` is disabled' do + before do + stub_feature_flags(ci_remove_legacy_predefined_variables: false) + end + + it { expect(subject.to_runner_variables).to eq(predefined_with_legacy_variables) } + end + it 'excludes variables that require an environment or user' do environment_based_variables_collection = subject.filter do |variable| %w[ @@ -2877,14 +3089,6 @@ RSpec.describe Ci::Build, feature_category: :continuous_integration, factory_def end end - context 'when the opt_in_jwt project setting is true' do - it 'does not include the JWT variables' do - project.ci_cd_settings.update!(opt_in_jwt: true) - - expect(subject.pluck(:key)).not_to include('CI_JOB_JWT', 'CI_JOB_JWT_V1', 'CI_JOB_JWT_V2') - end - end - describe 'variables ordering' do context 'when variables hierarchy is stubbed' do let(:build_pre_var) { { key: 'build', value: 'value', public: true, masked: false } } @@ -2941,16 +3145,13 @@ RSpec.describe Ci::Build, feature_category: :continuous_integration, factory_def end before do - create(:environment, project: build.project, - name: 'staging') + create(:environment, project: build.project, name: 'staging') - build.yaml_variables = [{ key: 'YAML_VARIABLE', - value: 'var', - public: true }] + build.yaml_variables = [{ key: 'YAML_VARIABLE', value: 'var', public: true }] build.environment = 'staging' # CI_ENVIRONMENT_NAME is set in predefined_variables when job environment is provided - predefined_variables.insert(20, { key: 'CI_ENVIRONMENT_NAME', value: 'staging', public: true, masked: false }) + predefined_variables.insert(18, { key: 'CI_ENVIRONMENT_NAME', value: 'staging', public: true, masked: false }) end it 'matches explicit variables ordering' do @@ -3003,6 +3204,97 @@ RSpec.describe Ci::Build, feature_category: :continuous_integration, factory_def end end end + + context 'when FF `ci_remove_legacy_predefined_variables` is disabled' do + before do + stub_feature_flags(ci_remove_legacy_predefined_variables: false) + end + + context 'when build has environment and user-provided variables' do + let(:expected_variables) do + predefined_with_legacy_variables.map { |variable| variable.fetch(:key) } + + %w[YAML_VARIABLE CI_ENVIRONMENT_NAME CI_ENVIRONMENT_SLUG + CI_ENVIRONMENT_ACTION CI_ENVIRONMENT_TIER CI_ENVIRONMENT_URL] + end + + before do + create(:environment, project: build.project, name: 'staging') + + build.yaml_variables = [{ key: 'YAML_VARIABLE', value: 'var', public: true }] + build.environment = 'staging' + + # CI_ENVIRONMENT_NAME is set in predefined_variables when job environment is provided + predefined_with_legacy_variables.insert(20, { key: 'CI_ENVIRONMENT_NAME', value: 'staging', public: true, masked: false }) + end + + it 'matches explicit variables ordering' do + received_variables = subject.map { |variable| variable[:key] } + + expect(received_variables).to eq expected_variables + end + + describe 'CI_ENVIRONMENT_ACTION' do + let(:enviroment_action_variable) { subject.find { |variable| variable[:key] == 'CI_ENVIRONMENT_ACTION' } } + + shared_examples 'defaults value' do + it 'value matches start' do + expect(enviroment_action_variable[:value]).to eq('start') + end + end + + it_behaves_like 'defaults value' + + context 'when options is set' do + before do + build.update!(options: options) + end + + context 'when options is empty' do + let(:options) { {} } + + it_behaves_like 'defaults value' + end + + context 'when options is nil' do + let(:options) { nil } + + it_behaves_like 'defaults value' + end + + context 'when options environment is specified' do + let(:options) { { environment: {} } } + + it_behaves_like 'defaults value' + end + + context 'when options environment action specified' do + let(:options) { { environment: { action: 'stop' } } } + + it 'matches the specified action' do + expect(enviroment_action_variable[:value]).to eq('stop') + end + end + end + end + end + end + end + + context 'when the build has ID tokens' do + before do + build.update!( + id_tokens: { 'TEST_ID_TOKEN' => { 'aud' => 'https://client.test' } } + ) + end + + it 'includes the tokens and excludes the predefined JWT variables' do + runner_vars = subject.to_runner_variables.pluck(:key) + + expect(runner_vars).to include('TEST_ID_TOKEN') + expect(runner_vars).not_to include('CI_JOB_JWT') + expect(runner_vars).not_to include('CI_JOB_JWT_V1') + expect(runner_vars).not_to include('CI_JOB_JWT_V2') + end end end @@ -3046,12 +3338,14 @@ RSpec.describe Ci::Build, feature_category: :continuous_integration, factory_def end let!(:environment) do - create(:environment, - project: build.project, - name: 'production', - slug: 'prod-slug', - tier: 'production', - external_url: '') + create( + :environment, + project: build.project, + name: 'production', + slug: 'prod-slug', + tier: 'production', + external_url: '' + ) end before do @@ -3184,10 +3478,13 @@ RSpec.describe Ci::Build, feature_category: :continuous_integration, factory_def let(:tag_message) { project.repository.tags.first.message } let!(:pipeline) do - create(:ci_pipeline, project: project, - sha: project.commit.id, - ref: tag_name, - status: 'success') + create( + :ci_pipeline, + project: project, + sha: project.commit.id, + ref: tag_name, + status: 'success' + ) end let!(:build) { create(:ci_build, pipeline: pipeline, ref: tag_name) } @@ -3218,8 +3515,7 @@ RSpec.describe Ci::Build, feature_category: :continuous_integration, factory_def end before do - create(:ci_variable, - ci_variable.slice(:key, :value).merge(project: project)) + create(:ci_variable, ci_variable.slice(:key, :value).merge(project: project)) end it { is_expected.to include(ci_variable) } @@ -3233,9 +3529,7 @@ RSpec.describe Ci::Build, feature_category: :continuous_integration, factory_def end before do - create(:ci_variable, - :protected, - protected_variable.slice(:key, :value).merge(project: project)) + create(:ci_variable, :protected, protected_variable.slice(:key, :value).merge(project: project)) end context 'when the branch is protected' do @@ -3265,8 +3559,7 @@ RSpec.describe Ci::Build, feature_category: :continuous_integration, factory_def end before do - create(:ci_group_variable, - ci_variable.slice(:key, :value).merge(group: group)) + create(:ci_group_variable, ci_variable.slice(:key, :value).merge(group: group)) end it { is_expected.to include(ci_variable) } @@ -3280,9 +3573,7 @@ RSpec.describe Ci::Build, feature_category: :continuous_integration, factory_def end before do - create(:ci_group_variable, - :protected, - protected_variable.slice(:key, :value).merge(group: group)) + create(:ci_group_variable, :protected, protected_variable.slice(:key, :value).merge(group: group)) end context 'when the branch is protected' do @@ -3335,9 +3626,7 @@ RSpec.describe Ci::Build, feature_category: :continuous_integration, factory_def let(:pipeline_schedule) { create(:ci_pipeline_schedule, project: project) } let!(:pipeline_schedule_variable) do - create(:ci_pipeline_schedule_variable, - key: 'SCHEDULE_VARIABLE_KEY', - pipeline_schedule: pipeline_schedule) + create(:ci_pipeline_schedule_variable, key: 'SCHEDULE_VARIABLE_KEY', pipeline_schedule: pipeline_schedule) end before do @@ -3352,10 +3641,7 @@ RSpec.describe Ci::Build, feature_category: :continuous_integration, factory_def let_it_be_with_reload(:project) { create(:project, :public, :repository, group: group) } let_it_be_with_reload(:pipeline) do - create(:ci_pipeline, project: project, - sha: project.commit.id, - ref: project.default_branch, - status: 'success') + create(:ci_pipeline, project: project, sha: project.commit.id, ref: project.default_branch, status: 'success') end let_it_be_with_refind(:build) { create(:ci_build, pipeline: pipeline) } @@ -3600,7 +3886,8 @@ RSpec.describe Ci::Build, feature_category: :continuous_integration, factory_def [ { key: 'APP_STORE_CONNECT_API_KEY_ISSUER_ID', value: apple_app_store_integration.app_store_issuer_id, masked: true, public: false }, { key: 'APP_STORE_CONNECT_API_KEY_KEY', value: Base64.encode64(apple_app_store_integration.app_store_private_key), masked: true, public: false }, - { key: 'APP_STORE_CONNECT_API_KEY_KEY_ID', value: apple_app_store_integration.app_store_key_id, masked: true, public: false } + { key: 'APP_STORE_CONNECT_API_KEY_KEY_ID', value: apple_app_store_integration.app_store_key_id, masked: true, public: false }, + { key: 'APP_STORE_CONNECT_API_KEY_IS_KEY_CONTENT_BASE64', value: "true", masked: false, public: false } ] end @@ -3626,6 +3913,7 @@ RSpec.describe Ci::Build, feature_category: :continuous_integration, factory_def expect(subject.find { |v| v[:key] == 'APP_STORE_CONNECT_API_KEY_ISSUER_ID' }).to be_nil expect(subject.find { |v| v[:key] == 'APP_STORE_CONNECT_API_KEY_KEY' }).to be_nil expect(subject.find { |v| v[:key] == 'APP_STORE_CONNECT_API_KEY_KEY_ID' }).to be_nil + expect(subject.find { |v| v[:key] == 'APP_STORE_CONNECT_API_KEY_IS_KEY_CONTENT_BASE64' }).to be_nil end end end @@ -3635,6 +3923,47 @@ RSpec.describe Ci::Build, feature_category: :continuous_integration, factory_def expect(subject.find { |v| v[:key] == 'APP_STORE_CONNECT_API_KEY_ISSUER_ID' }).to be_nil expect(subject.find { |v| v[:key] == 'APP_STORE_CONNECT_API_KEY_KEY' }).to be_nil expect(subject.find { |v| v[:key] == 'APP_STORE_CONNECT_API_KEY_KEY_ID' }).to be_nil + expect(subject.find { |v| v[:key] == 'APP_STORE_CONNECT_API_KEY_IS_KEY_CONTENT_BASE64' }).to be_nil + end + end + end + + context 'for the google_play integration' do + let_it_be(:google_play_integration) { create(:google_play_integration) } + + let(:google_play_variables) do + [ + { key: 'SUPPLY_JSON_KEY_DATA', value: google_play_integration.service_account_key, masked: true, public: false } + ] + end + + context 'when the google_play integration exists' do + context 'when a build is protected' do + before do + allow(build.pipeline).to receive(:protected_ref?).and_return(true) + build.project.update!(google_play_integration: google_play_integration) + end + + it 'includes google_play variables' do + is_expected.to include(*google_play_variables) + end + end + + context 'when a build is not protected' do + before do + allow(build.pipeline).to receive(:protected_ref?).and_return(false) + build.project.update!(google_play_integration: google_play_integration) + end + + it 'does not include the google_play variable' do + expect(subject[:key] == 'SUPPLY_JSON_KEY_DATA').to eq(false) + end + end + end + + context 'when the googel_play integration does not exist' do + it 'does not include google_play variable' do + expect(subject[:key] == 'SUPPLY_JSON_KEY_DATA').to eq(false) end end end @@ -3656,6 +3985,7 @@ RSpec.describe Ci::Build, feature_category: :continuous_integration, factory_def 'ID_TOKEN_1' => { aud: 'developers' }, 'ID_TOKEN_2' => { aud: 'maintainers' } }) + build.runner = build_stubbed(:ci_runner) end subject(:runner_vars) { build.variables.to_runner_variables } @@ -3750,8 +4080,6 @@ RSpec.describe Ci::Build, feature_category: :continuous_integration, factory_def keys = %w[CI_JOB_ID CI_JOB_URL CI_JOB_TOKEN - CI_BUILD_ID - CI_BUILD_TOKEN CI_REGISTRY_USER CI_REGISTRY_PASSWORD CI_REPOSITORY_URL @@ -3763,6 +4091,30 @@ RSpec.describe Ci::Build, feature_category: :continuous_integration, factory_def expect(names).not_to include(*keys) end end + + context 'when FF `ci_remove_legacy_predefined_variables` is disabled' do + before do + stub_feature_flags(ci_remove_legacy_predefined_variables: false) + end + + it 'does not return prohibited variables' do + keys = %w[CI_JOB_ID + CI_JOB_URL + CI_JOB_TOKEN + CI_BUILD_ID + CI_BUILD_TOKEN + CI_REGISTRY_USER + CI_REGISTRY_PASSWORD + CI_REPOSITORY_URL + CI_ENVIRONMENT_URL + CI_DEPLOY_USER + CI_DEPLOY_PASSWORD] + + build.scoped_variables.map { |env| env[:key] }.tap do |names| + expect(names).not_to include(*keys) + end + end + end end context 'with dependency variables' do @@ -5720,9 +6072,11 @@ RSpec.describe Ci::Build, feature_category: :continuous_integration, factory_def describe '#runtime_hooks' do let(:build1) do - FactoryBot.build(:ci_build, - options: { hooks: { pre_get_sources_script: ["echo 'hello pre_get_sources_script'"] } }, - pipeline: pipeline) + FactoryBot.build( + :ci_build, + options: { hooks: { pre_get_sources_script: ["echo 'hello pre_get_sources_script'"] } }, + pipeline: pipeline + ) end subject(:runtime_hooks) { build1.runtime_hooks } @@ -5784,15 +6138,9 @@ RSpec.describe Ci::Build, feature_category: :continuous_integration, factory_def expect(build.token).to be_nil expect(build.changes).to be_empty end - - it 'does not remove the token when FF is disabled' do - stub_feature_flags(remove_job_token_on_completion: false) - - expect { build.remove_token! }.not_to change(build, :token) - end end - describe 'metadata partitioning', :ci_partitioning do + describe 'metadata partitioning', :ci_partitionable do let(:pipeline) { create(:ci_pipeline, project: project, partition_id: ci_testing_partition_id) } let(:build) do @@ -5905,4 +6253,31 @@ RSpec.describe Ci::Build, feature_category: :continuous_integration, factory_def end end end + + describe 'token format for builds transiting into pending' do + let(:partition_id) { 100 } + let(:ci_build) { described_class.new(partition_id: partition_id) } + + context 'when build is initialized without a token and transits to pending' do + let(:partition_id_prefix_in_16_bit_encode) { partition_id.to_s(16) + '_' } + + it 'generates a token' do + expect { ci_build.enqueue } + .to change { ci_build.token }.from(nil).to(a_string_starting_with(partition_id_prefix_in_16_bit_encode)) + end + end + + context 'when build is initialized with a token and transits to pending' do + let(:token) { 'an_existing_secret_token' } + + before do + ci_build.set_token(token) + end + + it 'does not change the existing token' do + expect { ci_build.enqueue } + .not_to change { ci_build.token }.from(token) + end + end + end end diff --git a/spec/models/ci/build_trace_chunk_spec.rb b/spec/models/ci/build_trace_chunk_spec.rb index ac0a18a176d..355905cdabd 100644 --- a/spec/models/ci/build_trace_chunk_spec.rb +++ b/spec/models/ci/build_trace_chunk_spec.rb @@ -20,6 +20,12 @@ RSpec.describe Ci::BuildTraceChunk, :clean_gitlab_redis_shared_state, :clean_git stub_artifacts_object_storage end + describe 'associations' do + it do + is_expected.to belong_to(:build).class_name('Ci::Build').with_foreign_key(:build_id).inverse_of(:trace_chunks) + end + end + it_behaves_like 'having unique enum values' def redis_instance @@ -633,8 +639,7 @@ RSpec.describe Ci::BuildTraceChunk, :clean_gitlab_redis_shared_state, :clean_git build_trace_chunk.checksum = '12345' expect { build_trace_chunk.persist_data! } - .to raise_error(described_class::FailedToPersistDataError, - /Modifed build trace chunk detected/) + .to raise_error(described_class::FailedToPersistDataError, /Modifed build trace chunk detected/) end end diff --git a/spec/models/ci/build_trace_metadata_spec.rb b/spec/models/ci/build_trace_metadata_spec.rb index 2ab300e4054..866d94b4cbe 100644 --- a/spec/models/ci/build_trace_metadata_spec.rb +++ b/spec/models/ci/build_trace_metadata_spec.rb @@ -159,7 +159,7 @@ RSpec.describe Ci::BuildTraceMetadata, feature_category: :continuous_integration end end - describe 'partitioning' do + describe 'partitioning', :ci_partitionable do include Ci::PartitioningHelpers let_it_be(:pipeline) { create(:ci_pipeline) } diff --git a/spec/models/ci/build_trace_spec.rb b/spec/models/ci/build_trace_spec.rb index 907b49dc180..30e4ef760d7 100644 --- a/spec/models/ci/build_trace_spec.rb +++ b/spec/models/ci/build_trace_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe Ci::BuildTrace do +RSpec.describe Ci::BuildTrace, feature_category: :continuous_integration do let(:build) { build_stubbed(:ci_build) } let(:state) { nil } let(:data) { StringIO.new('the-stream') } @@ -13,7 +13,7 @@ RSpec.describe Ci::BuildTrace do subject { described_class.new(build: build, stream: stream, state: state) } - shared_examples 'delegates methods' do + describe 'delegated methods' do it { is_expected.to delegate_method(:state).to(:trace) } it { is_expected.to delegate_method(:append).to(:trace) } it { is_expected.to delegate_method(:truncated).to(:trace) } @@ -25,8 +25,6 @@ RSpec.describe Ci::BuildTrace do it { is_expected.to delegate_method(:complete?).to(:build).with_prefix } end - it_behaves_like 'delegates methods' - it 'returns formatted trace' do expect(subject.lines).to eq( [ diff --git a/spec/models/ci/catalog/listing_spec.rb b/spec/models/ci/catalog/listing_spec.rb new file mode 100644 index 00000000000..93d70a3f63e --- /dev/null +++ b/spec/models/ci/catalog/listing_spec.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Ci::Catalog::Listing, feature_category: :pipeline_composition do + let_it_be(:namespace) { create(:group) } + let_it_be(:project_1) { create(:project, namespace: namespace) } + let_it_be(:project_2) { create(:project, namespace: namespace) } + let_it_be(:project_3) { create(:project) } + let_it_be(:user) { create(:user) } + + let(:list) { described_class.new(namespace, user) } + + describe '#new' do + context 'when namespace is not a root namespace' do + let(:namespace) { create(:group, :nested) } + + it 'raises an exception' do + expect { list }.to raise_error(ArgumentError, 'Namespace is not a root namespace') + end + end + end + + describe '#resources' do + subject(:resources) { list.resources } + + context 'when the user has access to all projects in the namespace' do + before do + namespace.add_developer(user) + end + + context 'when the namespace has no catalog resources' do + it { is_expected.to be_empty } + end + + context 'when the namespace has catalog resources' do + let!(:resource) { create(:catalog_resource, project: project_1) } + let!(:other_namespace_resource) { create(:catalog_resource, project: project_3) } + + it 'contains only catalog resources for projects in that namespace' do + is_expected.to contain_exactly(resource) + end + end + end + + context 'when the user only has access to some projects in the namespace' do + let!(:resource_1) { create(:catalog_resource, project: project_1) } + let!(:resource_2) { create(:catalog_resource, project: project_2) } + + before do + project_1.add_developer(user) + project_2.add_guest(user) + end + + it 'only returns catalog resources for projects the user has access to' do + is_expected.to contain_exactly(resource_1) + end + end + + context 'when the user does not have access to the namespace' do + let!(:resource) { create(:catalog_resource, project: project_1) } + + it { is_expected.to be_empty } + end + end +end diff --git a/spec/models/ci/catalog/resource_spec.rb b/spec/models/ci/catalog/resource_spec.rb new file mode 100644 index 00000000000..a239bbad857 --- /dev/null +++ b/spec/models/ci/catalog/resource_spec.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Ci::Catalog::Resource, feature_category: :pipeline_composition do + let_it_be(:project) { create(:project) } + let_it_be(:resource) { create(:catalog_resource, project: project) } + + let_it_be(:releases) do + [ + create(:release, project: project, released_at: Time.zone.now - 2.days), + create(:release, project: project, released_at: Time.zone.now - 1.day), + create(:release, project: project, released_at: Time.zone.now) + ] + end + + it { is_expected.to belong_to(:project) } + + it { is_expected.to delegate_method(:avatar_path).to(:project) } + it { is_expected.to delegate_method(:description).to(:project) } + it { is_expected.to delegate_method(:name).to(:project) } + + describe '.for_projects' do + it 'returns catalog resources for the given project IDs' do + resources_for_projects = described_class.for_projects(project.id) + + expect(resources_for_projects).to contain_exactly(resource) + end + end + + describe '#versions' do + it 'returns releases ordered by released date descending' do + expect(resource.versions).to eq(releases.reverse) + end + end + + describe '#latest_version' do + it 'returns the latest release' do + expect(resource.latest_version).to eq(releases.last) + end + end +end diff --git a/spec/models/ci/commit_with_pipeline_spec.rb b/spec/models/ci/commit_with_pipeline_spec.rb index 320143535e2..766e99288c0 100644 --- a/spec/models/ci/commit_with_pipeline_spec.rb +++ b/spec/models/ci/commit_with_pipeline_spec.rb @@ -2,9 +2,9 @@ require 'spec_helper' -RSpec.describe Ci::CommitWithPipeline do - let(:project) { create(:project, :public, :repository) } - let(:commit) { described_class.new(project.commit) } +RSpec.describe Ci::CommitWithPipeline, feature_category: :continuous_integration do + let_it_be(:project) { create(:project, :public, :repository) } + let(:commit) { described_class.new(project.commit) } describe '#last_pipeline' do let!(:first_pipeline) do @@ -27,28 +27,43 @@ RSpec.describe Ci::CommitWithPipeline do end describe '#lazy_latest_pipeline' do - let(:commit_1) do - described_class.new(Commit.new(RepoHelpers.sample_commit, project)) + let_it_be(:other_project) { create(:project, :repository) } + + let_it_be(:commits_with_pipelines) do + [ + described_class.new(Commit.new(RepoHelpers.sample_commit, project)), + described_class.new(Commit.new(RepoHelpers.another_sample_commit, project)), + described_class.new(Commit.new(RepoHelpers.sample_big_commit, project)), + described_class.new(Commit.new(RepoHelpers.sample_commit, other_project)) + ] end - let(:commit_2) do - described_class.new(Commit.new(RepoHelpers.another_sample_commit, project)) + let_it_be(:commits) do + commits_with_pipelines + [ + described_class.new(Commit.new(RepoHelpers.another_sample_commit, other_project)) + ] end - let!(:commits) { [commit_1, commit_2] } + before(:all) do + commits_with_pipelines.each do |commit| + create(:ci_empty_pipeline, project: commit.project, sha: commit.sha) + end + end - it 'executes only 1 SQL query' do + it 'returns the correct pipelines with only 1 SQL query per project', :aggregate_failures do recorder = ActiveRecord::QueryRecorder.new do - # Running this first ensures we don't run one query for every - # commit. + # batch commits commits.each(&:lazy_latest_pipeline) - # This forces the execution of the SQL queries necessary to load the - # data. - commits.each { |c| c.latest_pipeline.try(:id) } + # assert result correctness + commits_with_pipelines.each do |commit| + expect(commit.lazy_latest_pipeline.project).to eq(commit.project) + end + + expect(commits.last.lazy_latest_pipeline&.itself).to be_nil end - expect(recorder.count).to eq(1) + expect(recorder.count).to eq(2) end end diff --git a/spec/models/ci/daily_build_group_report_result_spec.rb b/spec/models/ci/daily_build_group_report_result_spec.rb index cd55817243f..6f73d89d760 100644 --- a/spec/models/ci/daily_build_group_report_result_spec.rb +++ b/spec/models/ci/daily_build_group_report_result_spec.rb @@ -6,7 +6,11 @@ RSpec.describe Ci::DailyBuildGroupReportResult do let(:daily_build_group_report_result) { build(:ci_daily_build_group_report_result) } describe 'associations' do - it { is_expected.to belong_to(:last_pipeline) } + it do + is_expected.to belong_to(:last_pipeline).class_name('Ci::Pipeline') + .with_foreign_key(:last_pipeline_id).inverse_of(:daily_build_group_report_results) + end + it { is_expected.to belong_to(:project) } it { is_expected.to belong_to(:group) } end diff --git a/spec/models/ci/group_spec.rb b/spec/models/ci/group_spec.rb index 4900bc792af..82e4f7ce8fc 100644 --- a/spec/models/ci/group_spec.rb +++ b/spec/models/ci/group_spec.rb @@ -111,11 +111,13 @@ RSpec.describe Ci::Group do end def create_build(type, status: 'success', **opts) - create(type, pipeline: pipeline, - stage: stage.name, - status: status, - stage_id: stage.id, - **opts) + create( + type, pipeline: pipeline, + stage: stage.name, + status: status, + stage_id: stage.id, + **opts + ) end end end diff --git a/spec/models/ci/group_variable_spec.rb b/spec/models/ci/group_variable_spec.rb index e73319cfcd7..a2751b9fb20 100644 --- a/spec/models/ci/group_variable_spec.rb +++ b/spec/models/ci/group_variable_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe Ci::GroupVariable, feature_category: :pipeline_authoring do +RSpec.describe Ci::GroupVariable, feature_category: :secrets_management do let_it_be_with_refind(:group) { create(:group) } subject { build(:ci_group_variable, group: group) } @@ -65,4 +65,16 @@ RSpec.describe Ci::GroupVariable, feature_category: :pipeline_authoring do expect(subject.audit_details).to eq(subject.key) end end + + describe '#group_name' do + it "equals to the name of the group the variable belongs to" do + expect(subject.group_name).to eq(subject.group.name) + end + end + + describe '#group_ci_cd_settings_path' do + it "equals to the path of the CI/CD settings of the group the variable belongs to" do + expect(subject.group_ci_cd_settings_path).to eq(Gitlab::Routing.url_helpers.group_settings_ci_cd_path(subject.group)) + end + end end diff --git a/spec/models/ci/job_artifact_spec.rb b/spec/models/ci/job_artifact_spec.rb index e94445f17cd..a34657adf60 100644 --- a/spec/models/ci/job_artifact_spec.rb +++ b/spec/models/ci/job_artifact_spec.rb @@ -7,7 +7,7 @@ RSpec.describe Ci::JobArtifact, feature_category: :build_artifacts do describe "Associations" do it { is_expected.to belong_to(:project) } - it { is_expected.to belong_to(:job) } + it { is_expected.to belong_to(:job).class_name('Ci::Build').with_foreign_key(:job_id).inverse_of(:job_artifacts) } it { is_expected.to validate_presence_of(:job) } it { is_expected.to validate_presence_of(:partition_id) } end @@ -243,6 +243,29 @@ RSpec.describe Ci::JobArtifact, feature_category: :build_artifacts do end end + describe '.non_trace' do + subject { described_class.non_trace } + + context 'when there is only a trace job artifact' do + let!(:trace) { create(:ci_job_artifact, :trace) } + + it { is_expected.to be_empty } + end + + context 'when there is only a non-trace job artifact' do + let!(:junit) { create(:ci_job_artifact, :junit) } + + it { is_expected.to eq([junit]) } + end + + context 'when there are both trace and non-trace job artifacts' do + let!(:trace) { create(:ci_job_artifact, :trace) } + let!(:junit) { create(:ci_job_artifact, :junit) } + + it { is_expected.to eq([junit]) } + end + end + describe '.downloadable' do subject { described_class.downloadable } @@ -460,9 +483,11 @@ RSpec.describe Ci::JobArtifact, feature_category: :build_artifacts do context "when #{file_type} type with other formats" do described_class.file_formats.except(file_format).values.each do |other_format| - let(:artifact) { build(:ci_job_artifact, file_type: file_type, file_format: other_format) } + context "with #{other_format}" do + let(:artifact) { build(:ci_job_artifact, file_type: file_type, file_format: other_format) } - it { is_expected.not_to be_valid } + it { is_expected.not_to be_valid } + end end end end diff --git a/spec/models/ci/job_token/allowlist_spec.rb b/spec/models/ci/job_token/allowlist_spec.rb index 3a2673c7c26..3d29a637d68 100644 --- a/spec/models/ci/job_token/allowlist_spec.rb +++ b/spec/models/ci/job_token/allowlist_spec.rb @@ -16,10 +16,12 @@ RSpec.describe Ci::JobToken::Allowlist, feature_category: :continuous_integratio context 'when no projects are added to the scope' do [:inbound, :outbound].each do |d| - let(:direction) { d } + context "with #{d}" do + let(:direction) { d } - it 'returns the project defining the scope' do - expect(projects).to contain_exactly(source_project) + it 'returns the project defining the scope' do + expect(projects).to contain_exactly(source_project) + end end end end @@ -47,15 +49,17 @@ RSpec.describe Ci::JobToken::Allowlist, feature_category: :continuous_integratio subject { allowlist.add!(added_project, user: user) } [:inbound, :outbound].each do |d| - let(:direction) { d } + context "with #{d}" do + let(:direction) { d } - it 'adds the project' do - subject + it 'adds the project' do + subject - expect(allowlist.projects).to contain_exactly(source_project, added_project) - expect(subject.added_by_id).to eq(user.id) - expect(subject.source_project_id).to eq(source_project.id) - expect(subject.target_project_id).to eq(added_project.id) + expect(allowlist.projects).to contain_exactly(source_project, added_project) + expect(subject.added_by_id).to eq(user.id) + expect(subject.source_project_id).to eq(source_project.id) + expect(subject.target_project_id).to eq(added_project.id) + end end end end diff --git a/spec/models/ci/job_token/scope_spec.rb b/spec/models/ci/job_token/scope_spec.rb index 9ae061a3702..7aa861a3dab 100644 --- a/spec/models/ci/job_token/scope_spec.rb +++ b/spec/models/ci/job_token/scope_spec.rb @@ -63,12 +63,14 @@ RSpec.describe Ci::JobToken::Scope, feature_category: :continuous_integration, f subject { scope.add!(new_project, direction: direction, user: user) } [:inbound, :outbound].each do |d| - let(:direction) { d } + context "with #{d}" do + let(:direction) { d } - it 'adds the project' do - subject + it 'adds the project' do + subject - expect(scope.send("#{direction}_projects")).to contain_exactly(current_project, new_project) + expect(scope.send("#{direction}_projects")).to contain_exactly(current_project, new_project) + end end end @@ -160,13 +162,5 @@ RSpec.describe Ci::JobToken::Scope, feature_category: :continuous_integration, f include_examples 'enforces outbound scope only' end - - context 'when inbound scope flag disabled' do - before do - stub_feature_flags(ci_inbound_job_token_scope: false) - end - - include_examples 'enforces outbound scope only' - end end end diff --git a/spec/models/ci/job_variable_spec.rb b/spec/models/ci/job_variable_spec.rb index 0a65708160a..a56e6b6be43 100644 --- a/spec/models/ci/job_variable_spec.rb +++ b/spec/models/ci/job_variable_spec.rb @@ -8,7 +8,7 @@ RSpec.describe Ci::JobVariable, feature_category: :continuous_integration do describe 'associations' do let!(:job_variable) { create(:ci_job_variable) } - it { is_expected.to belong_to(:job) } + it { is_expected.to belong_to(:job).class_name('Ci::Build').with_foreign_key(:job_id).inverse_of(:job_variables) } it { is_expected.to validate_uniqueness_of(:key).scoped_to(:job_id) } end diff --git a/spec/models/ci/pipeline_schedule_spec.rb b/spec/models/ci/pipeline_schedule_spec.rb index 9b70f7c2839..c441be58edf 100644 --- a/spec/models/ci/pipeline_schedule_spec.rb +++ b/spec/models/ci/pipeline_schedule_spec.rb @@ -10,7 +10,7 @@ RSpec.describe Ci::PipelineSchedule, feature_category: :continuous_integration d it { is_expected.to belong_to(:project) } it { is_expected.to belong_to(:owner) } - it { is_expected.to have_many(:pipelines) } + it { is_expected.to have_many(:pipelines).dependent(:nullify) } it { is_expected.to have_many(:variables) } it { is_expected.to respond_to(:ref) } @@ -281,4 +281,19 @@ RSpec.describe Ci::PipelineSchedule, feature_category: :continuous_integration d let!(:model) { create(:ci_pipeline_schedule, project: parent) } end end + + describe 'before_destroy' do + let_it_be_with_reload(:pipeline_schedule) { create(:ci_pipeline_schedule, cron: ' 0 0 * * * ') } + let_it_be_with_reload(:pipeline) { create(:ci_pipeline, pipeline_schedule: pipeline_schedule) } + + it 'nullifys associated pipelines' do + expect(pipeline_schedule).to receive(:nullify_dependent_associations_in_batches).and_call_original + + result = pipeline_schedule.destroy + + expect(result).to be_truthy + expect(pipeline.reload.pipeline_schedule).to be_nil + expect(described_class.find_by(id: pipeline_schedule.id)).to be_nil + end + end end diff --git a/spec/models/ci/pipeline_spec.rb b/spec/models/ci/pipeline_spec.rb index 61422978df7..5b67cbbc86b 100644 --- a/spec/models/ci/pipeline_spec.rb +++ b/spec/models/ci/pipeline_spec.rb @@ -19,32 +19,67 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep, feature_category: it { is_expected.to belong_to(:project) } it { is_expected.to belong_to(:user) } - it { is_expected.to belong_to(:auto_canceled_by) } + it { is_expected.to belong_to(:auto_canceled_by).class_name('Ci::Pipeline').inverse_of(:auto_canceled_pipelines) } it { is_expected.to belong_to(:pipeline_schedule) } it { is_expected.to belong_to(:merge_request) } it { is_expected.to belong_to(:external_pull_request) } it { is_expected.to have_many(:statuses) } - it { is_expected.to have_many(:trigger_requests) } + it { is_expected.to have_many(:trigger_requests).with_foreign_key(:commit_id).inverse_of(:pipeline) } it { is_expected.to have_many(:variables) } it { is_expected.to have_many(:builds) } - it { is_expected.to have_many(:statuses_order_id_desc) } + + it do + is_expected.to have_many(:statuses_order_id_desc) + .class_name('CommitStatus').with_foreign_key(:commit_id).inverse_of(:pipeline) + end + it { is_expected.to have_many(:bridges) } it { is_expected.to have_many(:job_artifacts).through(:builds) } it { is_expected.to have_many(:build_trace_chunks).through(:builds) } - it { is_expected.to have_many(:auto_canceled_pipelines) } - it { is_expected.to have_many(:auto_canceled_jobs) } - it { is_expected.to have_many(:sourced_pipelines) } + it { is_expected.to have_many(:triggered_pipelines) } it { is_expected.to have_many(:pipeline_artifacts) } - it { is_expected.to have_one(:chat_data) } + it do + is_expected.to have_many(:failed_builds).class_name('Ci::Build') + .with_foreign_key(:commit_id).inverse_of(:pipeline) + end + + it do + is_expected.to have_many(:cancelable_statuses).class_name('CommitStatus') + .with_foreign_key(:commit_id).inverse_of(:pipeline) + end + + it do + is_expected.to have_many(:auto_canceled_pipelines).class_name('Ci::Pipeline') + .with_foreign_key(:auto_canceled_by_id).inverse_of(:auto_canceled_by) + end + + it do + is_expected.to have_many(:auto_canceled_jobs).class_name('CommitStatus') + .with_foreign_key(:auto_canceled_by_id).inverse_of(:auto_canceled_by) + end + + it do + is_expected.to have_many(:sourced_pipelines).class_name('Ci::Sources::Pipeline') + .with_foreign_key(:source_pipeline_id).inverse_of(:source_pipeline) + end + it { is_expected.to have_one(:source_pipeline) } + it { is_expected.to have_one(:chat_data) } it { is_expected.to have_one(:triggered_by_pipeline) } it { is_expected.to have_one(:source_job) } it { is_expected.to have_one(:pipeline_config) } it { is_expected.to have_one(:pipeline_metadata) } + it do + is_expected.to have_many(:daily_build_group_report_results).class_name('Ci::DailyBuildGroupReportResult') + .with_foreign_key(:last_pipeline_id).inverse_of(:last_pipeline) + end + + it { is_expected.to have_many(:latest_builds_report_results).through(:latest_builds).source(:report_results) } + it { is_expected.to respond_to :git_author_name } it { is_expected.to respond_to :git_author_email } it { is_expected.to respond_to :git_author_full_text } @@ -409,6 +444,16 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep, feature_category: end end + describe '.preload_pipeline_metadata' do + let_it_be(:pipeline) { create(:ci_empty_pipeline, project: project, user: user, name: 'Chatops pipeline') } + + it 'loads associations' do + result = described_class.preload_pipeline_metadata.first + + expect(result.association(:pipeline_metadata).loaded?).to be(true) + end + end + describe '.ci_sources' do subject { described_class.ci_sources } @@ -462,11 +507,13 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep, feature_category: let!(:other_pipeline) { create(:ci_pipeline, project: project) } before do - create(:ci_sources_pipeline, - source_job: create(:ci_build, pipeline: upstream_pipeline), - source_project: project, - pipeline: child_pipeline, - project: project) + create( + :ci_sources_pipeline, + source_job: create(:ci_build, pipeline: upstream_pipeline), + source_project: project, + pipeline: child_pipeline, + project: project + ) end it 'only returns pipelines outside pipeline family' do @@ -485,11 +532,13 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep, feature_category: let!(:other_pipeline) { create(:ci_pipeline, project: project) } before do - create(:ci_sources_pipeline, - source_job: create(:ci_build, pipeline: upstream_pipeline), - source_project: project, - pipeline: child_pipeline, - project: project) + create( + :ci_sources_pipeline, + source_job: create(:ci_build, pipeline: upstream_pipeline), + source_project: project, + pipeline: child_pipeline, + project: project + ) end it 'only returns older pipelines outside pipeline family' do @@ -497,6 +546,17 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep, feature_category: end end + describe '.order_id_desc' do + subject(:pipelines_ordered_by_id) { described_class.order_id_desc } + + let(:older_pipeline) { create(:ci_pipeline, id: 99, project: project) } + let(:newest_pipeline) { create(:ci_pipeline, id: 100, project: project) } + + it 'only returns the pipelines ordered by id' do + expect(pipelines_ordered_by_id).to eq([newest_pipeline, older_pipeline]) + end + end + describe '.jobs_count_in_alive_pipelines' do before do ::Ci::HasStatus::ALIVE_STATUSES.each do |status| @@ -872,6 +932,26 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep, feature_category: expect(pipeline).to be_valid end end + + context 'when source is unknown' do + subject(:pipeline) { create(:ci_empty_pipeline, :created) } + + let(:attr) { :source } + let(:attr_value) { :unknown } + + it_behaves_like 'having enum with nil value' + end + end + + describe '#config_source' do + context 'when source is unknown' do + subject(:pipeline) { create(:ci_empty_pipeline, :created) } + + let(:attr) { :config_source } + let(:attr_value) { :unknown_source } + + it_behaves_like 'having enum with nil value' + end end describe '#block' do @@ -1131,29 +1211,41 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep, feature_category: describe 'legacy stages' do before do - create(:commit_status, pipeline: pipeline, - stage: 'build', - name: 'linux', - stage_idx: 0, - status: 'success') - - create(:commit_status, pipeline: pipeline, - stage: 'build', - name: 'mac', - stage_idx: 0, - status: 'failed') - - create(:commit_status, pipeline: pipeline, - stage: 'deploy', - name: 'staging', - stage_idx: 2, - status: 'running') - - create(:commit_status, pipeline: pipeline, - stage: 'test', - name: 'rspec', - stage_idx: 1, - status: 'success') + create( + :commit_status, + pipeline: pipeline, + stage: 'build', + name: 'linux', + stage_idx: 0, + status: 'success' + ) + + create( + :commit_status, + pipeline: pipeline, + stage: 'build', + name: 'mac', + stage_idx: 0, + status: 'failed' + ) + + create( + :commit_status, + pipeline: pipeline, + stage: 'deploy', + name: 'staging', + stage_idx: 2, + status: 'running' + ) + + create( + :commit_status, + pipeline: pipeline, + stage: 'test', + name: 'rspec', + stage_idx: 1, + status: 'success' + ) end describe '#stages_count' do @@ -1604,8 +1696,7 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep, feature_category: before do upstream_bridge = create(:ci_bridge, :strategy_depend, pipeline: upstream_of_upstream_pipeline) - create(:ci_sources_pipeline, pipeline: upstream_pipeline, - source_job: upstream_bridge) + create(:ci_sources_pipeline, pipeline: upstream_pipeline, source_job: upstream_bridge) end context 'when the downstream pipeline first fails then retries and succeeds' do @@ -1661,13 +1752,163 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep, feature_category: end end + describe 'merge status subscription trigger' do + shared_examples 'state transition not triggering GraphQL subscription mergeRequestMergeStatusUpdated' do + context 'when state transitions to running' do + it_behaves_like 'does not trigger GraphQL subscription mergeRequestMergeStatusUpdated' do + let(:action) { pipeline.run } + end + end + + context 'when state transitions to success' do + it_behaves_like 'does not trigger GraphQL subscription mergeRequestMergeStatusUpdated' do + let(:action) { pipeline.succeed } + end + end + + context 'when state transitions to failed' do + it_behaves_like 'does not trigger GraphQL subscription mergeRequestMergeStatusUpdated' do + let(:action) { pipeline.drop } + end + end + + context 'when state transitions to canceled' do + it_behaves_like 'does not trigger GraphQL subscription mergeRequestMergeStatusUpdated' do + let(:action) { pipeline.cancel } + end + end + + context 'when state transitions to skipped' do + it_behaves_like 'does not trigger GraphQL subscription mergeRequestMergeStatusUpdated' do + let(:action) { pipeline.skip } + end + end + end + + shared_examples 'state transition triggering GraphQL subscription mergeRequestMergeStatusUpdated' do + context 'when state transitions to running' do + it_behaves_like 'triggers GraphQL subscription mergeRequestMergeStatusUpdated' do + let(:action) { pipeline.run } + end + end + + context 'when state transitions to success' do + it_behaves_like 'triggers GraphQL subscription mergeRequestMergeStatusUpdated' do + let(:action) { pipeline.succeed } + end + end + + context 'when state transitions to failed' do + it_behaves_like 'triggers GraphQL subscription mergeRequestMergeStatusUpdated' do + let(:action) { pipeline.drop } + end + end + + context 'when state transitions to canceled' do + it_behaves_like 'triggers GraphQL subscription mergeRequestMergeStatusUpdated' do + let(:action) { pipeline.cancel } + end + end + + context 'when state transitions to skipped' do + it_behaves_like 'triggers GraphQL subscription mergeRequestMergeStatusUpdated' do + let(:action) { pipeline.skip } + end + end + + context 'when only_allow_merge_if_pipeline_succeeds? returns false' do + let(:only_allow_merge_if_pipeline_succeeds?) { false } + + it_behaves_like 'state transition not triggering GraphQL subscription mergeRequestMergeStatusUpdated' + end + + context 'when pipeline_trigger_merge_status feature flag is disabled' do + before do + stub_feature_flags(pipeline_trigger_merge_status: false) + end + + it_behaves_like 'state transition not triggering GraphQL subscription mergeRequestMergeStatusUpdated' + end + end + + context 'when pipeline has merge requests' do + let(:merge_request) do + create( + :merge_request, + :simple, + source_project: project, + target_project: project + ) + end + + let(:only_allow_merge_if_pipeline_succeeds?) { true } + + before do + allow(project) + .to receive(:only_allow_merge_if_pipeline_succeeds?) + .and_return(only_allow_merge_if_pipeline_succeeds?) + end + + context 'when for a specific merge request' do + let(:pipeline) do + create( + :ci_pipeline, + project: project, + merge_request: merge_request + ) + end + + it_behaves_like 'state transition triggering GraphQL subscription mergeRequestMergeStatusUpdated' + + context 'when pipeline is a child' do + let(:parent_pipeline) do + create( + :ci_pipeline, + project: project, + merge_request: merge_request + ) + end + + let(:pipeline) do + create( + :ci_pipeline, + child_of: parent_pipeline, + merge_request: merge_request + ) + end + + it_behaves_like 'state transition not triggering GraphQL subscription mergeRequestMergeStatusUpdated' + end + end + + context 'when for merge requests matching the source branch and SHA' do + let(:pipeline) do + create( + :ci_pipeline, + project: project, + ref: merge_request.source_branch, + sha: merge_request.diff_head_sha + ) + end + + it_behaves_like 'state transition triggering GraphQL subscription mergeRequestMergeStatusUpdated' + end + end + + context 'when pipeline has no merge requests' do + it_behaves_like 'state transition not triggering GraphQL subscription mergeRequestMergeStatusUpdated' + end + end + def create_build(name, *traits, queued_at: current, started_from: 0, **opts) - create(:ci_build, *traits, - name: name, - pipeline: pipeline, - queued_at: queued_at, - started_at: queued_at + started_from, - **opts) + create( + :ci_build, *traits, + name: name, + pipeline: pipeline, + queued_at: queued_at, + started_at: queued_at + started_from, + **opts + ) end end @@ -1715,9 +1956,7 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep, feature_category: let(:pipeline) { build(:ci_pipeline, merge_request: merge_request) } let(:merge_request) do - create(:merge_request, :simple, - source_project: project, - target_project: project) + create(:merge_request, :simple, source_project: project, target_project: project) end it 'returns false' do @@ -1758,17 +1997,17 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep, feature_category: context 'when ref is merge request' do let(:pipeline) do - create(:ci_pipeline, - source: :merge_request_event, - merge_request: merge_request) + create(:ci_pipeline, source: :merge_request_event, merge_request: merge_request) end let(:merge_request) do - create(:merge_request, - source_project: project, - source_branch: 'feature', - target_project: project, - target_branch: 'master') + create( + :merge_request, + source_project: project, + source_branch: 'feature', + target_project: project, + target_branch: 'master' + ) end it 'returns branch ref' do @@ -1812,35 +2051,63 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep, feature_category: context 'with non-empty project' do let(:pipeline) do - create(:ci_pipeline, - ref: project.default_branch, - sha: project.commit.sha) + create( + :ci_pipeline, + project: project, + ref: project.default_branch, + sha: project.commit.sha + ) end describe '#lazy_ref_commit' do let(:another) do - create(:ci_pipeline, - ref: 'feature', - sha: project.commit('feature').sha) + create( + :ci_pipeline, + project: project, + ref: 'feature', + sha: project.commit('feature').sha + ) end let(:unicode) do - create(:ci_pipeline, - ref: 'ü/unicode/multi-byte') + create( + :ci_pipeline, + project: project, + ref: 'ü/unicode/multi-byte' + ) + end + + let(:in_another_project) do + other_project = create(:project, :repository) + create( + :ci_pipeline, + project: other_project, + ref: other_project.default_branch, + sha: other_project.commit.sha + ) end - it 'returns the latest commit for a ref lazily' do + it 'returns the latest commit for a ref lazily', :aggregate_failures do expect(project.repository) .to receive(:list_commits_by_ref_name).once .and_call_original + requests_before = Gitlab::GitalyClient.get_request_count pipeline.lazy_ref_commit another.lazy_ref_commit unicode.lazy_ref_commit + in_another_project.lazy_ref_commit + requests_after = Gitlab::GitalyClient.get_request_count + + expect(requests_after - requests_before).to eq(0) expect(pipeline.lazy_ref_commit.id).to eq pipeline.sha expect(another.lazy_ref_commit.id).to eq another.sha - expect(unicode.lazy_ref_commit).to be_nil + expect(unicode.lazy_ref_commit.itself).to be_nil + expect(in_another_project.lazy_ref_commit.id).to eq in_another_project.sha + + expect(pipeline.lazy_ref_commit.repository.container).to eq project + expect(in_another_project.lazy_ref_commit.repository.container).to eq in_another_project.project end end @@ -1969,9 +2236,7 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep, feature_category: end let(:merge_request) do - create(:merge_request, :simple, - source_project: project, - target_project: project) + create(:merge_request, :simple, source_project: project, target_project: project) end it 'returns merge request modified paths' do @@ -1996,8 +2261,7 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep, feature_category: describe '#modified_paths_since' do let(:project) do - create(:project, :custom_repo, - files: { 'file1.txt' => 'file 1' }) + create(:project, :custom_repo, files: { 'file1.txt' => 'file 1' }) end let(:user) { project.owner } @@ -3270,19 +3534,23 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep, feature_category: let(:target_branch) { 'master' } let!(:pipeline) do - create(:ci_pipeline, - source: :merge_request_event, - project: pipeline_project, - ref: source_branch, - merge_request: merge_request) + create( + :ci_pipeline, + source: :merge_request_event, + project: pipeline_project, + ref: source_branch, + merge_request: merge_request + ) end let(:merge_request) do - create(:merge_request, - source_project: pipeline_project, - source_branch: source_branch, - target_project: project, - target_branch: target_branch) + create( + :merge_request, + source_project: pipeline_project, + source_branch: source_branch, + target_project: project, + target_branch: target_branch + ) end it 'returns an associated merge request' do @@ -3293,19 +3561,23 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep, feature_category: let(:target_branch_2) { 'merge-test' } let!(:pipeline_2) do - create(:ci_pipeline, - source: :merge_request_event, - project: pipeline_project, - ref: source_branch, - merge_request: merge_request_2) + create( + :ci_pipeline, + source: :merge_request_event, + project: pipeline_project, + ref: source_branch, + merge_request: merge_request_2 + ) end let(:merge_request_2) do - create(:merge_request, - source_project: pipeline_project, - source_branch: source_branch, - target_project: project, - target_branch: target_branch_2) + create( + :merge_request, + source_project: pipeline_project, + source_branch: source_branch, + target_project: project, + target_branch: target_branch_2 + ) end it 'does not return an associated merge request' do @@ -3701,10 +3973,12 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep, feature_category: let(:project) { create(:project, :repository, namespace: namespace) } let(:pipeline) do - create(:ci_pipeline, - project: project, - sha: project.commit('master').sha, - user: project.first_owner) + create( + :ci_pipeline, + project: project, + sha: project.commit('master').sha, + user: project.first_owner + ) end before do @@ -4488,10 +4762,7 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep, feature_category: let(:stage_name) { 'test' } let(:stage) do - create(:ci_stage, - pipeline: pipeline, - project: pipeline.project, - name: 'test') + create(:ci_stage, pipeline: pipeline, project: pipeline.project, name: 'test') end before do @@ -5208,11 +5479,11 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep, feature_category: describe '#cluster_agent_authorizations' do let(:pipeline) { create(:ci_empty_pipeline, :created) } - let(:authorization) { instance_double(Clusters::Agents::GroupAuthorization) } + let(:authorization) { instance_double(Clusters::Agents::Authorizations::CiAccess::GroupAuthorization) } let(:finder) { double(execute: [authorization]) } it 'retrieves authorization records from the finder and caches the result' do - expect(Clusters::AgentAuthorizationsFinder).to receive(:new).once + expect(Clusters::Agents::Authorizations::CiAccess::Finder).to receive(:new).once .with(pipeline.project) .and_return(finder) diff --git a/spec/models/ci/processable_spec.rb b/spec/models/ci/processable_spec.rb index db22d8f3a6c..34a56162dd9 100644 --- a/spec/models/ci/processable_spec.rb +++ b/spec/models/ci/processable_spec.rb @@ -43,11 +43,13 @@ RSpec.describe Ci::Processable, feature_category: :continuous_integration do let_it_be(:another_pipeline) { create(:ci_empty_pipeline, project: project) } let_it_be_with_refind(:processable) do - create(:ci_build, :failed, :picked, :expired, :erased, :queued, :coverage, :tags, - :allowed_to_fail, :on_tag, :triggered, :teardown_environment, :resource_group, - description: 'my-job', stage: 'test', stage_id: stage.id, - pipeline: pipeline, auto_canceled_by: another_pipeline, - scheduled_at: 10.seconds.since) + create( + :ci_build, :failed, :picked, :expired, :erased, :queued, :coverage, :tags, + :allowed_to_fail, :on_tag, :triggered, :teardown_environment, :resource_group, + description: 'my-job', stage: 'test', stage_id: stage.id, + pipeline: pipeline, auto_canceled_by: another_pipeline, + scheduled_at: 10.seconds.since + ) end let_it_be(:internal_job_variable) { create(:ci_job_variable, job: processable) } @@ -83,7 +85,7 @@ RSpec.describe Ci::Processable, feature_category: :continuous_integration do runner_id tag_taggings taggings tags trigger_request_id user_id auto_canceled_by_id retried failure_reason sourced_pipelines sourced_pipeline artifacts_file_store artifacts_metadata_store - metadata runner_machine_id runner_machine runner_session trace_chunks upstream_pipeline_id + metadata runner_manager_build runner_manager runner_session trace_chunks upstream_pipeline_id artifacts_file artifacts_metadata artifacts_size commands resource resource_group_id processed security_scans author pipeline_id report_results pending_state pages_deployments @@ -95,8 +97,7 @@ RSpec.describe Ci::Processable, feature_category: :continuous_integration do before_all do # Create artifacts to check that the associations are rejected when cloning Ci::JobArtifact::TYPE_AND_FORMAT_PAIRS.each do |file_type, file_format| - create(:ci_job_artifact, file_format, - file_type: file_type, job: processable, expire_at: processable.artifacts_expire_at) + create(:ci_job_artifact, file_format, file_type: file_type, job: processable, expire_at: processable.artifacts_expire_at) end create(:ci_job_variable, :dotenv_source, job: processable) @@ -193,8 +194,7 @@ RSpec.describe Ci::Processable, feature_category: :continuous_integration do context 'when it has a deployment' do let!(:processable) do - create(:ci_build, :with_deployment, :deploy_to_production, - pipeline: pipeline, stage_id: stage.id, project: project) + create(:ci_build, :with_deployment, :deploy_to_production, pipeline: pipeline, stage_id: stage.id, project: project) end it 'persists the expanded environment name' do diff --git a/spec/models/ci/ref_spec.rb b/spec/models/ci/ref_spec.rb index ffbda4b459f..eab5a40bc30 100644 --- a/spec/models/ci/ref_spec.rb +++ b/spec/models/ci/ref_spec.rb @@ -105,8 +105,11 @@ RSpec.describe Ci::Ref do context 'when pipeline is a detached merge request pipeline' do let(:merge_request) do - create(:merge_request, target_project: project, target_branch: 'master', - source_project: project, source_branch: 'feature') + create( + :merge_request, + target_project: project, target_branch: 'master', + source_project: project, source_branch: 'feature' + ) end let!(:pipeline) do diff --git a/spec/models/ci/resource_group_spec.rb b/spec/models/ci/resource_group_spec.rb index 01acf5194f0..6d518d5c874 100644 --- a/spec/models/ci/resource_group_spec.rb +++ b/spec/models/ci/resource_group_spec.rb @@ -118,7 +118,7 @@ RSpec.describe Ci::ResourceGroup do let!(:resource_group) { create(:ci_resource_group, process_mode: process_mode, project: project) } - Ci::HasStatus::STATUSES_ENUM.keys.each do |status| + Ci::HasStatus::STATUSES_ENUM.keys.each do |status| # rubocop:diable RSpec/UselessDynamicDefinition let!("build_1_#{status}") { create(:ci_build, pipeline: pipeline_1, status: status, resource_group: resource_group) } let!("build_2_#{status}") { create(:ci_build, pipeline: pipeline_2, status: status, resource_group: resource_group) } end @@ -165,4 +165,23 @@ RSpec.describe Ci::ResourceGroup do end end end + + describe '#current_processable' do + subject { resource_group.current_processable } + + let(:build) { create(:ci_build) } + let(:resource_group) { create(:ci_resource_group) } + + context 'when resource is retained by a build' do + before do + resource_group.assign_resource_to(build) + end + + it { is_expected.to eq(build) } + end + + context 'when resource is not retained by a build' do + it { is_expected.to be_nil } + end + end end diff --git a/spec/models/ci/runner_machine_spec.rb b/spec/models/ci/runner_machine_spec.rb deleted file mode 100644 index d0979d8a485..00000000000 --- a/spec/models/ci/runner_machine_spec.rb +++ /dev/null @@ -1,197 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe Ci::RunnerMachine, feature_category: :runner_fleet, type: :model do - it_behaves_like 'having unique enum values' - - it { is_expected.to belong_to(:runner) } - it { is_expected.to belong_to(:runner_version).with_foreign_key(:version) } - it { is_expected.to have_many(:build_metadata) } - it { is_expected.to have_many(:builds).through(:build_metadata) } - - describe 'validation' do - it { is_expected.to validate_presence_of(:runner) } - it { is_expected.to validate_presence_of(:system_xid) } - it { is_expected.to validate_length_of(:system_xid).is_at_most(64) } - it { is_expected.to validate_length_of(:version).is_at_most(2048) } - it { is_expected.to validate_length_of(:revision).is_at_most(255) } - it { is_expected.to validate_length_of(:platform).is_at_most(255) } - it { is_expected.to validate_length_of(:architecture).is_at_most(255) } - it { is_expected.to validate_length_of(:ip_address).is_at_most(1024) } - - context 'when runner has config' do - it 'is valid' do - runner_machine = build(:ci_runner_machine, config: { gpus: "all" }) - - expect(runner_machine).to be_valid - end - end - - context 'when runner has an invalid config' do - it 'is invalid' do - runner_machine = build(:ci_runner_machine, config: { test: 1 }) - - expect(runner_machine).not_to be_valid - end - end - end - - describe '.stale', :freeze_time do - subject { described_class.stale.ids } - - let!(:runner_machine1) { create(:ci_runner_machine, :stale) } - let!(:runner_machine2) { create(:ci_runner_machine, :stale, contacted_at: nil) } - let!(:runner_machine3) { create(:ci_runner_machine, created_at: 6.months.ago, contacted_at: Time.current) } - let!(:runner_machine4) { create(:ci_runner_machine, created_at: 5.days.ago) } - let!(:runner_machine5) do - create(:ci_runner_machine, created_at: (7.days - 1.second).ago, contacted_at: (7.days - 1.second).ago) - end - - it 'returns stale runner machines' do - is_expected.to match_array([runner_machine1.id, runner_machine2.id]) - end - end - - describe '#heartbeat', :freeze_time do - let(:runner_machine) { create(:ci_runner_machine) } - let(:executor) { 'shell' } - let(:version) { '15.0.1' } - let(:values) do - { - ip_address: '8.8.8.8', - architecture: '18-bit', - config: { gpus: "all" }, - executor: executor, - version: version - } - end - - subject(:heartbeat) do - runner_machine.heartbeat(values) - end - - context 'when database was updated recently' do - before do - runner_machine.contacted_at = Time.current - end - - it 'schedules version update' do - expect(Ci::Runners::ProcessRunnerVersionUpdateWorker).to receive(:perform_async).with(version).once - - heartbeat - - expect(runner_machine.runner_version).to be_nil - end - - it 'updates cache' do - expect_redis_update - - heartbeat - end - - context 'with only ip_address specified' do - let(:values) do - { ip_address: '1.1.1.1' } - end - - it 'updates only ip_address' do - attrs = Gitlab::Json.dump(ip_address: '1.1.1.1', contacted_at: Time.current) - - Gitlab::Redis::Cache.with do |redis| - redis_key = runner_machine.send(:cache_attribute_key) - expect(redis).to receive(:set).with(redis_key, attrs, any_args) - end - - heartbeat - end - end - end - - context 'when database was not updated recently' do - before do - runner_machine.contacted_at = 2.hours.ago - - allow(Ci::Runners::ProcessRunnerVersionUpdateWorker).to receive(:perform_async).with(version).once - end - - context 'with invalid runner_machine' do - before do - runner_machine.runner = nil - end - - it 'still updates redis cache and database' do - expect(runner_machine).to be_invalid - - expect_redis_update - does_db_update - - expect(Ci::Runners::ProcessRunnerVersionUpdateWorker).to have_received(:perform_async) - .with(version).once - end - end - - context 'with unchanged runner_machine version' do - let(:runner_machine) { create(:ci_runner_machine, version: version) } - - it 'does not schedule ci_runner_versions update' do - heartbeat - - expect(Ci::Runners::ProcessRunnerVersionUpdateWorker).not_to have_received(:perform_async) - end - end - - it 'updates redis cache and database' do - expect_redis_update - does_db_update - - expect(Ci::Runners::ProcessRunnerVersionUpdateWorker).to have_received(:perform_async) - .with(version).once - end - - Ci::Runner::EXECUTOR_NAME_TO_TYPES.each_key do |executor| - context "with #{executor} executor" do - let(:executor) { executor } - - it 'updates with expected executor type' do - expect_redis_update - - heartbeat - - expect(runner_machine.reload.read_attribute(:executor_type)).to eq(expected_executor_type) - end - - def expected_executor_type - executor.gsub(/[+-]/, '_') - end - end - end - - context "with an unknown executor type" do - let(:executor) { 'some-unknown-type' } - - it 'updates with unknown executor type' do - expect_redis_update - - heartbeat - - expect(runner_machine.reload.read_attribute(:executor_type)).to eq('unknown') - end - end - end - - def expect_redis_update - Gitlab::Redis::Cache.with do |redis| - redis_key = runner_machine.send(:cache_attribute_key) - expect(redis).to receive(:set).with(redis_key, anything, any_args).and_call_original - end - end - - def does_db_update - expect { heartbeat }.to change { runner_machine.reload.read_attribute(:contacted_at) } - .and change { runner_machine.reload.read_attribute(:architecture) } - .and change { runner_machine.reload.read_attribute(:config) } - .and change { runner_machine.reload.read_attribute(:executor_type) } - end - end -end diff --git a/spec/models/ci/runner_manager_build_spec.rb b/spec/models/ci/runner_manager_build_spec.rb new file mode 100644 index 00000000000..3a381313b76 --- /dev/null +++ b/spec/models/ci/runner_manager_build_spec.rb @@ -0,0 +1,100 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Ci::RunnerManagerBuild, model: true, feature_category: :runner_fleet do + let_it_be(:runner) { create(:ci_runner) } + let_it_be(:runner_manager) { create(:ci_runner_machine, runner: runner) } + let_it_be(:build) { create(:ci_build, runner_manager: runner_manager) } + + it { is_expected.to belong_to(:build) } + it { is_expected.to belong_to(:runner_manager) } + + describe 'partitioning' do + context 'with build' do + let(:build) { FactoryBot.build(:ci_build, partition_id: ci_testing_partition_id) } + let(:runner_manager_build) { FactoryBot.build(:ci_runner_machine_build, build: build) } + + it 'sets partition_id to the current partition value' do + expect { runner_manager_build.valid? }.to change { runner_manager_build.partition_id } + .to(ci_testing_partition_id) + end + + context 'when it is already set' do + let(:runner_manager_build) { FactoryBot.build(:ci_runner_machine_build, partition_id: 125) } + + it 'does not change the partition_id value' do + expect { runner_manager_build.valid? }.not_to change { runner_manager_build.partition_id } + end + end + end + + context 'without build' do + let(:runner_manager_build) { FactoryBot.build(:ci_runner_machine_build, build: nil) } + + it { is_expected.to validate_presence_of(:partition_id) } + + it 'does not change the partition_id value' do + expect { runner_manager_build.valid? }.not_to change { runner_manager_build.partition_id } + end + end + end + + describe 'ci_sliding_list partitioning' do + let(:connection) { described_class.connection } + let(:partition_manager) { Gitlab::Database::Partitioning::PartitionManager.new(described_class) } + + let(:partitioning_strategy) { described_class.partitioning_strategy } + + it { expect(partitioning_strategy.missing_partitions).to be_empty } + it { expect(partitioning_strategy.extra_partitions).to be_empty } + it { expect(partitioning_strategy.current_partitions).to include partitioning_strategy.initial_partition } + it { expect(partitioning_strategy.active_partition).to be_present } + end + + context 'loose foreign key on p_ci_runner_manager_builds.runner_manager_id' do # rubocop:disable RSpec/ContextWording + it_behaves_like 'cleanup by a loose foreign key' do + let!(:parent) { create(:ci_runner_machine) } + let!(:model) { create(:ci_runner_machine_build, runner_manager: parent) } + end + end + + describe '.for_build' do + subject(:for_build) { described_class.for_build(build_id) } + + context 'with valid build_id' do + let(:build_id) { build.id } + + it { is_expected.to contain_exactly(described_class.find_by_build_id(build_id)) } + end + + context 'with valid build_ids' do + let(:build2) { create(:ci_build, runner_manager: runner_manager) } + let(:build_id) { [build, build2] } + + it { is_expected.to eq(described_class.where(build_id: build_id)) } + end + + context 'with non-existeng build_id' do + let(:build_id) { non_existing_record_id } + + it { is_expected.to be_empty } + end + end + + describe '.pluck_runner_manager_id_and_build_id' do + subject { scope.pluck_build_id_and_runner_manager_id } + + context 'with default scope' do + let(:scope) { described_class } + + it { is_expected.to eq({ build.id => runner_manager.id }) } + end + + context 'with scope excluding build' do + let(:scope) { described_class.where(build_id: non_existing_record_id) } + + it { is_expected.to be_empty } + end + end +end diff --git a/spec/models/ci/runner_manager_spec.rb b/spec/models/ci/runner_manager_spec.rb new file mode 100644 index 00000000000..d69c9ef845e --- /dev/null +++ b/spec/models/ci/runner_manager_spec.rb @@ -0,0 +1,291 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Ci::RunnerManager, feature_category: :runner_fleet, type: :model do + it_behaves_like 'having unique enum values' + + it_behaves_like 'it has loose foreign keys' do + let(:factory_name) { :ci_runner_machine } + end + + it { is_expected.to belong_to(:runner) } + it { is_expected.to belong_to(:runner_version).with_foreign_key(:version) } + it { is_expected.to have_many(:runner_manager_builds) } + it { is_expected.to have_many(:builds).through(:runner_manager_builds) } + + describe 'validation' do + it { is_expected.to validate_presence_of(:runner) } + it { is_expected.to validate_presence_of(:system_xid) } + it { is_expected.to validate_length_of(:system_xid).is_at_most(64) } + it { is_expected.to validate_length_of(:version).is_at_most(2048) } + it { is_expected.to validate_length_of(:revision).is_at_most(255) } + it { is_expected.to validate_length_of(:platform).is_at_most(255) } + it { is_expected.to validate_length_of(:architecture).is_at_most(255) } + it { is_expected.to validate_length_of(:ip_address).is_at_most(1024) } + + context 'when runner has config' do + it 'is valid' do + runner_manager = build(:ci_runner_machine, config: { gpus: "all" }) + + expect(runner_manager).to be_valid + end + end + + context 'when runner has an invalid config' do + it 'is invalid' do + runner_manager = build(:ci_runner_machine, config: { test: 1 }) + + expect(runner_manager).not_to be_valid + end + end + end + + describe '.stale', :freeze_time do + subject { described_class.stale.ids } + + let!(:runner_manager1) { create(:ci_runner_machine, :stale) } + let!(:runner_manager2) { create(:ci_runner_machine, :stale, contacted_at: nil) } + let!(:runner_manager3) { create(:ci_runner_machine, created_at: 6.months.ago, contacted_at: Time.current) } + let!(:runner_manager4) { create(:ci_runner_machine, created_at: 5.days.ago) } + let!(:runner_manager5) do + create(:ci_runner_machine, created_at: (7.days - 1.second).ago, contacted_at: (7.days - 1.second).ago) + end + + it 'returns stale runner managers' do + is_expected.to match_array([runner_manager1.id, runner_manager2.id]) + end + end + + describe '.online_contact_time_deadline', :freeze_time do + subject { described_class.online_contact_time_deadline } + + it { is_expected.to eq(2.hours.ago) } + end + + describe '.stale_deadline', :freeze_time do + subject { described_class.stale_deadline } + + it { is_expected.to eq(7.days.ago) } + end + + describe '#status', :freeze_time do + let(:runner_manager) { build(:ci_runner_machine, created_at: 8.days.ago) } + + subject { runner_manager.status } + + context 'if never connected' do + before do + runner_manager.contacted_at = nil + end + + it { is_expected.to eq(:stale) } + + context 'if created recently' do + before do + runner_manager.created_at = 1.day.ago + end + + it { is_expected.to eq(:never_contacted) } + end + end + + context 'if contacted 1s ago' do + before do + runner_manager.contacted_at = 1.second.ago + end + + it { is_expected.to eq(:online) } + end + + context 'if contacted recently' do + before do + runner_manager.contacted_at = 2.hours.ago + end + + it { is_expected.to eq(:offline) } + end + + context 'if contacted long time ago' do + before do + runner_manager.contacted_at = 7.days.ago + end + + it { is_expected.to eq(:stale) } + end + end + + describe '#heartbeat', :freeze_time do + let(:runner_manager) { create(:ci_runner_machine, version: '15.0.0') } + let(:executor) { 'shell' } + let(:values) do + { + ip_address: '8.8.8.8', + architecture: '18-bit', + config: { gpus: "all" }, + executor: executor, + version: version + } + end + + subject(:heartbeat) do + runner_manager.heartbeat(values) + end + + context 'when database was updated recently' do + before do + runner_manager.contacted_at = Time.current + end + + context 'when version is changed' do + let(:version) { '15.0.1' } + + before do + allow(Ci::Runners::ProcessRunnerVersionUpdateWorker).to receive(:perform_async).with(version) + end + + it 'schedules version information update' do + heartbeat + + expect(Ci::Runners::ProcessRunnerVersionUpdateWorker).to have_received(:perform_async).with(version).once + end + + it 'updates cache' do + expect_redis_update + + heartbeat + + expect(runner_manager.runner_version).to be_nil + end + + context 'when fetching runner releases is disabled' do + before do + stub_application_setting(update_runner_versions_enabled: false) + end + + it 'does not schedule version information update' do + heartbeat + + expect(Ci::Runners::ProcessRunnerVersionUpdateWorker).not_to have_received(:perform_async) + end + end + end + + context 'with only ip_address specified' do + let(:values) do + { ip_address: '1.1.1.1' } + end + + it 'updates only ip_address' do + expect_redis_update(values.merge(contacted_at: Time.current)) + + heartbeat + end + + context 'with new version having been cached' do + let(:version) { '15.0.1' } + + before do + runner_manager.cache_attributes(version: version) + end + + it 'does not lose cached version value' do + expect { heartbeat }.not_to change { runner_manager.version }.from(version) + end + end + end + end + + context 'when database was not updated recently' do + before do + runner_manager.contacted_at = 2.hours.ago + + allow(Ci::Runners::ProcessRunnerVersionUpdateWorker).to receive(:perform_async).with(version) + end + + context 'when version is changed' do + let(:version) { '15.0.1' } + + context 'with invalid runner_manager' do + before do + runner_manager.runner = nil + end + + it 'still updates redis cache and database' do + expect(runner_manager).to be_invalid + + expect_redis_update + does_db_update + + expect(Ci::Runners::ProcessRunnerVersionUpdateWorker).to have_received(:perform_async) + .with(version).once + end + end + + it 'updates redis cache and database' do + expect_redis_update + does_db_update + + expect(Ci::Runners::ProcessRunnerVersionUpdateWorker).to have_received(:perform_async) + .with(version).once + end + end + + context 'with unchanged runner_manager version' do + let(:version) { runner_manager.version } + + it 'does not schedule ci_runner_versions update' do + heartbeat + + expect(Ci::Runners::ProcessRunnerVersionUpdateWorker).not_to have_received(:perform_async) + end + + Ci::Runner::EXECUTOR_NAME_TO_TYPES.each_key do |executor| + context "with #{executor} executor" do + let(:executor) { executor } + + it 'updates with expected executor type' do + expect_redis_update + + heartbeat + + expect(runner_manager.reload.read_attribute(:executor_type)).to eq(expected_executor_type) + end + + def expected_executor_type + executor.gsub(/[+-]/, '_') + end + end + end + + context 'with an unknown executor type' do + let(:executor) { 'some-unknown-type' } + + it 'updates with unknown executor type' do + expect_redis_update + + heartbeat + + expect(runner_manager.reload.read_attribute(:executor_type)).to eq('unknown') + end + end + end + end + + def expect_redis_update(values = anything) + values_json = values == anything ? anything : Gitlab::Json.dump(values) + + Gitlab::Redis::Cache.with do |redis| + redis_key = runner_manager.send(:cache_attribute_key) + expect(redis).to receive(:set).with(redis_key, values_json, any_args).and_call_original + end + end + + def does_db_update + expect { heartbeat }.to change { runner_manager.reload.read_attribute(:contacted_at) } + .and change { runner_manager.reload.read_attribute(:architecture) } + .and change { runner_manager.reload.read_attribute(:config) } + .and change { runner_manager.reload.read_attribute(:executor_type) } + end + end +end diff --git a/spec/models/ci/runner_spec.rb b/spec/models/ci/runner_spec.rb index 01d5fe7f90b..d202fef0ed0 100644 --- a/spec/models/ci/runner_spec.rb +++ b/spec/models/ci/runner_spec.rb @@ -273,7 +273,7 @@ RSpec.describe Ci::Runner, type: :model, feature_category: :runner do end end - shared_examples '.belonging_to_parent_group_of_project' do + shared_examples '.belonging_to_parent_groups_of_project' do let_it_be(:group1) { create(:group) } let_it_be(:project1) { create(:project, group: group1) } let_it_be(:runner1) { create(:ci_runner, :group, groups: [group1]) } @@ -284,7 +284,7 @@ RSpec.describe Ci::Runner, type: :model, feature_category: :runner do let(:project_id) { project1.id } - subject(:result) { described_class.belonging_to_parent_group_of_project(project_id) } + subject(:result) { described_class.belonging_to_parent_groups_of_project(project_id) } it 'returns the group runner' do expect(result).to contain_exactly(runner1) @@ -310,7 +310,7 @@ RSpec.describe Ci::Runner, type: :model, feature_category: :runner do end context 'when use_traversal_ids* are enabled' do - it_behaves_like '.belonging_to_parent_group_of_project' + it_behaves_like '.belonging_to_parent_groups_of_project' end context 'when use_traversal_ids* are disabled' do @@ -322,7 +322,7 @@ RSpec.describe Ci::Runner, type: :model, feature_category: :runner do ) end - it_behaves_like '.belonging_to_parent_group_of_project' + it_behaves_like '.belonging_to_parent_groups_of_project' end context 'with instance runners sharing enabled' do @@ -541,9 +541,9 @@ RSpec.describe Ci::Runner, type: :model, feature_category: :runner do describe '.stale', :freeze_time do subject { described_class.stale } - let!(:runner1) { create(:ci_runner, :instance, created_at: 4.months.ago, contacted_at: 3.months.ago + 10.seconds) } - let!(:runner2) { create(:ci_runner, :instance, created_at: 4.months.ago, contacted_at: 3.months.ago - 1.second) } - let!(:runner3) { create(:ci_runner, :instance, created_at: 3.months.ago - 1.second, contacted_at: nil) } + let!(:runner1) { create(:ci_runner, :instance, created_at: 4.months.ago, contacted_at: 3.months.ago + 1.second) } + let!(:runner2) { create(:ci_runner, :instance, created_at: 4.months.ago, contacted_at: 3.months.ago) } + let!(:runner3) { create(:ci_runner, :instance, created_at: 3.months.ago, contacted_at: nil) } let!(:runner4) { create(:ci_runner, :instance, created_at: 2.months.ago, contacted_at: nil) } it 'returns stale runners' do @@ -551,7 +551,7 @@ RSpec.describe Ci::Runner, type: :model, feature_category: :runner do end end - describe '#stale?', :clean_gitlab_redis_cache do + describe '#stale?', :clean_gitlab_redis_cache, :freeze_time do let(:runner) { build(:ci_runner, :instance) } subject { runner.stale? } @@ -570,11 +570,11 @@ RSpec.describe Ci::Runner, type: :model, feature_category: :runner do using RSpec::Parameterized::TableSyntax where(:created_at, :contacted_at, :expected_stale?) do - nil | nil | false - 3.months.ago - 1.second | 3.months.ago - 0.001.seconds | true - 3.months.ago - 1.second | 3.months.ago + 1.hour | false - 3.months.ago - 1.second | nil | true - 3.months.ago + 1.hour | nil | false + nil | nil | false + 3.months.ago | 3.months.ago | true + 3.months.ago | (3.months - 1.hour).ago | false + 3.months.ago | nil | true + (3.months - 1.hour).ago | nil | false end with_them do @@ -588,9 +588,7 @@ RSpec.describe Ci::Runner, type: :model, feature_category: :runner do runner.contacted_at = contacted_at end - specify do - is_expected.to eq(expected_stale?) - end + it { is_expected.to eq(expected_stale?) } end context 'with cache value' do @@ -599,9 +597,7 @@ RSpec.describe Ci::Runner, type: :model, feature_category: :runner do stub_redis_runner_contacted_at(contacted_at.to_s) end - specify do - is_expected.to eq(expected_stale?) - end + it { is_expected.to eq(expected_stale?) } end def stub_redis_runner_contacted_at(value) @@ -617,7 +613,7 @@ RSpec.describe Ci::Runner, type: :model, feature_category: :runner do end end - describe '.online' do + describe '.online', :freeze_time do subject { described_class.online } let!(:runner1) { create(:ci_runner, :instance, contacted_at: 2.hours.ago) } @@ -626,7 +622,7 @@ RSpec.describe Ci::Runner, type: :model, feature_category: :runner do it { is_expected.to match_array([runner2]) } end - describe '#online?', :clean_gitlab_redis_cache do + describe '#online?', :clean_gitlab_redis_cache, :freeze_time do let(:runner) { build(:ci_runner, :instance) } subject { runner.online? } @@ -891,26 +887,17 @@ RSpec.describe Ci::Runner, type: :model, feature_category: :runner do end end - describe '#status' do - let(:runner) { build(:ci_runner, :instance, created_at: 4.months.ago) } - let(:legacy_mode) {} + describe '#status', :freeze_time do + let(:runner) { build(:ci_runner, :instance, created_at: 3.months.ago) } - subject { runner.status(legacy_mode) } + subject { runner.status } context 'never connected' do before do runner.contacted_at = nil end - context 'with legacy_mode enabled' do - let(:legacy_mode) { '14.5' } - - it { is_expected.to eq(:stale) } - end - - context 'with legacy_mode disabled' do - it { is_expected.to eq(:stale) } - end + it { is_expected.to eq(:stale) } context 'created recently' do before do @@ -927,15 +914,7 @@ RSpec.describe Ci::Runner, type: :model, feature_category: :runner do runner.active = false end - context 'with legacy_mode enabled' do - let(:legacy_mode) { '14.5' } - - it { is_expected.to eq(:paused) } - end - - context 'with legacy_mode disabled' do - it { is_expected.to eq(:online) } - end + it { is_expected.to eq(:online) } end context 'contacted 1s ago' do @@ -948,7 +927,7 @@ RSpec.describe Ci::Runner, type: :model, feature_category: :runner do context 'contacted recently' do before do - runner.contacted_at = (3.months - 1.hour).ago + runner.contacted_at = (3.months - 1.second).ago end it { is_expected.to eq(:offline) } @@ -956,22 +935,14 @@ RSpec.describe Ci::Runner, type: :model, feature_category: :runner do context 'contacted long time ago' do before do - runner.contacted_at = (3.months + 1.second).ago + runner.contacted_at = 3.months.ago end - context 'with legacy_mode enabled' do - let(:legacy_mode) { '14.5' } - - it { is_expected.to eq(:stale) } - end - - context 'with legacy_mode disabled' do - it { is_expected.to eq(:stale) } - end + it { is_expected.to eq(:stale) } end end - describe '#deprecated_rest_status' do + describe '#deprecated_rest_status', :freeze_time do let(:runner) { create(:ci_runner, :instance, contacted_at: 1.second.ago) } subject { runner.deprecated_rest_status } @@ -994,8 +965,8 @@ RSpec.describe Ci::Runner, type: :model, feature_category: :runner do context 'contacted long time ago' do before do - runner.created_at = 1.year.ago - runner.contacted_at = 1.year.ago + runner.created_at = 3.months.ago + runner.contacted_at = 3.months.ago end it { is_expected.to eq(:stale) } @@ -1076,13 +1047,13 @@ RSpec.describe Ci::Runner, type: :model, feature_category: :runner do end end - describe '#heartbeat' do - let(:runner) { create(:ci_runner, :project) } + describe '#heartbeat', :freeze_time do + let(:runner) { create(:ci_runner, :project, version: '15.0.0') } let(:executor) { 'shell' } - let(:version) { '15.0.1' } + let(:values) { { architecture: '18-bit', config: { gpus: "all" }, executor: executor, version: version } } subject(:heartbeat) do - runner.heartbeat(architecture: '18-bit', config: { gpus: "all" }, executor: executor, version: version) + runner.heartbeat(values) end context 'when database was updated recently' do @@ -1090,29 +1061,61 @@ RSpec.describe Ci::Runner, type: :model, feature_category: :runner do runner.contacted_at = Time.current end - it 'updates cache' do - expect_redis_update - expect(Ci::Runners::ProcessRunnerVersionUpdateWorker).not_to receive(:perform_async) + context 'when version is changed' do + let(:version) { '15.0.1' } - heartbeat + before do + allow(Ci::Runners::ProcessRunnerVersionUpdateWorker).to receive(:perform_async).with(version) + end - expect(runner.runner_version).to be_nil + it 'updates cache' do + expect_redis_update + + heartbeat + + expect(runner.runner_version).to be_nil + end + + it 'schedules version information update' do + heartbeat + + expect(Ci::Runners::ProcessRunnerVersionUpdateWorker).to have_received(:perform_async).with(version).once + end + + context 'when fetching runner releases is disabled' do + before do + stub_application_setting(update_runner_versions_enabled: false) + end + + it 'does not schedule version information update' do + heartbeat + + expect(Ci::Runners::ProcessRunnerVersionUpdateWorker).not_to have_received(:perform_async) + end + end end context 'with only ip_address specified', :freeze_time do - subject(:heartbeat) do - runner.heartbeat(ip_address: '1.1.1.1') + let(:values) do + { ip_address: '1.1.1.1' } end it 'updates only ip_address' do - attrs = Gitlab::Json.dump(ip_address: '1.1.1.1', contacted_at: Time.current) + expect_redis_update(values.merge(contacted_at: Time.current)) - Gitlab::Redis::Cache.with do |redis| - redis_key = runner.send(:cache_attribute_key) - expect(redis).to receive(:set).with(redis_key, attrs, any_args) + heartbeat + end + + context 'with new version having been cached' do + let(:version) { '15.0.1' } + + before do + runner.cache_attributes(version: version) end - heartbeat + it 'does not lose cached version value' do + expect { heartbeat }.not_to change { runner.version }.from(version) + end end end end @@ -1121,65 +1124,81 @@ RSpec.describe Ci::Runner, type: :model, feature_category: :runner do before do runner.contacted_at = 2.hours.ago - allow(Ci::Runners::ProcessRunnerVersionUpdateWorker).to receive(:perform_async) + allow(Ci::Runners::ProcessRunnerVersionUpdateWorker).to receive(:perform_async).with(version) end - context 'with invalid runner' do - before do - runner.runner_projects.delete_all - end + context 'when version is changed' do + let(:version) { '15.0.1' } - it 'still updates redis cache and database' do - expect(runner).to be_invalid + context 'with invalid runner' do + before do + runner.runner_projects.delete_all + end + + it 'still updates redis cache and database' do + expect(runner).to be_invalid + + expect_redis_update + does_db_update + expect(Ci::Runners::ProcessRunnerVersionUpdateWorker).to have_received(:perform_async).with(version).once + end + end + + it 'updates redis cache and database' do expect_redis_update does_db_update - - expect(Ci::Runners::ProcessRunnerVersionUpdateWorker).to have_received(:perform_async).once + expect(Ci::Runners::ProcessRunnerVersionUpdateWorker).to have_received(:perform_async).with(version).once end end context 'with unchanged runner version' do - let(:runner) { create(:ci_runner, version: version) } + let(:version) { runner.version } it 'does not schedule ci_runner_versions update' do heartbeat expect(Ci::Runners::ProcessRunnerVersionUpdateWorker).not_to have_received(:perform_async) end - end - it 'updates redis cache and database' do - expect_redis_update - does_db_update - expect(Ci::Runners::ProcessRunnerVersionUpdateWorker).to have_received(:perform_async).once - end + Ci::Runner::EXECUTOR_NAME_TO_TYPES.each_key do |executor| + context "with #{executor} executor" do + let(:executor) { executor } - %w(custom shell docker docker-windows docker-ssh ssh parallels virtualbox docker+machine docker-ssh+machine kubernetes some-unknown-type).each do |executor| - context "with #{executor} executor" do - let(:executor) { executor } + it 'updates with expected executor type' do + expect_redis_update - it 'updates with expected executor type' do - expect_redis_update + heartbeat - heartbeat + expect(runner.reload.read_attribute(:executor_type)).to eq(expected_executor_type) + end - expect(runner.reload.read_attribute(:executor_type)).to eq(expected_executor_type) + def expected_executor_type + executor.gsub(/[+-]/, '_') + end end + end - def expected_executor_type - return 'unknown' if executor == 'some-unknown-type' + context 'with an unknown executor type' do + let(:executor) { 'some-unknown-type' } + + it 'updates with unknown executor type' do + expect_redis_update - executor.gsub(/[+-]/, '_') + heartbeat + + expect(runner.reload.read_attribute(:executor_type)).to eq('unknown') end end end end - def expect_redis_update + def expect_redis_update(values = anything) + values_json = values == anything ? anything : Gitlab::Json.dump(values) + Gitlab::Redis::Cache.with do |redis| redis_key = runner.send(:cache_attribute_key) - expect(redis).to receive(:set).with(redis_key, anything, any_args) + expect(redis).to receive(:set).with(redis_key, values_json, any_args).and_call_original end end @@ -1994,4 +2013,85 @@ RSpec.describe Ci::Runner, type: :model, feature_category: :runner do end end end + + describe '.with_creator' do + subject { described_class.with_creator } + + let!(:user) { create(:admin) } + let!(:runner) { create(:ci_runner, creator: user) } + + it { is_expected.to contain_exactly(runner) } + end + + describe '#ensure_token' do + let(:runner) { described_class.new(registration_type: registration_type) } + let(:token) { 'an_existing_secret_token' } + let(:static_prefix) { described_class::CREATED_RUNNER_TOKEN_PREFIX } + + context 'when runner is initialized without a token' do + context 'with registration_token' do + let(:registration_type) { :registration_token } + + it 'generates a token' do + expect { runner.ensure_token }.to change { runner.token }.from(nil) + end + end + + context 'with authenticated_user' do + let(:registration_type) { :authenticated_user } + + it 'generates a token with prefix' do + expect { runner.ensure_token }.to change { runner.token }.from(nil).to(a_string_starting_with(static_prefix)) + end + end + end + + context 'when runner is initialized with a token' do + before do + runner.set_token(token) + end + + context 'with registration_token' do + let(:registration_type) { :registration_token } + + it 'does not change the existing token' do + expect { runner.ensure_token }.not_to change { runner.token }.from(token) + end + end + + context 'with authenticated_user' do + let(:registration_type) { :authenticated_user } + + it 'does not change the existing token' do + expect { runner.ensure_token }.not_to change { runner.token }.from(token) + end + end + end + end + + describe '#gitlab_hosted?' do + using RSpec::Parameterized::TableSyntax + + subject(:runner) { build_stubbed(:ci_runner) } + + where(:saas, :runner_type, :expected_value) do + true | :instance_type | true + true | :group_type | false + true | :project_type | false + false | :instance_type | false + false | :group_type | false + false | :project_type | false + end + + with_them do + before do + allow(Gitlab).to receive(:com?).and_return(saas) + runner.runner_type = runner_type + end + + it 'returns the correct value based on saas and runner type' do + expect(runner.gitlab_hosted?).to eq(expected_value) + end + end + end end diff --git a/spec/models/ci/runner_version_spec.rb b/spec/models/ci/runner_version_spec.rb index 51a2f14c57c..bce1f2a6c39 100644 --- a/spec/models/ci/runner_version_spec.rb +++ b/spec/models/ci/runner_version_spec.rb @@ -11,7 +11,7 @@ RSpec.describe Ci::RunnerVersion, feature_category: :runner_fleet do create(:ci_runner_version, version: 'abc123', status: :unavailable) end - it { is_expected.to have_many(:runner_machines).with_foreign_key(:version) } + it { is_expected.to have_many(:runner_managers).with_foreign_key(:version) } it_behaves_like 'having unique enum values' @@ -39,4 +39,15 @@ RSpec.describe Ci::RunnerVersion, feature_category: :runner_fleet do describe 'validation' do it { is_expected.to validate_length_of(:version).is_at_most(2048) } end + + describe '#status' do + context 'when is not processed' do + subject(:ci_runner_version) { create(:ci_runner_version, version: 'abc124', status: :not_processed) } + + let(:attr) { :status } + let(:attr_value) { :not_processed } + + it_behaves_like 'having enum with nil value' + end + end end diff --git a/spec/models/ci/secure_file_spec.rb b/spec/models/ci/secure_file_spec.rb index 38ae908fb00..1043da33022 100644 --- a/spec/models/ci/secure_file_spec.rb +++ b/spec/models/ci/secure_file_spec.rb @@ -144,36 +144,43 @@ RSpec.describe Ci::SecureFile do describe '#update_metadata!' do it 'assigns the expected metadata when a parsable .cer file is supplied' do - file = create(:ci_secure_file, name: 'file1.cer', - file: CarrierWaveStringFile.new(fixture_file('ci_secure_files/sample.cer'))) + file = create( + :ci_secure_file, + name: 'file1.cer', + file: CarrierWaveStringFile.new(fixture_file('ci_secure_files/sample.cer')) + ) file.update_metadata! file.reload - expect(file.expires_at).to eq(DateTime.parse('2022-04-26 19:20:40')) + expect(file.expires_at).to eq(DateTime.parse('2023-04-26 19:20:39')) expect(file.metadata['id']).to eq('33669367788748363528491290218354043267') expect(file.metadata['issuer']['CN']).to eq('Apple Worldwide Developer Relations Certification Authority') expect(file.metadata['subject']['OU']).to eq('N7SYAN8PX8') end it 'assigns the expected metadata when a parsable .p12 file is supplied' do - file = create(:ci_secure_file, name: 'file1.p12', - file: CarrierWaveStringFile.new(fixture_file('ci_secure_files/sample.p12'))) + file = create( + :ci_secure_file, + name: 'file1.p12', + file: CarrierWaveStringFile.new(fixture_file('ci_secure_files/sample.p12')) + ) file.update_metadata! file.reload - expect(file.expires_at).to eq(DateTime.parse('2022-09-21 14:56:00')) + expect(file.expires_at).to eq(DateTime.parse('2023-09-21 14:55:59')) expect(file.metadata['id']).to eq('75949910542696343243264405377658443914') expect(file.metadata['issuer']['CN']).to eq('Apple Worldwide Developer Relations Certification Authority') expect(file.metadata['subject']['OU']).to eq('N7SYAN8PX8') end it 'assigns the expected metadata when a parsable .mobileprovision file is supplied' do - file = create(:ci_secure_file, name: 'file1.mobileprovision', - file: CarrierWaveStringFile.new( - fixture_file('ci_secure_files/sample.mobileprovision') - )) + file = create( + :ci_secure_file, + name: 'file1.mobileprovision', + file: CarrierWaveStringFile.new(fixture_file('ci_secure_files/sample.mobileprovision')) + ) file.update_metadata! file.reload @@ -187,7 +194,7 @@ RSpec.describe Ci::SecureFile do it 'logs an error when something goes wrong with the file parsing' do corrupt_file = create(:ci_secure_file, name: 'file1.cer', file: CarrierWaveStringFile.new('11111111')) - message = 'Validation failed: Metadata must be a valid json schema - not enough data.' + message = 'Validation failed: Metadata must be a valid json schema - PEM_read_bio_X509: no start line.' expect(Gitlab::AppLogger).to receive(:error).with("Secure File Parser Failure (#{corrupt_file.id}): #{message}") corrupt_file.update_metadata! end diff --git a/spec/models/ci/sources/pipeline_spec.rb b/spec/models/ci/sources/pipeline_spec.rb index 707872d0a15..036708ed61e 100644 --- a/spec/models/ci/sources/pipeline_spec.rb +++ b/spec/models/ci/sources/pipeline_spec.rb @@ -6,6 +6,11 @@ RSpec.describe Ci::Sources::Pipeline, feature_category: :continuous_integration it { is_expected.to belong_to(:project) } it { is_expected.to belong_to(:pipeline) } + it do + is_expected.to belong_to(:build).class_name('Ci::Build') + .with_foreign_key(:source_job_id).inverse_of(:sourced_pipelines) + end + it { is_expected.to belong_to(:source_project).class_name('::Project') } it { is_expected.to belong_to(:source_job) } it { is_expected.to belong_to(:source_bridge) } @@ -32,7 +37,7 @@ RSpec.describe Ci::Sources::Pipeline, feature_category: :continuous_integration end end - describe 'partitioning', :ci_partitioning do + describe 'partitioning', :ci_partitionable do include Ci::PartitioningHelpers let(:new_pipeline) { create(:ci_pipeline) } diff --git a/spec/models/ci/stage_spec.rb b/spec/models/ci/stage_spec.rb index b392ab4ed11..79e92082ee1 100644 --- a/spec/models/ci/stage_spec.rb +++ b/spec/models/ci/stage_spec.rb @@ -223,10 +223,13 @@ RSpec.describe Ci::Stage, :models do with_them do before do statuses.each do |status| - create(:commit_status, project: stage.project, - pipeline: stage.pipeline, - stage_id: stage.id, - status: status) + create( + :commit_status, + project: stage.project, + pipeline: stage.pipeline, + stage_id: stage.id, + status: status + ) stage.update_legacy_status end @@ -239,11 +242,14 @@ RSpec.describe Ci::Stage, :models do context 'when stage has warnings' do before do - create(:ci_build, project: stage.project, - pipeline: stage.pipeline, - stage_id: stage.id, - status: :failed, - allow_failure: true) + create( + :ci_build, + project: stage.project, + pipeline: stage.pipeline, + stage_id: stage.id, + status: :failed, + allow_failure: true + ) stage.update_legacy_status end diff --git a/spec/models/ci/variable_spec.rb b/spec/models/ci/variable_spec.rb index ce64b3ea158..85327dbeb34 100644 --- a/spec/models/ci/variable_spec.rb +++ b/spec/models/ci/variable_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe Ci::Variable, feature_category: :pipeline_authoring do +RSpec.describe Ci::Variable, feature_category: :secrets_management do let_it_be_with_reload(:project) { create(:project) } subject { build(:ci_variable, project: project) } diff --git a/spec/models/clusters/agent_spec.rb b/spec/models/clusters/agent_spec.rb index de67bdb32aa..10081b955f4 100644 --- a/spec/models/clusters/agent_spec.rb +++ b/spec/models/clusters/agent_spec.rb @@ -2,16 +2,17 @@ require 'spec_helper' -RSpec.describe Clusters::Agent do +RSpec.describe Clusters::Agent, feature_category: :deployment_management do subject { create(:cluster_agent) } it { is_expected.to belong_to(:created_by_user).class_name('User').optional } it { is_expected.to belong_to(:project).class_name('::Project') } it { is_expected.to have_many(:agent_tokens).class_name('Clusters::AgentToken').order(Clusters::AgentToken.arel_table[:last_used_at].desc.nulls_last) } - it { is_expected.to have_many(:group_authorizations).class_name('Clusters::Agents::GroupAuthorization') } - it { is_expected.to have_many(:authorized_groups).through(:group_authorizations) } - it { is_expected.to have_many(:project_authorizations).class_name('Clusters::Agents::ProjectAuthorization') } - it { is_expected.to have_many(:authorized_projects).through(:project_authorizations).class_name('::Project') } + it { is_expected.to have_many(:active_agent_tokens).class_name('Clusters::AgentToken').conditions(status: 0).order(Clusters::AgentToken.arel_table[:last_used_at].desc.nulls_last) } + it { is_expected.to have_many(:ci_access_group_authorizations).class_name('Clusters::Agents::Authorizations::CiAccess::GroupAuthorization') } + it { is_expected.to have_many(:ci_access_authorized_groups).through(:ci_access_group_authorizations) } + it { is_expected.to have_many(:ci_access_project_authorizations).class_name('Clusters::Agents::Authorizations::CiAccess::ProjectAuthorization') } + it { is_expected.to have_many(:ci_access_authorized_projects).through(:ci_access_project_authorizations).class_name('::Project') } it { is_expected.to validate_presence_of(:name) } it { is_expected.to validate_length_of(:name).is_at_most(63) } @@ -163,4 +164,188 @@ RSpec.describe Clusters::Agent do it { is_expected.to be_like_time(event2.recorded_at) } end + + describe '#ci_access_authorized_for?' do + using RSpec::Parameterized::TableSyntax + + let_it_be(:organization) { create(:group) } + let_it_be(:agent_management_project) { create(:project, group: organization) } + let_it_be(:agent) { create(:cluster_agent, project: agent_management_project) } + let_it_be(:deployment_project) { create(:project, group: organization) } + + let(:user) { create(:user) } + + subject { agent.ci_access_authorized_for?(user) } + + it { is_expected.to eq(false) } + + context 'with project-level authorization' do + let!(:authorization) { create(:agent_ci_access_project_authorization, agent: agent, project: deployment_project) } + + where(:user_role, :allowed) do + :guest | false + :reporter | false + :developer | true + :maintainer | true + :owner | true + end + + with_them do + before do + deployment_project.add_member(user, user_role) + end + + it { is_expected.to eq(allowed) } + end + + context 'when expose_authorized_cluster_agents feature flag is disabled' do + before do + stub_feature_flags(expose_authorized_cluster_agents: false) + end + + it { is_expected.to eq(false) } + end + end + + context 'with group-level authorization' do + let!(:authorization) { create(:agent_ci_access_group_authorization, agent: agent, group: organization) } + + where(:user_role, :allowed) do + :guest | false + :reporter | false + :developer | true + :maintainer | true + :owner | true + end + + with_them do + before do + organization.add_member(user, user_role) + end + + it { is_expected.to eq(allowed) } + end + + context 'when expose_authorized_cluster_agents feature flag is disabled' do + before do + stub_feature_flags(expose_authorized_cluster_agents: false) + end + + it { is_expected.to eq(false) } + end + end + end + + describe '#user_access_authorized_for?' do + using RSpec::Parameterized::TableSyntax + + let_it_be(:organization) { create(:group) } + let_it_be(:agent_management_project) { create(:project, group: organization) } + let_it_be(:agent) { create(:cluster_agent, project: agent_management_project) } + let_it_be(:deployment_project) { create(:project, group: organization) } + + let(:user) { create(:user) } + + subject { agent.user_access_authorized_for?(user) } + + it { is_expected.to eq(false) } + + context 'with project-level authorization' do + let!(:authorization) { create(:agent_user_access_project_authorization, agent: agent, project: deployment_project) } + + where(:user_role, :allowed) do + :guest | false + :reporter | false + :developer | true + :maintainer | true + :owner | true + end + + with_them do + before do + deployment_project.add_member(user, user_role) + end + + it { is_expected.to eq(allowed) } + end + + context 'when expose_authorized_cluster_agents feature flag is disabled' do + before do + stub_feature_flags(expose_authorized_cluster_agents: false) + end + + it { is_expected.to eq(false) } + end + end + + context 'with group-level authorization' do + let!(:authorization) { create(:agent_user_access_group_authorization, agent: agent, group: organization) } + + where(:user_role, :allowed) do + :guest | false + :reporter | false + :developer | true + :maintainer | true + :owner | true + end + + with_them do + before do + organization.add_member(user, user_role) + end + + it { is_expected.to eq(allowed) } + end + + context 'when expose_authorized_cluster_agents feature flag is disabled' do + before do + stub_feature_flags(expose_authorized_cluster_agents: false) + end + + it { is_expected.to eq(false) } + end + end + end + + describe '#user_access_config' do + let_it_be(:group) { create(:group) } + let_it_be(:project) { create(:project) } + let_it_be_with_refind(:agent) { create(:cluster_agent, project: project) } + + subject { agent.user_access_config } + + it { is_expected.to be_nil } + + context 'with user_access project authorizations' do + before do + create(:agent_user_access_project_authorization, agent: agent, project: project, config: config) + end + + let(:config) { {} } + + it { is_expected.to eq(config) } + + context 'when access_as keyword exists' do + let(:config) { { 'access_as' => { 'agent' => {} } } } + + it { is_expected.to eq(config) } + end + end + + context 'with user_access group authorizations' do + before do + create(:agent_user_access_group_authorization, agent: agent, group: group, config: config) + end + + let(:config) { {} } + + it { is_expected.to eq(config) } + + context 'when access_as keyword exists' do + let(:config) { { 'access_as' => { 'agent' => {} } } } + + it { is_expected.to eq(config) } + end + end + end end diff --git a/spec/models/clusters/agent_token_spec.rb b/spec/models/clusters/agent_token_spec.rb index 74723e3abd8..41f8215b713 100644 --- a/spec/models/clusters/agent_token_spec.rb +++ b/spec/models/clusters/agent_token_spec.rb @@ -24,13 +24,29 @@ RSpec.describe Clusters::AgentToken do end end - describe '.with_status' do + describe 'status-related scopes' do let!(:active_token) { create(:cluster_agent_token) } let!(:revoked_token) { create(:cluster_agent_token, :revoked) } - subject { described_class.with_status(:active) } + describe '.with_status' do + context 'when filtering by active status' do + subject { described_class.with_status(:active) } - it { is_expected.to contain_exactly(active_token) } + it { is_expected.to contain_exactly(active_token) } + end + + context 'when filtering by revoked status' do + subject { described_class.with_status(:revoked) } + + it { is_expected.to contain_exactly(revoked_token) } + end + end + + describe '.active' do + subject { described_class.active } + + it { is_expected.to contain_exactly(active_token) } + end end end diff --git a/spec/models/clusters/agents/group_authorization_spec.rb b/spec/models/clusters/agents/authorizations/ci_access/group_authorization_spec.rb index baeb8f5464e..deabebde760 100644 --- a/spec/models/clusters/agents/group_authorization_spec.rb +++ b/spec/models/clusters/agents/authorizations/ci_access/group_authorization_spec.rb @@ -2,14 +2,14 @@ require 'spec_helper' -RSpec.describe Clusters::Agents::GroupAuthorization do +RSpec.describe Clusters::Agents::Authorizations::CiAccess::GroupAuthorization, feature_category: :deployment_management do it { is_expected.to belong_to(:agent).class_name('Clusters::Agent').required } it { is_expected.to belong_to(:group).class_name('::Group').required } it { expect(described_class).to validate_jsonb_schema(['config']) } describe '#config_project' do - let(:record) { create(:agent_group_authorization) } + let(:record) { create(:agent_ci_access_group_authorization) } it { expect(record.config_project).to eq(record.agent.project) } end diff --git a/spec/models/clusters/agents/implicit_authorization_spec.rb b/spec/models/clusters/agents/authorizations/ci_access/implicit_authorization_spec.rb index 1f4c5b1ac9e..427858c7529 100644 --- a/spec/models/clusters/agents/implicit_authorization_spec.rb +++ b/spec/models/clusters/agents/authorizations/ci_access/implicit_authorization_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe Clusters::Agents::ImplicitAuthorization do +RSpec.describe Clusters::Agents::Authorizations::CiAccess::ImplicitAuthorization, feature_category: :deployment_management do let_it_be(:agent) { create(:cluster_agent) } subject { described_class.new(agent: agent) } diff --git a/spec/models/clusters/agents/project_authorization_spec.rb b/spec/models/clusters/agents/authorizations/ci_access/project_authorization_spec.rb index 9ba259356c7..fe5f3cb10ea 100644 --- a/spec/models/clusters/agents/project_authorization_spec.rb +++ b/spec/models/clusters/agents/authorizations/ci_access/project_authorization_spec.rb @@ -2,14 +2,14 @@ require 'spec_helper' -RSpec.describe Clusters::Agents::ProjectAuthorization do +RSpec.describe Clusters::Agents::Authorizations::CiAccess::ProjectAuthorization, feature_category: :deployment_management do it { is_expected.to belong_to(:agent).class_name('Clusters::Agent').required } it { is_expected.to belong_to(:project).class_name('Project').required } it { expect(described_class).to validate_jsonb_schema(['config']) } describe '#config_project' do - let(:record) { create(:agent_project_authorization) } + let(:record) { create(:agent_ci_access_project_authorization) } it { expect(record.config_project).to eq(record.agent.project) } end diff --git a/spec/models/clusters/agents/authorizations/user_access/group_authorization_spec.rb b/spec/models/clusters/agents/authorizations/user_access/group_authorization_spec.rb new file mode 100644 index 00000000000..da94654268b --- /dev/null +++ b/spec/models/clusters/agents/authorizations/user_access/group_authorization_spec.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Clusters::Agents::Authorizations::UserAccess::GroupAuthorization, feature_category: :deployment_management do + it { is_expected.to belong_to(:agent).class_name('Clusters::Agent').required } + it { is_expected.to belong_to(:group).class_name('::Group').required } + + it { expect(described_class).to validate_jsonb_schema(['config']) } + + describe '.for_user' do + using RSpec::Parameterized::TableSyntax + + let_it_be(:group) { create(:group) } + let_it_be(:subgroup) { create(:group, parent: group) } + + let!(:authorization) { create(:agent_user_access_group_authorization, group: group) } + let(:user) { create(:user) } + + subject { described_class.for_user(user) } + + where(:user_role, :expected_access_level) do + :guest | nil + :reporter | nil + :developer | Gitlab::Access::DEVELOPER + :maintainer | Gitlab::Access::MAINTAINER + :owner | Gitlab::Access::OWNER + end + + with_them do + before do + group.add_member(user, user_role) + end + + it 'returns the expected result' do + if expected_access_level + expect(subject).to contain_exactly(authorization) + expect(subject.first.access_level).to eq(expected_access_level) + else + expect(subject).to be_empty + end + end + + context 'when authorization belongs to sub-group' do + let!(:authorization) { create(:agent_user_access_group_authorization, group: subgroup) } + + it 'respects the role inheritance' do + if expected_access_level + expect(subject).to contain_exactly(authorization) + expect(subject.first.access_level).to eq(expected_access_level) + else + expect(subject).to be_empty + end + end + + it 'respects the role override' do + subgroup.add_member(user, :owner) + + expect(subject).to contain_exactly(authorization) + expect(subject.first.access_level).to eq(Gitlab::Access::OWNER) + end + end + end + end + + describe '#config_project' do + let(:record) { create(:agent_user_access_group_authorization) } + + it { expect(record.config_project).to eq(record.agent.project) } + end +end diff --git a/spec/models/clusters/agents/authorizations/user_access/project_authorization_spec.rb b/spec/models/clusters/agents/authorizations/user_access/project_authorization_spec.rb new file mode 100644 index 00000000000..f6f6b328ad9 --- /dev/null +++ b/spec/models/clusters/agents/authorizations/user_access/project_authorization_spec.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Clusters::Agents::Authorizations::UserAccess::ProjectAuthorization, feature_category: :deployment_management do + it { is_expected.to belong_to(:agent).class_name('Clusters::Agent').required } + it { is_expected.to belong_to(:project).class_name('Project').required } + + it { expect(described_class).to validate_jsonb_schema(['config']) } + + describe '.for_user' do + using RSpec::Parameterized::TableSyntax + + let_it_be(:project) { create(:project) } + let_it_be(:authorization) { create(:agent_user_access_project_authorization, project: project) } + + let(:user) { create(:user) } + + subject { described_class.for_user(user) } + + where(:user_role, :expected_access_level) do + :guest | nil + :reporter | nil + :developer | Gitlab::Access::DEVELOPER + :maintainer | Gitlab::Access::MAINTAINER + :owner | Gitlab::Access::OWNER + end + + with_them do + before do + project.add_member(user, user_role) + end + + it 'returns the expected result' do + if expected_access_level + expect(subject).to contain_exactly(authorization) + expect(subject.first.access_level).to eq(expected_access_level) + else + expect(subject).to be_empty + end + end + end + end + + describe '#config_project' do + let(:record) { create(:agent_user_access_project_authorization) } + + it { expect(record.config_project).to eq(record.agent.project) } + end +end diff --git a/spec/models/clusters/applications/crossplane_spec.rb b/spec/models/clusters/applications/crossplane_spec.rb deleted file mode 100644 index d1abaa52c7f..00000000000 --- a/spec/models/clusters/applications/crossplane_spec.rb +++ /dev/null @@ -1,62 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe Clusters::Applications::Crossplane do - let(:crossplane) { create(:clusters_applications_crossplane) } - - include_examples 'cluster application core specs', :clusters_applications_crossplane - include_examples 'cluster application status specs', :clusters_applications_crossplane - include_examples 'cluster application version specs', :clusters_applications_crossplane - include_examples 'cluster application initial status specs' - - describe 'validations' do - it { is_expected.to validate_presence_of(:stack) } - end - - describe 'default values' do - it { expect(subject.version).to eq(described_class::VERSION) } - it { expect(subject.stack).to be_empty } - end - - describe '#can_uninstall?' do - subject { crossplane.can_uninstall? } - - it { is_expected.to be_truthy } - end - - describe '#install_command' do - let(:stack) { 'gcp' } - - subject { crossplane.install_command } - - it { is_expected.to be_an_instance_of(Gitlab::Kubernetes::Helm::V3::InstallCommand) } - - it 'is initialized with crossplane arguments' do - expect(subject.name).to eq('crossplane') - expect(subject.chart).to eq('crossplane/crossplane') - expect(subject.repository).to eq('https://charts.crossplane.io/alpha') - expect(subject.version).to eq('0.4.1') - expect(subject).to be_rbac - end - - context 'application failed to install previously' do - let(:crossplane) { create(:clusters_applications_crossplane, :errored, version: '0.0.1') } - - it 'is initialized with the locked version' do - expect(subject.version).to eq('0.4.1') - end - end - end - - describe '#files' do - let(:application) { crossplane } - let(:values) { subject[:'values.yaml'] } - - subject { application.files } - - it 'includes crossplane specific keys in the values.yaml file' do - expect(values).to include('clusterStacks') - end - end -end diff --git a/spec/models/clusters/applications/helm_spec.rb b/spec/models/clusters/applications/helm_spec.rb deleted file mode 100644 index 1b8be92475a..00000000000 --- a/spec/models/clusters/applications/helm_spec.rb +++ /dev/null @@ -1,116 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe Clusters::Applications::Helm do - include_examples 'cluster application core specs', :clusters_applications_helm - - describe 'default values' do - it { expect(subject.version).to eq(Gitlab::Kubernetes::Helm::V2::BaseCommand::HELM_VERSION) } - end - - describe '.available' do - subject { described_class.available } - - let!(:installed_cluster) { create(:clusters_applications_helm, :installed) } - let!(:updated_cluster) { create(:clusters_applications_helm, :updated) } - - before do - create(:clusters_applications_helm, :errored) - end - - it { is_expected.to contain_exactly(installed_cluster, updated_cluster) } - end - - describe '#can_uninstall?' do - subject(:application) { build(:clusters_applications_helm).can_uninstall? } - - it { is_expected.to eq true } - end - - describe '#issue_client_cert' do - let(:application) { create(:clusters_applications_helm) } - - subject { application.issue_client_cert } - - it 'returns a new cert' do - is_expected.to be_kind_of(Gitlab::Kubernetes::Helm::V2::Certificate) - expect(subject.cert_string).not_to eq(application.ca_cert) - expect(subject.key_string).not_to eq(application.ca_key) - end - end - - describe '#install_command' do - let(:helm) { create(:clusters_applications_helm) } - - subject { helm.install_command } - - it { is_expected.to be_an_instance_of(Gitlab::Kubernetes::Helm::V2::InitCommand) } - - it 'is initialized with 1 arguments' do - expect(subject.name).to eq('helm') - end - - it 'has cert files' do - expect(subject.files[:'ca.pem']).to be_present - expect(subject.files[:'ca.pem']).to eq(helm.ca_cert) - - expect(subject.files[:'cert.pem']).to be_present - expect(subject.files[:'key.pem']).to be_present - - cert = OpenSSL::X509::Certificate.new(subject.files[:'cert.pem']) - expect(cert.not_after).to be > 999.years.from_now - end - - describe 'rbac' do - context 'rbac cluster' do - it { expect(subject).to be_rbac } - end - - context 'non rbac cluster' do - before do - helm.cluster.platform_kubernetes.abac! - end - - it { expect(subject).not_to be_rbac } - end - end - end - - describe '#uninstall_command' do - let(:helm) { create(:clusters_applications_helm) } - - subject { helm.uninstall_command } - - it { is_expected.to be_an_instance_of(Gitlab::Kubernetes::Helm::V2::ResetCommand) } - - it 'has name' do - expect(subject.name).to eq('helm') - end - - it 'has cert files' do - expect(subject.files[:'ca.pem']).to be_present - expect(subject.files[:'ca.pem']).to eq(helm.ca_cert) - - expect(subject.files[:'cert.pem']).to be_present - expect(subject.files[:'key.pem']).to be_present - - cert = OpenSSL::X509::Certificate.new(subject.files[:'cert.pem']) - expect(cert.not_after).to be > 999.years.from_now - end - - describe 'rbac' do - context 'rbac cluster' do - it { expect(subject).to be_rbac } - end - - context 'non rbac cluster' do - before do - helm.cluster.platform_kubernetes.abac! - end - - it { expect(subject).not_to be_rbac } - end - end - end -end diff --git a/spec/models/clusters/applications/ingress_spec.rb b/spec/models/clusters/applications/ingress_spec.rb deleted file mode 100644 index 2be59e5f515..00000000000 --- a/spec/models/clusters/applications/ingress_spec.rb +++ /dev/null @@ -1,180 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe Clusters::Applications::Ingress do - let(:ingress) { create(:clusters_applications_ingress) } - - before do - allow(ClusterWaitForIngressIpAddressWorker).to receive(:perform_in) - allow(ClusterWaitForIngressIpAddressWorker).to receive(:perform_async) - end - - it_behaves_like 'having unique enum values' - - include_examples 'cluster application core specs', :clusters_applications_ingress - include_examples 'cluster application status specs', :clusters_applications_ingress - include_examples 'cluster application version specs', :clusters_applications_ingress - include_examples 'cluster application helm specs', :clusters_applications_ingress - include_examples 'cluster application initial status specs' - - describe 'default values' do - it { expect(subject.ingress_type).to eq("nginx") } - it { expect(subject.version).to eq(described_class::VERSION) } - end - - describe '#can_uninstall?' do - subject { ingress.can_uninstall? } - - context 'with jupyter installed' do - before do - create(:clusters_applications_jupyter, :installed, cluster: ingress.cluster) - end - - it 'returns false if external_ip_or_hostname? is true' do - ingress.external_ip = 'IP' - - is_expected.to be_falsey - end - - it 'returns false if external_ip_or_hostname? is false' do - is_expected.to be_falsey - end - end - - context 'with jupyter installable' do - before do - create(:clusters_applications_jupyter, :installable, cluster: ingress.cluster) - end - - it 'returns true if external_ip_or_hostname? is true' do - ingress.external_ip = 'IP' - - is_expected.to be_truthy - end - - it 'returns false if external_ip_or_hostname? is false' do - is_expected.to be_falsey - end - end - - context 'with jupyter nil' do - it 'returns false if external_ip_or_hostname? is false' do - is_expected.to be_falsey - end - - context 'if external_ip_or_hostname? is true' do - context 'with IP' do - before do - ingress.external_ip = 'IP' - end - - it { is_expected.to be_truthy } - end - - context 'with hostname' do - before do - ingress.external_hostname = 'example.com' - end - - it { is_expected.to be_truthy } - end - end - end - end - - describe '#make_installed!' do - before do - application.make_installed! - end - - let(:application) { create(:clusters_applications_ingress, :installing) } - - it 'schedules a ClusterWaitForIngressIpAddressWorker' do - expect(ClusterWaitForIngressIpAddressWorker).to have_received(:perform_in) - .with(Clusters::Applications::Ingress::FETCH_IP_ADDRESS_DELAY, 'ingress', application.id) - end - end - - describe '#schedule_status_update' do - let(:application) { create(:clusters_applications_ingress, :installed) } - - before do - application.schedule_status_update - end - - it 'schedules a ClusterWaitForIngressIpAddressWorker' do - expect(ClusterWaitForIngressIpAddressWorker).to have_received(:perform_async) - .with('ingress', application.id) - end - - context 'when the application is not installed' do - let(:application) { create(:clusters_applications_ingress, :installing) } - - it 'does not schedule a ClusterWaitForIngressIpAddressWorker' do - expect(ClusterWaitForIngressIpAddressWorker).not_to have_received(:perform_async) - end - end - - context 'when there is already an external_ip' do - let(:application) { create(:clusters_applications_ingress, :installed, external_ip: '111.222.222.111') } - - it 'does not schedule a ClusterWaitForIngressIpAddressWorker' do - expect(ClusterWaitForIngressIpAddressWorker).not_to have_received(:perform_in) - end - end - - context 'when there is already an external_hostname' do - let(:application) { create(:clusters_applications_ingress, :installed, external_hostname: 'localhost.localdomain') } - - it 'does not schedule a ClusterWaitForIngressIpAddressWorker' do - expect(ClusterWaitForIngressIpAddressWorker).not_to have_received(:perform_in) - end - end - end - - describe '#install_command' do - subject { ingress.install_command } - - it { is_expected.to be_an_instance_of(Gitlab::Kubernetes::Helm::V3::InstallCommand) } - - it 'is initialized with ingress arguments' do - expect(subject.name).to eq('ingress') - expect(subject.chart).to eq('ingress/nginx-ingress') - expect(subject.version).to eq('1.40.2') - expect(subject).to be_rbac - expect(subject.files).to eq(ingress.files) - end - - context 'on a non rbac enabled cluster' do - before do - ingress.cluster.platform_kubernetes.abac! - end - - it { is_expected.not_to be_rbac } - end - - context 'application failed to install previously' do - let(:ingress) { create(:clusters_applications_ingress, :errored, version: 'nginx') } - - it 'is initialized with the locked version' do - expect(subject.version).to eq('1.40.2') - end - end - end - - describe '#files' do - let(:application) { ingress } - let(:values) { subject[:'values.yaml'] } - - subject { application.files } - - it 'includes ingress valid keys in values' do - expect(values).to include('image') - expect(values).to include('repository') - expect(values).to include('stats') - expect(values).to include('podAnnotations') - expect(values).to include('clusterIP') - end - end -end diff --git a/spec/models/clusters/applications/jupyter_spec.rb b/spec/models/clusters/applications/jupyter_spec.rb deleted file mode 100644 index 9336d2352f8..00000000000 --- a/spec/models/clusters/applications/jupyter_spec.rb +++ /dev/null @@ -1,130 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe Clusters::Applications::Jupyter do - include_examples 'cluster application core specs', :clusters_applications_jupyter - include_examples 'cluster application status specs', :clusters_applications_jupyter - include_examples 'cluster application version specs', :clusters_applications_jupyter - include_examples 'cluster application helm specs', :clusters_applications_jupyter - - it { is_expected.to belong_to(:oauth_application) } - - describe 'default values' do - it { expect(subject.version).to eq(described_class::VERSION) } - end - - describe '#can_uninstall?' do - let(:ingress) { create(:clusters_applications_ingress, :installed, external_hostname: 'localhost.localdomain') } - let(:jupyter) { create(:clusters_applications_jupyter, cluster: ingress.cluster) } - - subject { jupyter.can_uninstall? } - - it { is_expected.to be_truthy } - end - - describe '#set_initial_status' do - before do - jupyter.set_initial_status - end - - context 'when ingress is not installed' do - let(:cluster) { create(:cluster, :provided_by_gcp) } - let(:jupyter) { create(:clusters_applications_jupyter, cluster: cluster) } - - it { expect(jupyter).to be_not_installable } - end - - context 'when ingress is installed and external_ip is assigned' do - let(:ingress) { create(:clusters_applications_ingress, :installed, external_ip: '127.0.0.1') } - let(:jupyter) { create(:clusters_applications_jupyter, cluster: ingress.cluster) } - - it { expect(jupyter).to be_installable } - end - - context 'when ingress is installed and external_hostname is assigned' do - let(:ingress) { create(:clusters_applications_ingress, :installed, external_hostname: 'localhost.localdomain') } - let(:jupyter) { create(:clusters_applications_jupyter, cluster: ingress.cluster) } - - it { expect(jupyter).to be_installable } - end - end - - describe '#install_command' do - let!(:ingress) { create(:clusters_applications_ingress, :installed, external_ip: '127.0.0.1') } - let!(:jupyter) { create(:clusters_applications_jupyter, cluster: ingress.cluster) } - - subject { jupyter.install_command } - - it { is_expected.to be_an_instance_of(Gitlab::Kubernetes::Helm::V3::InstallCommand) } - - it 'is initialized with 4 arguments' do - expect(subject.name).to eq('jupyter') - expect(subject.chart).to eq('jupyter/jupyterhub') - expect(subject.version).to eq('0.9.0') - - expect(subject).to be_rbac - expect(subject.repository).to eq('https://jupyterhub.github.io/helm-chart/') - expect(subject.files).to eq(jupyter.files) - end - - context 'on a non rbac enabled cluster' do - before do - jupyter.cluster.platform_kubernetes.abac! - end - - it { is_expected.not_to be_rbac } - end - - context 'application failed to install previously' do - let(:jupyter) { create(:clusters_applications_jupyter, :errored, version: '0.0.1') } - - it 'is initialized with the locked version' do - expect(subject.version).to eq('0.9.0') - end - end - end - - describe '#files' do - let(:cluster) { create(:cluster, :with_installed_helm, :provided_by_gcp, :project) } - let(:application) { create(:clusters_applications_jupyter, cluster: cluster) } - let(:values) { subject[:'values.yaml'] } - - subject { application.files } - - context 'when cluster belongs to a project' do - it 'includes valid values' do - expect(values).to include('ingress') - expect(values).to include('hub') - expect(values).to include('proxy') - expect(values).to include('auth') - expect(values).to include('singleuser') - expect(values).to match(/clientId: '?#{application.oauth_application.uid}/) - expect(values).to match(/callbackUrl: '?#{application.callback_url}/) - expect(values).to include("gitlabProjectIdWhitelist:\n - #{application.cluster.project.id}") - expect(values).to include("c.GitLabOAuthenticator.scope = ['api read_repository write_repository']") - expect(values).to match(/GITLAB_HOST: '?#{Gitlab.config.gitlab.host}/) - expect(values).to match(/GITLAB_CLUSTER_ID: '?#{application.cluster.id}/) - end - end - - context 'when cluster belongs to a group' do - let(:group) { create(:group) } - let(:cluster) { create(:cluster, :with_installed_helm, :provided_by_gcp, :group, groups: [group]) } - - it 'includes valid values' do - expect(values).to include('ingress') - expect(values).to include('hub') - expect(values).to include('proxy') - expect(values).to include('auth') - expect(values).to include('singleuser') - expect(values).to match(/clientId: '?#{application.oauth_application.uid}/) - expect(values).to match(/callbackUrl: '?#{application.callback_url}/) - expect(values).to include("gitlabGroupWhitelist:\n - #{group.to_param}") - expect(values).to include("c.GitLabOAuthenticator.scope = ['api read_repository write_repository']") - expect(values).to match(/GITLAB_HOST: '?#{Gitlab.config.gitlab.host}/) - expect(values).to match(/GITLAB_CLUSTER_ID: '?#{application.cluster.id}/) - end - end - end -end diff --git a/spec/models/clusters/applications/knative_spec.rb b/spec/models/clusters/applications/knative_spec.rb deleted file mode 100644 index f6b13f4a93f..00000000000 --- a/spec/models/clusters/applications/knative_spec.rb +++ /dev/null @@ -1,260 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe Clusters::Applications::Knative do - let(:knative) { create(:clusters_applications_knative) } - - before do - allow(ClusterWaitForIngressIpAddressWorker).to receive(:perform_in) - allow(ClusterWaitForIngressIpAddressWorker).to receive(:perform_async) - allow(ClusterConfigureIstioWorker).to receive(:perform_async) - end - - include_examples 'cluster application core specs', :clusters_applications_knative - include_examples 'cluster application status specs', :clusters_applications_knative - include_examples 'cluster application helm specs', :clusters_applications_knative - include_examples 'cluster application version specs', :clusters_applications_knative - include_examples 'cluster application initial status specs' - - describe 'associations' do - it { is_expected.to have_one(:serverless_domain_cluster).class_name('::Serverless::DomainCluster').with_foreign_key('clusters_applications_knative_id').inverse_of(:knative) } - end - - describe 'default values' do - it { expect(subject.version).to eq(described_class::VERSION) } - end - - describe 'when cloud run is enabled' do - let(:cluster) { create(:cluster, :provided_by_gcp, :cloud_run_enabled) } - let(:knative_cloud_run) { create(:clusters_applications_knative, cluster: cluster) } - - it { expect(knative_cloud_run).to be_not_installable } - end - - describe 'when rbac is not enabled' do - let(:cluster) { create(:cluster, :provided_by_gcp, :rbac_disabled) } - let(:knative_no_rbac) { create(:clusters_applications_knative, cluster: cluster) } - - it { expect(knative_no_rbac).to be_not_installable } - end - - describe 'make_installed with external_ip' do - before do - application.make_installed! - end - - let(:application) { create(:clusters_applications_knative, :installing) } - - it 'schedules a ClusterWaitForIngressIpAddressWorker' do - expect(ClusterWaitForIngressIpAddressWorker).to have_received(:perform_in) - .with(Clusters::Applications::Knative::FETCH_IP_ADDRESS_DELAY, 'knative', application.id) - end - end - - describe 'configuring istio ingress gateway' do - context 'after installed' do - let(:application) { create(:clusters_applications_knative, :installing) } - - before do - application.make_installed! - end - - it 'schedules a ClusterConfigureIstioWorker' do - expect(ClusterConfigureIstioWorker).to have_received(:perform_async).with(application.cluster_id) - end - end - - context 'after updated' do - let(:application) { create(:clusters_applications_knative, :updating) } - - before do - application.make_installed! - end - - it 'schedules a ClusterConfigureIstioWorker' do - expect(ClusterConfigureIstioWorker).to have_received(:perform_async).with(application.cluster_id) - end - end - end - - describe '#can_uninstall?' do - subject { knative.can_uninstall? } - - it { is_expected.to be_truthy } - end - - describe '#schedule_status_update with external_ip' do - let(:application) { create(:clusters_applications_knative, :installed) } - - before do - application.schedule_status_update - end - - it 'schedules a ClusterWaitForIngressIpAddressWorker' do - expect(ClusterWaitForIngressIpAddressWorker).to have_received(:perform_async) - .with('knative', application.id) - end - - context 'when the application is not installed' do - let(:application) { create(:clusters_applications_knative, :installing) } - - it 'does not schedule a ClusterWaitForIngressIpAddressWorker' do - expect(ClusterWaitForIngressIpAddressWorker).not_to have_received(:perform_async) - end - end - - context 'when there is already an external_ip' do - let(:application) { create(:clusters_applications_knative, :installed, external_ip: '111.222.222.111') } - - it 'does not schedule a ClusterWaitForIngressIpAddressWorker' do - expect(ClusterWaitForIngressIpAddressWorker).not_to have_received(:perform_in) - end - end - - context 'when there is already an external_hostname' do - let(:application) { create(:clusters_applications_knative, :installed, external_hostname: 'localhost.localdomain') } - - it 'does not schedule a ClusterWaitForIngressIpAddressWorker' do - expect(ClusterWaitForIngressIpAddressWorker).not_to have_received(:perform_in) - end - end - end - - shared_examples 'a command' do - it 'is an instance of Helm::InstallCommand' do - expect(subject).to be_an_instance_of(Gitlab::Kubernetes::Helm::V3::InstallCommand) - end - - it 'is initialized with knative arguments' do - expect(subject.name).to eq('knative') - expect(subject.chart).to eq('knative/knative') - expect(subject.files).to eq(knative.files) - end - - it 'does not install metrics for prometheus' do - expect(subject.postinstall).to be_empty - end - - context 'with prometheus installed' do - let(:prometheus) { create(:clusters_applications_prometheus, :installed) } - let(:knative) { create(:clusters_applications_knative, cluster: prometheus.cluster) } - - subject { knative.install_command } - - it 'installs metrics' do - expect(subject.postinstall).not_to be_empty - expect(subject.postinstall.length).to be(1) - expect(subject.postinstall[0]).to eql("kubectl apply -f #{Clusters::Applications::Knative::METRICS_CONFIG}") - end - end - end - - describe '#install_command' do - subject { knative.install_command } - - it 'is initialized with latest version' do - expect(subject.version).to eq('0.10.0') - end - - it_behaves_like 'a command' - end - - describe '#update_command' do - let!(:current_installed_version) { knative.version = '0.1.0' } - - subject { knative.update_command } - - it 'is initialized with current version' do - expect(subject.version).to eq(current_installed_version) - end - - it_behaves_like 'a command' - end - - describe '#uninstall_command' do - subject { knative.uninstall_command } - - it { is_expected.to be_an_instance_of(Gitlab::Kubernetes::Helm::V3::DeleteCommand) } - - it "removes knative deployed services before uninstallation" do - 2.times do |i| - cluster_project = create(:cluster_project, cluster: knative.cluster) - - create(:cluster_kubernetes_namespace, - cluster: cluster_project.cluster, - cluster_project: cluster_project, - project: cluster_project.project, - namespace: "namespace_#{i}") - end - - remove_namespaced_services_script = [ - "kubectl delete ksvc --all -n #{knative.cluster.kubernetes_namespaces.first.namespace}", - "kubectl delete ksvc --all -n #{knative.cluster.kubernetes_namespaces.second.namespace}" - ] - - expect(subject.predelete).to match_array(remove_namespaced_services_script) - end - - it "initializes command with all necessary postdelete script" do - api_groups = YAML.safe_load(File.read(Rails.root.join(Clusters::Applications::Knative::API_GROUPS_PATH))) - - remove_knative_istio_leftovers_script = [ - "kubectl delete --ignore-not-found ns knative-serving", - "kubectl delete --ignore-not-found ns knative-build" - ] - - full_delete_commands_size = api_groups.size + remove_knative_istio_leftovers_script.size - - expect(subject.postdelete).to include(*remove_knative_istio_leftovers_script) - expect(subject.postdelete.size).to eq(full_delete_commands_size) - expect(subject.postdelete[2]).to include("kubectl api-resources -o name --api-group #{api_groups[0]} | xargs -r kubectl delete --ignore-not-found crd") - expect(subject.postdelete[3]).to include("kubectl api-resources -o name --api-group #{api_groups[1]} | xargs -r kubectl delete --ignore-not-found crd") - end - end - - describe '#files' do - let(:application) { knative } - let(:values) { subject[:'values.yaml'] } - - subject { application.files } - - it 'includes knative specific keys in the values.yaml file' do - expect(values).to include('domain') - end - end - - describe 'validations' do - it { is_expected.to validate_presence_of(:hostname) } - end - - describe '#available_domains' do - let!(:domain) { create(:pages_domain, :instance_serverless) } - - it 'returns all instance serverless domains' do - expect(PagesDomain).to receive(:instance_serverless).and_call_original - - domains = subject.available_domains - - expect(domains.length).to eq(1) - expect(domains).to include(domain) - end - end - - describe '#find_available_domain' do - let!(:domain) { create(:pages_domain, :instance_serverless) } - - it 'returns the domain scoped to available domains' do - expect(subject).to receive(:available_domains).and_call_original - expect(subject.find_available_domain(domain.id)).to eq(domain) - end - end - - describe '#pages_domain' do - let!(:sdc) { create(:serverless_domain_cluster, knative: knative) } - - it 'returns the the associated pages domain' do - expect(knative.reload.pages_domain).to eq(sdc.pages_domain) - end - end -end diff --git a/spec/models/clusters/applications/prometheus_spec.rb b/spec/models/clusters/applications/prometheus_spec.rb deleted file mode 100644 index 15c3162270e..00000000000 --- a/spec/models/clusters/applications/prometheus_spec.rb +++ /dev/null @@ -1,349 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe Clusters::Applications::Prometheus do - include KubernetesHelpers - include StubRequests - - include_examples 'cluster application core specs', :clusters_applications_prometheus - include_examples 'cluster application status specs', :clusters_applications_prometheus - include_examples 'cluster application version specs', :clusters_applications_prometheus - include_examples 'cluster application helm specs', :clusters_applications_prometheus - include_examples 'cluster application initial status specs' - - describe 'default values' do - subject(:prometheus) { build(:clusters_applications_prometheus) } - - it { expect(prometheus.alert_manager_token).to be_an_instance_of(String) } - it { expect(prometheus.version).to eq(described_class::VERSION) } - end - - describe 'after_destroy' do - let(:cluster) { create(:cluster, :with_installed_helm) } - let(:application) { create(:clusters_applications_prometheus, :installed, cluster: cluster) } - - it 'disables the corresponding integration' do - application.destroy! - - expect(cluster.integration_prometheus).not_to be_enabled - end - end - - describe 'transition to installed' do - let(:project) { create(:project) } - let(:cluster) { create(:cluster, :with_installed_helm) } - let(:application) { create(:clusters_applications_prometheus, :installing, cluster: cluster) } - - it 'enables the corresponding integration' do - application.make_installed - - expect(cluster.integration_prometheus).to be_enabled - end - end - - describe 'transition to externally_installed' do - let(:project) { create(:project) } - let(:cluster) { create(:cluster, :with_installed_helm) } - let(:application) { create(:clusters_applications_prometheus, :installing, cluster: cluster) } - - it 'enables the corresponding integration' do - application.make_externally_installed! - - expect(cluster.integration_prometheus).to be_enabled - end - end - - describe 'transition to updating' do - let(:project) { create(:project) } - let(:cluster) { create(:cluster, projects: [project]) } - - subject { create(:clusters_applications_prometheus, :installed, cluster: cluster) } - - it 'sets last_update_started_at to now' do - freeze_time do - expect { subject.make_updating }.to change { subject.reload.last_update_started_at }.to be_within(1.second).of(Time.current) - end - end - end - - describe '#managed_prometheus?' do - subject { prometheus.managed_prometheus? } - - let(:prometheus) { build(:clusters_applications_prometheus) } - - it { is_expected.to be_truthy } - - context 'externally installed' do - let(:prometheus) { build(:clusters_applications_prometheus, :externally_installed) } - - it { is_expected.to be_falsey } - end - - context 'uninstalled' do - let(:prometheus) { build(:clusters_applications_prometheus, :uninstalled) } - - it { is_expected.to be_falsey } - end - end - - describe '#can_uninstall?' do - let(:prometheus) { create(:clusters_applications_prometheus) } - - subject { prometheus.can_uninstall? } - - it { is_expected.to be_truthy } - end - - describe '#prometheus_client' do - include_examples '#prometheus_client shared' do - let(:factory) { :clusters_applications_prometheus } - end - end - - describe '#install_command' do - let(:prometheus) { create(:clusters_applications_prometheus) } - - subject { prometheus.install_command } - - it { is_expected.to be_an_instance_of(Gitlab::Kubernetes::Helm::V3::InstallCommand) } - - it 'is initialized with 3 arguments' do - expect(subject.name).to eq('prometheus') - expect(subject.chart).to eq('prometheus/prometheus') - expect(subject.version).to eq('10.4.1') - expect(subject).to be_rbac - expect(subject.files).to eq(prometheus.files) - end - - context 'on a non rbac enabled cluster' do - before do - prometheus.cluster.platform_kubernetes.abac! - end - - it { is_expected.not_to be_rbac } - end - - context 'application failed to install previously' do - let(:prometheus) { create(:clusters_applications_prometheus, :errored, version: '2.0.0') } - - it 'is initialized with the locked version' do - expect(subject.version).to eq('10.4.1') - end - end - - it 'does not install knative metrics' do - expect(subject.postinstall).to be_empty - end - - context 'with knative installed' do - let(:knative) { create(:clusters_applications_knative, :updated) } - let(:prometheus) { create(:clusters_applications_prometheus, cluster: knative.cluster) } - - subject { prometheus.install_command } - - it 'installs knative metrics' do - expect(subject.postinstall).to include("kubectl apply -f #{Clusters::Applications::Knative::METRICS_CONFIG}") - end - end - end - - describe '#uninstall_command' do - let(:prometheus) { create(:clusters_applications_prometheus) } - - subject { prometheus.uninstall_command } - - it { is_expected.to be_an_instance_of(Gitlab::Kubernetes::Helm::V3::DeleteCommand) } - - it 'has the application name' do - expect(subject.name).to eq('prometheus') - end - - it 'has files' do - expect(subject.files).to eq(prometheus.files) - end - - it 'is rbac' do - expect(subject).to be_rbac - end - - describe '#predelete' do - let(:knative) { create(:clusters_applications_knative, :updated) } - let(:prometheus) { create(:clusters_applications_prometheus, cluster: knative.cluster) } - - subject { prometheus.uninstall_command.predelete } - - it 'deletes knative metrics' do - metrics_config = Clusters::Applications::Knative::METRICS_CONFIG - is_expected.to include("kubectl delete -f #{metrics_config} --ignore-not-found") - end - end - - context 'on a non rbac enabled cluster' do - before do - prometheus.cluster.platform_kubernetes.abac! - end - - it { is_expected.not_to be_rbac } - end - end - - describe '#patch_command' do - subject(:patch_command) { prometheus.patch_command(values) } - - let(:prometheus) { build(:clusters_applications_prometheus) } - let(:values) { prometheus.values } - - it { is_expected.to be_an_instance_of(::Gitlab::Kubernetes::Helm::V3::PatchCommand) } - - it 'is initialized with 3 arguments' do - expect(patch_command.name).to eq('prometheus') - expect(patch_command.chart).to eq('prometheus/prometheus') - expect(patch_command.version).to eq('10.4.1') - expect(patch_command.files).to eq(prometheus.files) - end - end - - describe '#update_in_progress?' do - context 'when app is updating' do - it 'returns true' do - cluster = create(:cluster) - prometheus_app = build(:clusters_applications_prometheus, :updating, cluster: cluster) - - expect(prometheus_app.update_in_progress?).to be true - end - end - end - - describe '#update_errored?' do - context 'when app errored' do - it 'returns true' do - cluster = create(:cluster) - prometheus_app = build(:clusters_applications_prometheus, :update_errored, cluster: cluster) - - expect(prometheus_app.update_errored?).to be true - end - end - end - - describe '#files' do - let(:application) { create(:clusters_applications_prometheus) } - let(:values) { subject[:'values.yaml'] } - - subject { application.files } - - it 'includes prometheus valid values' do - expect(values).to include('alertmanager') - expect(values).to include('kubeStateMetrics') - expect(values).to include('nodeExporter') - expect(values).to include('pushgateway') - expect(values).to include('serverFiles') - end - end - - describe '#files_with_replaced_values' do - let(:application) { build(:clusters_applications_prometheus) } - let(:files) { application.files } - - subject { application.files_with_replaced_values({ hello: :world }) } - - it 'does not modify #files' do - expect(subject[:'values.yaml']).not_to eq(files[:'values.yaml']) - - expect(files[:'values.yaml']).to eq(application.values) - end - - it 'returns values.yaml with replaced values' do - expect(subject[:'values.yaml']).to eq({ hello: :world }) - end - - it 'uses values from #files, except for values.yaml' do - allow(application).to receive(:files).and_return({ - 'values.yaml': 'some value specific to files', - 'file_a.txt': 'file_a', - 'file_b.txt': 'file_b' - }) - - expect(subject.except(:'values.yaml')).to eq({ - 'file_a.txt': 'file_a', - 'file_b.txt': 'file_b' - }) - end - end - - describe '#configured?' do - let(:prometheus) { create(:clusters_applications_prometheus, :installed, cluster: cluster) } - - subject { prometheus.configured? } - - context 'when a kubenetes client is present' do - let(:cluster) { create(:cluster, :project, :provided_by_gcp) } - - it { is_expected.to be_truthy } - - context 'when it is not availalble' do - let(:prometheus) { create(:clusters_applications_prometheus, cluster: cluster) } - - it { is_expected.to be_falsey } - end - - context 'when the kubernetes URL is blocked' do - before do - blocked_ip = '127.0.0.1' # localhost addresses are blocked by default - - stub_all_dns(cluster.platform.api_url, ip_address: blocked_ip) - end - - it { is_expected.to be_falsey } - end - end - - context 'when a kubenetes client is not present' do - let(:cluster) { create(:cluster) } - - it { is_expected.to be_falsy } - end - end - - describe '#updated_since?' do - let(:cluster) { create(:cluster) } - let(:prometheus_app) { build(:clusters_applications_prometheus, cluster: cluster) } - let(:timestamp) { Time.current - 5.minutes } - - around do |example| - freeze_time { example.run } - end - - before do - prometheus_app.last_update_started_at = Time.current - end - - context 'when app does not have status failed' do - it 'returns true when last update started after the timestamp' do - expect(prometheus_app.updated_since?(timestamp)).to be true - end - - it 'returns false when last update started before the timestamp' do - expect(prometheus_app.updated_since?(Time.current + 5.minutes)).to be false - end - end - - context 'when app has status failed' do - it 'returns false when last update started after the timestamp' do - prometheus_app.status = 6 - - expect(prometheus_app.updated_since?(timestamp)).to be false - end - end - end - - describe 'alert manager token' do - subject { create(:clusters_applications_prometheus) } - - it 'is autogenerated on creation' do - expect(subject.alert_manager_token).to match(/\A\h{32}\z/) - expect(subject.encrypted_alert_manager_token).not_to be_nil - expect(subject.encrypted_alert_manager_token_iv).not_to be_nil - end - end -end diff --git a/spec/models/clusters/applications/runner_spec.rb b/spec/models/clusters/applications/runner_spec.rb deleted file mode 100644 index 04b5ae9641d..00000000000 --- a/spec/models/clusters/applications/runner_spec.rb +++ /dev/null @@ -1,127 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe Clusters::Applications::Runner do - let(:ci_runner) { create(:ci_runner) } - - include_examples 'cluster application core specs', :clusters_applications_runner - include_examples 'cluster application status specs', :clusters_applications_runner - include_examples 'cluster application version specs', :clusters_applications_runner - include_examples 'cluster application helm specs', :clusters_applications_runner - include_examples 'cluster application initial status specs' - - it { is_expected.to belong_to(:runner) } - - describe 'default values' do - it { expect(subject.version).to eq(described_class::VERSION) } - end - - describe '#can_uninstall?' do - let(:gitlab_runner) { create(:clusters_applications_runner, runner: ci_runner) } - - subject { gitlab_runner.can_uninstall? } - - it { is_expected.to be_truthy } - end - - describe '#install_command' do - let(:kubeclient) { double('kubernetes client') } - let(:gitlab_runner) { create(:clusters_applications_runner, runner: ci_runner) } - - subject { gitlab_runner.install_command } - - it { is_expected.to be_an_instance_of(Gitlab::Kubernetes::Helm::V3::InstallCommand) } - - it 'is initialized with 4 arguments' do - expect(subject.name).to eq('runner') - expect(subject.chart).to eq('runner/gitlab-runner') - expect(subject.version).to eq(Clusters::Applications::Runner::VERSION) - expect(subject).to be_rbac - expect(subject.repository).to eq('https://charts.gitlab.io') - expect(subject.files).to eq(gitlab_runner.files) - end - - context 'on a non rbac enabled cluster' do - before do - gitlab_runner.cluster.platform_kubernetes.abac! - end - - it { is_expected.not_to be_rbac } - end - - context 'application failed to install previously' do - let(:gitlab_runner) { create(:clusters_applications_runner, :errored, runner: ci_runner, version: '0.1.13') } - - it 'is initialized with the locked version' do - expect(subject.version).to eq(Clusters::Applications::Runner::VERSION) - end - end - end - - describe '#files' do - let(:application) { create(:clusters_applications_runner, runner: ci_runner) } - let(:values) { subject[:'values.yaml'] } - - subject { application.files } - - it 'includes runner valid values' do - expect(values).to include('concurrent') - expect(values).to include('checkInterval') - expect(values).to include('rbac') - expect(values).to include('runners') - expect(values).to include('privileged: true') - expect(values).to include('image: ubuntu:16.04') - expect(values).to include('resources') - expect(values).to match(/gitlabUrl: ['"]?#{Regexp.escape(Gitlab::Routing.url_helpers.root_url)}/) - end - - context 'with duplicated values on vendor/runner/values.yaml' do - let(:stub_values) do - { - "concurrent" => 4, - "checkInterval" => 3, - "rbac" => { - "create" => false - }, - "clusterWideAccess" => false, - "runners" => { - "privileged" => false, - "image" => "ubuntu:16.04", - "builds" => {}, - "services" => {}, - "helpers" => {} - } - } - end - - before do - allow(application).to receive(:chart_values).and_return(stub_values) - end - - it 'overwrites values.yaml' do - expect(values).to match(/privileged: '?#{application.privileged}/) - end - end - end - - describe '#make_uninstalling!' do - subject { create(:clusters_applications_runner, :scheduled, runner: ci_runner) } - - it 'calls prepare_uninstall' do - expect_next_instance_of(described_class) do |instance| - expect(instance).to receive(:prepare_uninstall).and_call_original - end - - subject.make_uninstalling! - end - end - - describe '#post_uninstall' do - it 'destroys its runner' do - application_runner = create(:clusters_applications_runner, :scheduled, runner: ci_runner) - - expect { application_runner.post_uninstall }.to change { Ci::Runner.count }.by(-1) - end - end -end diff --git a/spec/models/clusters/cluster_spec.rb b/spec/models/clusters/cluster_spec.rb index 2a2e2899d24..d501325dd90 100644 --- a/spec/models/clusters/cluster_spec.rb +++ b/spec/models/clusters/cluster_spec.rb @@ -3,7 +3,7 @@ require 'spec_helper' RSpec.describe Clusters::Cluster, :use_clean_rails_memory_store_caching, -feature_category: :kubernetes_management do + feature_category: :deployment_management do include ReactiveCachingHelpers include KubernetesHelpers @@ -23,10 +23,6 @@ feature_category: :kubernetes_management do it { is_expected.to have_one(:provider_aws) } it { is_expected.to have_one(:platform_kubernetes) } it { is_expected.to have_one(:integration_prometheus) } - it { is_expected.to have_one(:application_helm) } - it { is_expected.to have_one(:application_ingress) } - it { is_expected.to have_one(:application_prometheus) } - it { is_expected.to have_one(:application_runner) } it { is_expected.to have_many(:kubernetes_namespaces) } it { is_expected.to have_one(:cluster_project) } it { is_expected.to have_many(:deployment_clusters) } @@ -36,8 +32,6 @@ feature_category: :kubernetes_management do it { is_expected.to delegate_method(:status).to(:provider) } it { is_expected.to delegate_method(:status_reason).to(:provider) } - it { is_expected.to delegate_method(:external_ip).to(:application_ingress).with_prefix } - it { is_expected.to delegate_method(:external_hostname).to(:application_ingress).with_prefix } it { is_expected.to respond_to :project } it { is_expected.to be_namespace_per_environment } @@ -50,15 +44,6 @@ feature_category: :kubernetes_management do let(:factory_name) { :cluster } end - describe 'applications have inverse_of: :cluster option' do - let(:cluster) { create(:cluster) } - let!(:helm) { create(:clusters_applications_helm, cluster: cluster) } - - it 'does not do a third query when referencing cluster again' do - expect { cluster.application_helm.cluster }.not_to exceed_query_limit(2) - end - end - describe '.enabled' do subject { described_class.enabled } @@ -693,103 +678,6 @@ feature_category: :kubernetes_management do end end - describe '.with_persisted_applications' do - let(:cluster) { create(:cluster) } - let!(:helm) { create(:clusters_applications_helm, :installed, cluster: cluster) } - - it 'preloads persisted applications' do - query_rec = ActiveRecord::QueryRecorder.new do - described_class.with_persisted_applications.find_by_id(cluster.id).application_helm - end - - expect(query_rec.count).to eq(1) - end - end - - describe '#persisted_applications' do - let(:cluster) { create(:cluster) } - - subject { cluster.persisted_applications } - - context 'when all applications are created' do - let!(:helm) { create(:clusters_applications_helm, cluster: cluster) } - let!(:ingress) { create(:clusters_applications_ingress, cluster: cluster) } - let!(:prometheus) { create(:clusters_applications_prometheus, cluster: cluster) } - let!(:runner) { create(:clusters_applications_runner, cluster: cluster) } - let!(:jupyter) { create(:clusters_applications_jupyter, cluster: cluster) } - let!(:knative) { create(:clusters_applications_knative, cluster: cluster) } - - it 'returns a list of created applications' do - is_expected.to contain_exactly(helm, ingress, prometheus, runner, jupyter, knative) - end - end - - context 'when not all were created' do - let!(:helm) { create(:clusters_applications_helm, cluster: cluster) } - let!(:ingress) { create(:clusters_applications_ingress, cluster: cluster) } - - it 'returns a list of created applications' do - is_expected.to contain_exactly(helm, ingress) - end - end - end - - describe '#applications' do - let_it_be(:cluster, reload: true) { create(:cluster) } - - subject { cluster.applications } - - context 'when none of applications are created' do - it 'returns a list of a new objects' do - is_expected.not_to be_empty - end - end - - context 'when applications are created' do - let(:cluster) { create(:cluster, :with_all_applications) } - - it 'returns a list of created applications', :aggregate_failures do - is_expected.to have_attributes(size: described_class::APPLICATIONS.size) - is_expected.to all(be_kind_of(::Clusters::Concerns::ApplicationCore)) - is_expected.to all(be_persisted) - end - end - end - - describe '#find_or_build_application' do - let_it_be(:cluster, reload: true) { create(:cluster) } - - it 'rejects classes that are not applications' do - expect do - cluster.find_or_build_application(Project) - end.to raise_error(ArgumentError) - end - - context 'when none of applications are created' do - it 'returns the new application', :aggregate_failures do - described_class::APPLICATIONS.values.each do |application_class| - application = cluster.find_or_build_application(application_class) - - expect(application).to be_a(application_class) - expect(application).not_to be_persisted - end - end - end - - context 'when application is persisted' do - let(:cluster) { create(:cluster, :with_all_applications) } - - it 'returns the persisted application', :aggregate_failures do - described_class::APPLICATIONS.each_value do |application_class| - application = cluster.find_or_build_application(application_class) - - expect(application).to be_kind_of(::Clusters::Concerns::ApplicationCore) - expect(application).to be_persisted - end - end - end - end - describe '#allow_user_defined_namespace?' do subject { cluster.allow_user_defined_namespace? } @@ -839,7 +727,7 @@ feature_category: :kubernetes_management do describe '#all_projects' do context 'cluster_type is project_type' do let(:project) { create(:project) } - let(:cluster) { create(:cluster, :with_installed_helm, projects: [project]) } + let(:cluster) { create(:cluster, projects: [project]) } it 'returns projects' do expect(cluster.all_projects).to match_array [project] @@ -849,7 +737,7 @@ feature_category: :kubernetes_management do context 'cluster_type is group_type' do let(:group) { create(:group) } let!(:project) { create(:project, group: group) } - let(:cluster) { create(:cluster_for_group, :with_installed_helm, groups: [group]) } + let(:cluster) { create(:cluster_for_group, groups: [group]) } it 'returns group projects' do expect(cluster.all_projects.ids).to match_array [project.id] @@ -1342,22 +1230,6 @@ feature_category: :kubernetes_management do expect(cluster.prometheus_adapter).to eq(integration) end end - - context 'has application_prometheus' do - let_it_be(:application) { create(:clusters_applications_prometheus, :no_helm_installed, cluster: cluster) } - - it 'returns nil' do - expect(cluster.prometheus_adapter).to be_nil - end - - context 'also has a integration_prometheus' do - let_it_be(:integration) { create(:clusters_integrations_prometheus, cluster: cluster) } - - it 'returns the integration' do - expect(cluster.prometheus_adapter).to eq(integration) - end - end - end end describe '#delete_cached_resources!' do @@ -1444,36 +1316,6 @@ feature_category: :kubernetes_management do end end - describe '#knative_pre_installed?' do - subject(:knative_pre_installed?) { cluster.knative_pre_installed? } - - before do - allow(cluster).to receive(:provider).and_return(provider) - end - - context 'without provider' do - let(:provider) {} - - it { is_expected.to eq(false) } - end - - context 'with provider' do - let(:provider) { instance_double(Clusters::Providers::Aws, knative_pre_installed?: knative_pre_installed?) } - - context 'with knative_pre_installed? set to true' do - let(:knative_pre_installed?) { true } - - it { is_expected.to eq(true) } - end - - context 'with knative_pre_installed? set to false' do - let(:knative_pre_installed?) { false } - - it { is_expected.to eq(false) } - end - end - end - describe '#platform_kubernetes_active?' do subject(:platform_kubernetes_active?) { cluster.platform_kubernetes_active? } @@ -1533,94 +1375,4 @@ feature_category: :kubernetes_management do end end end - - describe '#application_helm_available?' do - subject(:application_helm_available?) { cluster.application_helm_available? } - - before do - allow(cluster).to receive(:application_helm).and_return(application_helm) - end - - context 'without application_helm' do - let(:application_helm) {} - - it { is_expected.to eq(false) } - end - - context 'with application_helm' do - let(:application_helm) { instance_double(Clusters::Applications::Helm, available?: available?) } - - context 'with available? set to true' do - let(:available?) { true } - - it { is_expected.to eq(true) } - end - - context 'with available? set to false' do - let(:available?) { false } - - it { is_expected.to eq(false) } - end - end - end - - describe '#application_ingress_available?' do - subject(:application_ingress_available?) { cluster.application_ingress_available? } - - before do - allow(cluster).to receive(:application_ingress).and_return(application_ingress) - end - - context 'without application_ingress' do - let(:application_ingress) {} - - it { is_expected.to eq(false) } - end - - context 'with application_ingress' do - let(:application_ingress) { instance_double(Clusters::Applications::Ingress, available?: available?) } - - context 'with available? set to true' do - let(:available?) { true } - - it { is_expected.to eq(true) } - end - - context 'with available? set to false' do - let(:available?) { false } - - it { is_expected.to eq(false) } - end - end - end - - describe '#application_knative_available?' do - subject(:application_knative_available?) { cluster.application_knative_available? } - - before do - allow(cluster).to receive(:application_knative).and_return(application_knative) - end - - context 'without application_knative' do - let(:application_knative) {} - - it { is_expected.to eq(false) } - end - - context 'with application_knative' do - let(:application_knative) { instance_double(Clusters::Applications::Knative, available?: available?) } - - context 'with available? set to true' do - let(:available?) { true } - - it { is_expected.to eq(true) } - end - - context 'with available? set to false' do - let(:available?) { false } - - it { is_expected.to eq(false) } - end - end - end end diff --git a/spec/models/clusters/integrations/prometheus_spec.rb b/spec/models/clusters/integrations/prometheus_spec.rb index d6d1105cdb1..f7ab0ae067c 100644 --- a/spec/models/clusters/integrations/prometheus_spec.rb +++ b/spec/models/clusters/integrations/prometheus_spec.rb @@ -28,7 +28,7 @@ RSpec.describe Clusters::Integrations::Prometheus do describe 'after_destroy' do subject(:integration) { create(:clusters_integrations_prometheus, cluster: cluster, enabled: true) } - let(:cluster) { create(:cluster, :with_installed_helm) } + let(:cluster) { create(:cluster) } it 'deactivates prometheus_integration' do expect(Clusters::Applications::DeactivateIntegrationWorker) @@ -41,7 +41,7 @@ RSpec.describe Clusters::Integrations::Prometheus do describe 'after_save' do subject(:integration) { create(:clusters_integrations_prometheus, cluster: cluster, enabled: enabled) } - let(:cluster) { create(:cluster, :with_installed_helm) } + let(:cluster) { create(:cluster) } let(:enabled) { true } context 'when no change to enabled status' do diff --git a/spec/models/clusters/kubernetes_namespace_spec.rb b/spec/models/clusters/kubernetes_namespace_spec.rb index e70cd15baca..40d381e4dd6 100644 --- a/spec/models/clusters/kubernetes_namespace_spec.rb +++ b/spec/models/clusters/kubernetes_namespace_spec.rb @@ -69,10 +69,12 @@ RSpec.describe Clusters::KubernetesNamespace, type: :model do context 'when cluster is using the namespace' do before do - create(:cluster_kubernetes_namespace, - cluster: kubernetes_namespace.cluster, - environment: kubernetes_namespace.environment, - namespace: 'my-namespace') + create( + :cluster_kubernetes_namespace, + cluster: kubernetes_namespace.cluster, + environment: kubernetes_namespace.environment, + namespace: 'my-namespace' + ) end it { is_expected.not_to be_valid } diff --git a/spec/models/clusters/platforms/kubernetes_spec.rb b/spec/models/clusters/platforms/kubernetes_spec.rb index b280275c2e5..c32abaf50f5 100644 --- a/spec/models/clusters/platforms/kubernetes_spec.rb +++ b/spec/models/clusters/platforms/kubernetes_spec.rb @@ -198,10 +198,12 @@ RSpec.describe Clusters::Platforms::Kubernetes do subject { kubernetes.kubeclient } before do - create(:cluster_kubernetes_namespace, - cluster: kubernetes.cluster, - cluster_project: kubernetes.cluster.cluster_project, - project: kubernetes.cluster.cluster_project.project) + create( + :cluster_kubernetes_namespace, + cluster: kubernetes.cluster, + cluster_project: kubernetes.cluster.cluster_project, + project: kubernetes.cluster.cluster_project.project + ) end it { is_expected.to be_an_instance_of(Gitlab::Kubernetes::KubeClient) } @@ -209,11 +211,13 @@ RSpec.describe Clusters::Platforms::Kubernetes do context 'ca_pem is a single certificate' do let(:ca_pem) { File.read(Rails.root.join('spec/fixtures/clusters/root_certificate.pem')) } let(:kubernetes) do - build(:cluster_platform_kubernetes, - :configured, - namespace: 'a-namespace', - cluster: cluster, - ca_pem: ca_pem) + build( + :cluster_platform_kubernetes, + :configured, + namespace: 'a-namespace', + cluster: cluster, + ca_pem: ca_pem + ) end it 'adds it to cert_store' do @@ -227,11 +231,13 @@ RSpec.describe Clusters::Platforms::Kubernetes do context 'ca_pem is a chain' do let(:cert_chain) { File.read(Rails.root.join('spec/fixtures/clusters/chain_certificates.pem')) } let(:kubernetes) do - build(:cluster_platform_kubernetes, - :configured, - namespace: 'a-namespace', - cluster: cluster, - ca_pem: cert_chain) + build( + :cluster_platform_kubernetes, + :configured, + namespace: 'a-namespace', + cluster: cluster, + ca_pem: cert_chain + ) end where(:fixture_path) do @@ -930,4 +936,13 @@ RSpec.describe Clusters::Platforms::Kubernetes do end end end + + describe '#authorization_type' do + subject(:kubernetes) { create(:cluster_platform_kubernetes) } + + let(:attr) { :authorization_type } + let(:attr_value) { :unknown_authorization } + + it_behaves_like 'having enum with nil value' + end end diff --git a/spec/models/commit_collection_spec.rb b/spec/models/commit_collection_spec.rb index 6dd34c3e21f..1d2d89573bb 100644 --- a/spec/models/commit_collection_spec.rb +++ b/spec/models/commit_collection_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe CommitCollection do +RSpec.describe CommitCollection, feature_category: :source_code_management do let(:project) { create(:project, :repository) } let(:commit) { project.commit("c1c67abbaf91f624347bb3ae96eabe3a1b742478") } @@ -45,6 +45,24 @@ RSpec.describe CommitCollection do end end + describe '#committer_user_ids' do + subject(:collection) { described_class.new(project, [commit]) } + + it 'returns an array of committer user IDs' do + user = create(:user, email: commit.committer_email) + + expect(collection.committer_user_ids).to contain_exactly(user.id) + end + + context 'when there are no committers' do + subject(:collection) { described_class.new(project, []) } + + it 'returns an empty array' do + expect(collection.committer_user_ids).to be_empty + end + end + end + describe '#without_merge_commits' do it 'returns all commits except merge commits' do merge_commit = project.commit("60ecb67744cb56576c30214ff52294f8ce2def98") @@ -191,6 +209,19 @@ RSpec.describe CommitCollection do end end + describe '#load_tags' do + let(:gitaly_commit_with_tags) { project.commit('5937ac0a7beb003549fc5fd26fc247adbce4a52e') } + let(:collection) { described_class.new(project, [gitaly_commit_with_tags]) } + + subject { collection.load_tags } + + it 'loads tags' do + subject + + expect(collection.commits[0].referenced_by).to contain_exactly('refs/tags/v1.1.0') + end + end + describe '#respond_to_missing?' do it 'returns true when the underlying Array responds to the message' do collection = described_class.new(project, []) diff --git a/spec/models/commit_spec.rb b/spec/models/commit_spec.rb index 36d0e37454d..edb856d34df 100644 --- a/spec/models/commit_spec.rb +++ b/spec/models/commit_spec.rb @@ -92,9 +92,11 @@ RSpec.describe Commit do end it 'parses date strings into Time instances' do - commit = described_class.build_from_sidekiq_hash(project, - id: '123', - authored_date: Time.current.to_s) + commit = described_class.build_from_sidekiq_hash( + project, + id: '123', + authored_date: Time.current.to_s + ) expect(commit.authored_date).to be_a_kind_of(Time) end @@ -551,18 +553,22 @@ eos let(:repository) { project.repository } let(:merge_request) do - create(:merge_request, - source_branch: 'video', - target_branch: 'master', - source_project: project, - author: user) + create( + :merge_request, + source_branch: 'video', + target_branch: 'master', + source_project: project, + author: user + ) end let(:merge_commit) do - merge_commit_id = repository.merge(user, - merge_request.diff_head_sha, - merge_request, - 'Test message') + merge_commit_id = repository.merge( + user, + merge_request.diff_head_sha, + merge_request, + 'Test message' + ) repository.commit(merge_commit_id) end @@ -640,17 +646,21 @@ eos let(:user2) { build(:user) } let!(:note1) do - create(:note_on_commit, - commit_id: commit.id, - project: project, - note: 'foo') + create( + :note_on_commit, + commit_id: commit.id, + project: project, + note: 'foo' + ) end let!(:note2) do - create(:note_on_commit, - commit_id: commit.id, - project: project, - note: 'bar') + create( + :note_on_commit, + commit_id: commit.id, + project: project, + note: 'bar' + ) end before do @@ -854,11 +864,13 @@ eos let(:issue) { create(:issue, author: user, project: project) } it 'returns true if the commit has been reverted' do - create(:note_on_issue, - noteable: issue, - system: true, - note: commit.revert_description(user), - project: issue.project) + create( + :note_on_issue, + noteable: issue, + system: true, + note: commit.revert_description(user), + project: issue.project + ) expect_next_instance_of(Commit) do |revert_commit| expect(revert_commit).to receive(:reverts_commit?) @@ -873,4 +885,94 @@ eos expect(commit.has_been_reverted?(user, issue.notes_with_associations)).to eq(false) end end + + describe '#tipping_refs' do + let_it_be(:tag_name) { 'v1.1.0' } + let_it_be(:branch_names) { %w[master not-merged-branch v1.1.0] } + + shared_examples 'tipping ref names' do + context 'when called without limits' do + it 'return tipping refs names' do + expect(called_method.call).to eq(expected) + end + end + + context 'when called with limits' do + it 'return tipping refs names' do + limit = 1 + expect(called_method.call(limit).size).to be <= limit + end + end + + describe '#tipping_branches' do + let(:called_method) { ->(limit = 0) { commit.tipping_branches(limit: limit) } } + let(:expected) { branch_names } + + it_behaves_like 'with tipping ref names' + end + + describe '#tipping_tags' do + let(:called_method) { ->(limit = 0) { commit.tipping_tags(limit: limit) } } + let(:expected) { [tag_name] } + + it_behaves_like 'with tipping ref names' + end + end + end + + context 'containing refs' do + shared_examples 'containing ref names' do + context 'without arguments' do + it 'returns branch names containing the commit' do + expect(ref_containing.call).to eq(containing_refs) + end + end + + context 'with limit argument' do + it 'returns the appropriate amount branch names' do + limit = 2 + expect(ref_containing.call(limit: limit).size).to be <= limit + end + end + + context 'with tipping refs excluded' do + let(:excluded_refs) do + project.repository.refs_by_oid(oid: commit_sha, ref_patterns: [ref_prefix]).map { |n| n.delete_prefix(ref_prefix) } + end + + it 'returns branch names containing the commit without the one with the commit at tip' do + expect(ref_containing.call(excluded_tipped: true)).to eq(containing_refs - excluded_refs) + end + + it 'returns the appropriate amount branch names with limit argument' do + limit = 2 + expect(ref_containing.call(limit: limit, excluded_tipped: true).size).to be <= limit + end + end + end + + describe '#branches_containing' do + let_it_be(:commit_sha) { project.commit.sha } + let_it_be(:containing_refs) { project.repository.branch_names_contains(commit_sha) } + + let(:ref_prefix) { Gitlab::Git::BRANCH_REF_PREFIX } + + let(:ref_containing) { ->(limit: 0, excluded_tipped: false) { commit.branches_containing(exclude_tipped: excluded_tipped, limit: limit) } } + + it_behaves_like 'containing ref names' + end + + describe '#tags_containing' do + let_it_be(:tag_name) { 'v1.1.0' } + let_it_be(:commit_sha) { project.repository.find_tag(tag_name).target_commit.sha } + let_it_be(:containing_refs) { %w[v1.1.0 v1.1.1] } + + let(:ref_prefix) { Gitlab::Git::TAG_REF_PREFIX } + + let(:commit) { project.repository.commit(commit_sha) } + let(:ref_containing) { ->(limit: 0, excluded_tipped: false) { commit.tags_containing(exclude_tipped: excluded_tipped, limit: limit) } } + + it_behaves_like 'containing ref names' + end + end end diff --git a/spec/models/commit_status_spec.rb b/spec/models/commit_status_spec.rb index 4ff451af9de..38c45e8c975 100644 --- a/spec/models/commit_status_spec.rb +++ b/spec/models/commit_status_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe CommitStatus do +RSpec.describe CommitStatus, feature_category: :continuous_integration do let_it_be(:project) { create(:project, :repository) } let_it_be(:pipeline) do @@ -17,7 +17,11 @@ RSpec.describe CommitStatus do it_behaves_like 'having unique enum values' - it { is_expected.to belong_to(:pipeline) } + it do + is_expected.to belong_to(:pipeline).class_name('Ci::Pipeline') + .with_foreign_key(:commit_id).inverse_of(:statuses) + end + it { is_expected.to belong_to(:user) } it { is_expected.to belong_to(:project) } it { is_expected.to belong_to(:auto_canceled_by) } @@ -25,6 +29,11 @@ RSpec.describe CommitStatus do it { is_expected.to validate_presence_of(:name) } it { is_expected.to validate_inclusion_of(:status).in_array(%w(pending running failed success canceled)) } + it { is_expected.to validate_length_of(:stage).is_at_most(255) } + it { is_expected.to validate_length_of(:ref).is_at_most(255) } + it { is_expected.to validate_length_of(:target_url).is_at_most(255) } + it { is_expected.to validate_length_of(:description).is_at_most(255) } + it { is_expected.to delegate_method(:sha).to(:pipeline) } it { is_expected.to delegate_method(:short_sha).to(:pipeline) } @@ -33,6 +42,7 @@ RSpec.describe CommitStatus do it { is_expected.to respond_to :running? } it { is_expected.to respond_to :pending? } it { is_expected.not_to be_retried } + it { expect(described_class.primary_key).to eq('id') } describe '#author' do subject { commit_status.author } @@ -422,29 +432,6 @@ RSpec.describe CommitStatus do end end - describe '.exclude_ignored' do - subject { described_class.exclude_ignored.order(:id) } - - let(:statuses) do - [create_status(when: 'manual', status: 'skipped'), - create_status(when: 'manual', status: 'success'), - create_status(when: 'manual', status: 'failed'), - create_status(when: 'on_failure', status: 'skipped'), - create_status(when: 'on_failure', status: 'success'), - create_status(when: 'on_failure', status: 'failed'), - create_status(allow_failure: true, status: 'success'), - create_status(allow_failure: true, status: 'failed'), - create_status(allow_failure: false, status: 'success'), - create_status(allow_failure: false, status: 'failed'), - create_status(allow_failure: true, status: 'manual'), - create_status(allow_failure: false, status: 'manual')] - end - - it 'returns statuses without what we want to ignore' do - is_expected.to eq(statuses.values_at(0, 1, 2, 3, 4, 5, 6, 8, 9, 11)) - end - end - describe '.failed_but_allowed' do subject { described_class.failed_but_allowed.order(:id) } @@ -578,6 +565,15 @@ RSpec.describe CommitStatus do end end + describe '.with_type' do + let_it_be(:build_job) { create_status(name: 'build job', type: ::Ci::Build) } + let_it_be(:bridge_job) { create_status(name: 'bridge job', type: ::Ci::Bridge) } + + it 'returns statuses that match type' do + expect(described_class.with_type(::Ci::Build)).to contain_exactly(have_attributes(name: 'build job')) + end + end + describe '#before_sha' do subject { commit_status.before_sha } @@ -1062,4 +1058,13 @@ RSpec.describe CommitStatus do end end end + + describe '#failure_reason' do + subject(:status) { commit_status } + + let(:attr) { :failure_reason } + let(:attr_value) { :unknown_failure } + + it_behaves_like 'having enum with nil value' + end end diff --git a/spec/models/compare_spec.rb b/spec/models/compare_spec.rb index dc8429fe77e..2206ed7bfe8 100644 --- a/spec/models/compare_spec.rb +++ b/spec/models/compare_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe Compare do +RSpec.describe Compare, feature_category: :source_code_management do include RepoHelpers let(:project) { create(:project, :public, :repository) } @@ -10,10 +10,11 @@ RSpec.describe Compare do let(:start_commit) { sample_image_commit } let(:head_commit) { sample_commit } + let(:straight) { false } let(:raw_compare) { Gitlab::Git::Compare.new(project.repository.raw_repository, start_commit.id, head_commit.id) } - subject(:compare) { described_class.new(raw_compare, project) } + subject(:compare) { described_class.new(raw_compare, project, straight: straight) } describe '#cache_key' do subject { compare.cache_key } @@ -147,4 +148,33 @@ RSpec.describe Compare do end end end + + describe '#to_param' do + subject { compare.to_param } + + let(:start_commit) { another_sample_commit } + let(:base_commit) { head_commit } + + it 'returns the range between base and head commits' do + is_expected.to eq(from: base_commit.id, to: head_commit.id) + end + + context 'when straight mode is on' do + let(:straight) { true } + + it 'returns the range between start and head commits' do + is_expected.to eq(from: start_commit.id, to: head_commit.id) + end + end + + context 'when there are no merge base between commits' do + before do + allow(project).to receive(:merge_base_commit).and_return(nil) + end + + it 'returns the range between start and head commits' do + is_expected.to eq(from: start_commit.id, to: head_commit.id) + end + end + end end diff --git a/spec/models/concerns/atomic_internal_id_spec.rb b/spec/models/concerns/atomic_internal_id_spec.rb index 5fe3141eb17..625d8fec0fb 100644 --- a/spec/models/concerns/atomic_internal_id_spec.rb +++ b/spec/models/concerns/atomic_internal_id_spec.rb @@ -250,11 +250,104 @@ RSpec.describe AtomicInternalId do end end - describe '.track_project_iid!' do + describe '.track_namespace_iid!' do it 'tracks the present value' do expect do - ::Issue.track_project_iid!(milestone.project, external_iid) - end.to change { InternalId.find_by(project: milestone.project, usage: :issues)&.last_value.to_i }.to(external_iid) + ::Issue.track_namespace_iid!(milestone.project.project_namespace, external_iid) + end.to change { + InternalId.find_by(namespace: milestone.project.project_namespace, usage: :issues)&.last_value.to_i + }.to(external_iid) + end + end + + context 'when transitioning a model from one scope to another' do + let!(:issue) { build(:issue, project: project) } + let(:old_issue_model) do + Class.new(ApplicationRecord) do + include AtomicInternalId + + self.table_name = :issues + + belongs_to :project + belongs_to :namespace + + has_internal_id :iid, scope: :project + + def self.name + 'TestClassA' + end + end + end + + let(:old_issue_instance) { old_issue_model.new(issue.attributes) } + let(:new_issue_instance) { Issue.new(issue.attributes) } + + it 'generates the iid on the new scope' do + # set a random iid, just so that it does not start at 1 + old_issue_instance.iid = 123 + old_issue_instance.save! + + # creating a new old_issue_instance increments the iid. + expect { old_issue_model.new(issue.attributes).save! }.to change { + InternalId.find_by(project: project, usage: :issues)&.last_value.to_i + }.from(123).to(124).and(not_change { InternalId.count }) + + # creating a new Issue creates a new record in internal_ids, scoped to the namespace. + # Given the Issue#has_internal_id -> init definition the internal_ids#last_value would be the + # maximum between the old iid value in internal_ids, scoped to the project and max(iid) value from issues + # table by namespace_id. + # see Issue#has_internal_id + expect { new_issue_instance.save! }.to change { + InternalId.find_by(namespace: project.project_namespace, usage: :issues)&.last_value.to_i + }.to(125).and(change { InternalId.count }.by(1)) + + # transition back to project scope would generate overlapping IIDs and raise a duplicate key value error, unless + # we cleanup the issues usage scoped to the project first + expect { old_issue_model.new(issue.attributes).save! }.to raise_error(ActiveRecord::RecordNotUnique) + + # delete issues usage scoped to te project + InternalId.where(project: project, usage: :issues).delete_all + + expect { old_issue_model.new(issue.attributes).save! }.to change { + InternalId.find_by(project: project, usage: :issues)&.last_value.to_i + }.to(126).and(change { InternalId.count }.by(1)) + end + end + + context 'when models is scoped to namespace and does not have an init proc' do + let!(:issue) { build(:issue, namespace: create(:group)) } + + let(:issue_model) do + Class.new(ApplicationRecord) do + include AtomicInternalId + + self.table_name = :issues + + belongs_to :project + belongs_to :namespace + + has_internal_id :iid, scope: :namespace + + def self.name + 'TestClass' + end + end + end + + let(:model_instance) { issue_model.new(issue.attributes) } + + it 'generates the iid on the new scope' do + expect { model_instance.save! }.to change { + InternalId.find_by(namespace: model_instance.namespace, usage: :issues)&.last_value.to_i + }.to(1).and(change { InternalId.count }.by(1)) + end + + it 'supplies a stream of iid values' do + expect do + issue_model.with_namespace_iid_supply(model_instance.namespace) do |supply| + 4.times { supply.next_value } + end + end.to change { InternalId.find_by(namespace: model_instance.namespace, usage: :issues)&.last_value.to_i }.by(4) end end end diff --git a/spec/models/concerns/awareness_spec.rb b/spec/models/concerns/awareness_spec.rb deleted file mode 100644 index 67acacc7bb1..00000000000 --- a/spec/models/concerns/awareness_spec.rb +++ /dev/null @@ -1,39 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe Awareness, :clean_gitlab_redis_shared_state do - subject { create(:user) } - - let(:session) { AwarenessSession.for(1) } - - describe "when joining a session" do - it "increases the number of sessions" do - expect { subject.join(session) } - .to change { subject.session_ids.size } - .by(1) - end - end - - describe "when leaving session" do - it "decreases the number of sessions" do - subject.join(session) - - expect { subject.leave(session) } - .to change { subject.session_ids.size } - .by(-1) - end - end - - describe "when joining multiple sessions" do - let(:session2) { AwarenessSession.for(2) } - - it "increases number of active sessions for user" do - expect do - subject.join(session) - subject.join(session2) - end.to change { subject.session_ids.size } - .by(2) - end - end -end diff --git a/spec/models/concerns/ci/has_status_spec.rb b/spec/models/concerns/ci/has_status_spec.rb index 4ef690ca4c1..5e0a430aa13 100644 --- a/spec/models/concerns/ci/has_status_spec.rb +++ b/spec/models/concerns/ci/has_status_spec.rb @@ -393,24 +393,26 @@ RSpec.describe Ci::HasStatus, feature_category: :continuous_integration do subject { object.blocked? } %w[ci_pipeline ci_stage ci_build generic_commit_status].each do |type| - let(:object) { build(type, status: status) } + context "when #{type}" do + let(:object) { build(type, status: status) } - context 'when status is scheduled' do - let(:status) { :scheduled } + context 'when status is scheduled' do + let(:status) { :scheduled } - it { is_expected.to be_truthy } - end + it { is_expected.to be_truthy } + end - context 'when status is manual' do - let(:status) { :manual } + context 'when status is manual' do + let(:status) { :manual } - it { is_expected.to be_truthy } - end + it { is_expected.to be_truthy } + end - context 'when status is created' do - let(:status) { :created } + context 'when status is created' do + let(:status) { :created } - it { is_expected.to be_falsy } + it { is_expected.to be_falsy } + end end end end diff --git a/spec/models/concerns/ci/maskable_spec.rb b/spec/models/concerns/ci/maskable_spec.rb index b57b2b15608..6e648a39f8f 100644 --- a/spec/models/concerns/ci/maskable_spec.rb +++ b/spec/models/concerns/ci/maskable_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe Ci::Maskable, feature_category: :pipeline_authoring do +RSpec.describe Ci::Maskable, feature_category: :secrets_management do let(:variable) { build(:ci_variable) } describe 'masked value validations' do diff --git a/spec/models/concerns/ci/partitionable/partitioned_filter_spec.rb b/spec/models/concerns/ci/partitionable/partitioned_filter_spec.rb deleted file mode 100644 index bb25d7d1665..00000000000 --- a/spec/models/concerns/ci/partitionable/partitioned_filter_spec.rb +++ /dev/null @@ -1,80 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe Ci::Partitionable::PartitionedFilter, :aggregate_failures, feature_category: :continuous_integration do - before do - create_tables(<<~SQL) - CREATE TABLE _test_ci_jobs_metadata ( - id serial NOT NULL, - partition_id int NOT NULL DEFAULT 10, - name text, - PRIMARY KEY (id, partition_id) - ) PARTITION BY LIST(partition_id); - - CREATE TABLE _test_ci_jobs_metadata_1 - PARTITION OF _test_ci_jobs_metadata - FOR VALUES IN (10); - SQL - end - - let(:model) do - Class.new(Ci::ApplicationRecord) do - include Ci::Partitionable::PartitionedFilter - - self.primary_key = :id - self.table_name = :_test_ci_jobs_metadata - - def self.name - 'TestCiJobMetadata' - end - end - end - - let!(:record) { model.create! } - - let(:where_filter) do - /WHERE "_test_ci_jobs_metadata"."id" = #{record.id} AND "_test_ci_jobs_metadata"."partition_id" = 10/ - end - - describe '#save' do - it 'uses id and partition_id' do - record.name = 'test' - recorder = ActiveRecord::QueryRecorder.new { record.save! } - - expect(recorder.log).to include(where_filter) - expect(record.name).to eq('test') - end - end - - describe '#update' do - it 'uses id and partition_id' do - recorder = ActiveRecord::QueryRecorder.new { record.update!(name: 'test') } - - expect(recorder.log).to include(where_filter) - expect(record.name).to eq('test') - end - end - - describe '#delete' do - it 'uses id and partition_id' do - recorder = ActiveRecord::QueryRecorder.new { record.delete } - - expect(recorder.log).to include(where_filter) - expect(model.count).to be_zero - end - end - - describe '#destroy' do - it 'uses id and partition_id' do - recorder = ActiveRecord::QueryRecorder.new { record.destroy! } - - expect(recorder.log).to include(where_filter) - expect(model.count).to be_zero - end - end - - def create_tables(table_sql) - Ci::ApplicationRecord.connection.execute(table_sql) - end -end diff --git a/spec/models/concerns/ci/partitionable/switch_spec.rb b/spec/models/concerns/ci/partitionable/switch_spec.rb index d955ad223f8..551ae111fa4 100644 --- a/spec/models/concerns/ci/partitionable/switch_spec.rb +++ b/spec/models/concerns/ci/partitionable/switch_spec.rb @@ -59,13 +59,14 @@ RSpec.describe Ci::Partitionable::Switch, :aggregate_failures do model.include(Ci::Partitionable) model.partitionable scope: ->(r) { 1 }, - through: { table: :_test_p_ci_jobs_metadata, flag: table_rollout_flag } + through: { table: :_test_p_ci_jobs_metadata, flag: table_rollout_flag } model.belongs_to :job, anonymous_class: jobs_model - jobs_model.has_one :metadata, anonymous_class: model, - foreign_key: :job_id, inverse_of: :job, - dependent: :destroy + jobs_model.has_one :metadata, + anonymous_class: model, + foreign_key: :job_id, inverse_of: :job, + dependent: :destroy allow(Feature::Definition).to receive(:get).and_call_original allow(Feature::Definition).to receive(:get).with(table_rollout_flag) diff --git a/spec/models/concerns/ci/partitionable_spec.rb b/spec/models/concerns/ci/partitionable_spec.rb index 430ef57d493..d41654e547e 100644 --- a/spec/models/concerns/ci/partitionable_spec.rb +++ b/spec/models/concerns/ci/partitionable_spec.rb @@ -31,7 +31,7 @@ RSpec.describe Ci::Partitionable do ci_model.include(described_class) ci_model.partitionable scope: ->(r) { 1 }, - through: { table: :_test_table_name, flag: :some_flag } + through: { table: :_test_table_name, flag: :some_flag } end it { expect(ci_model.routing_table_name).to eq(:_test_table_name) } @@ -52,16 +52,16 @@ RSpec.describe Ci::Partitionable do context 'when partitioned is true' do let(:partitioned) { true } - it { expect(ci_model.ancestors).to include(described_class::PartitionedFilter) } - it { expect(ci_model).to be_partitioned } + it { expect(ci_model.ancestors).to include(PartitionedTable) } + it { expect(ci_model.partitioning_strategy).to be_a(Gitlab::Database::Partitioning::CiSlidingListStrategy) } + it { expect(ci_model.partitioning_strategy.partitioning_key).to eq(:partition_id) } end context 'when partitioned is false' do let(:partitioned) { false } - it { expect(ci_model.ancestors).not_to include(described_class::PartitionedFilter) } - - it { expect(ci_model).not_to be_partitioned } + it { expect(ci_model.ancestors).not_to include(PartitionedTable) } + it { expect(ci_model).not_to respond_to(:partitioning_strategy) } end end end diff --git a/spec/models/concerns/ci/track_environment_usage_spec.rb b/spec/models/concerns/ci/track_environment_usage_spec.rb index d75972c49b5..ad89973eee5 100644 --- a/spec/models/concerns/ci/track_environment_usage_spec.rb +++ b/spec/models/concerns/ci/track_environment_usage_spec.rb @@ -8,10 +8,12 @@ RSpec.describe Ci::TrackEnvironmentUsage do context 'when build is the verify action for the environment' do let(:build) do - build_stubbed(:ci_build, - ref: 'master', - environment: 'staging', - options: { environment: { action: 'verify' } }) + build_stubbed( + :ci_build, + ref: 'master', + environment: 'staging', + options: { environment: { action: 'verify' } } + ) end it { is_expected.to be_truthy } @@ -19,10 +21,12 @@ RSpec.describe Ci::TrackEnvironmentUsage do context 'when build is not the verify action for the environment' do let(:build) do - build_stubbed(:ci_build, - ref: 'master', - environment: 'staging', - options: { environment: { action: 'start' } }) + build_stubbed( + :ci_build, + ref: 'master', + environment: 'staging', + options: { environment: { action: 'start' } } + ) end it { is_expected.to be_falsey } diff --git a/spec/models/concerns/clusters/agents/authorization_config_scopes_spec.rb b/spec/models/concerns/clusters/agents/authorization_config_scopes_spec.rb deleted file mode 100644 index a4d1a33b3d5..00000000000 --- a/spec/models/concerns/clusters/agents/authorization_config_scopes_spec.rb +++ /dev/null @@ -1,21 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe Clusters::Agents::AuthorizationConfigScopes do - describe '.with_available_ci_access_fields' do - let(:project) { create(:project) } - - let!(:agent_authorization_0) { create(:agent_project_authorization, project: project) } - let!(:agent_authorization_1) { create(:agent_project_authorization, project: project, config: { access_as: {} }) } - let!(:agent_authorization_2) { create(:agent_project_authorization, project: project, config: { access_as: { agent: {} } }) } - let!(:impersonate_authorization) { create(:agent_project_authorization, project: project, config: { access_as: { impersonate: {} } }) } - let!(:ci_user_authorization) { create(:agent_project_authorization, project: project, config: { access_as: { ci_user: {} } }) } - let!(:ci_job_authorization) { create(:agent_project_authorization, project: project, config: { access_as: { ci_job: {} } }) } - let!(:unexpected_authorization) { create(:agent_project_authorization, project: project, config: { access_as: { unexpected: {} } }) } - - subject { Clusters::Agents::ProjectAuthorization.with_available_ci_access_fields(project) } - - it { is_expected.to contain_exactly(agent_authorization_0, agent_authorization_1, agent_authorization_2) } - end -end diff --git a/spec/models/concerns/clusters/agents/authorizations/ci_access/config_scopes_spec.rb b/spec/models/concerns/clusters/agents/authorizations/ci_access/config_scopes_spec.rb new file mode 100644 index 00000000000..5c69ede11fc --- /dev/null +++ b/spec/models/concerns/clusters/agents/authorizations/ci_access/config_scopes_spec.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Clusters::Agents::Authorizations::CiAccess::ConfigScopes, feature_category: :deployment_management do + describe '.with_available_ci_access_fields' do + let(:project) { create(:project) } + + let!(:agent_authorization_0) { create(:agent_ci_access_project_authorization, project: project) } + let!(:agent_authorization_1) { create(:agent_ci_access_project_authorization, project: project, config: { access_as: {} }) } + let!(:agent_authorization_2) { create(:agent_ci_access_project_authorization, project: project, config: { access_as: { agent: {} } }) } + let!(:impersonate_authorization) { create(:agent_ci_access_project_authorization, project: project, config: { access_as: { impersonate: {} } }) } + let!(:ci_user_authorization) { create(:agent_ci_access_project_authorization, project: project, config: { access_as: { ci_user: {} } }) } + let!(:ci_job_authorization) { create(:agent_ci_access_project_authorization, project: project, config: { access_as: { ci_job: {} } }) } + let!(:unexpected_authorization) { create(:agent_ci_access_project_authorization, project: project, config: { access_as: { unexpected: {} } }) } + + subject { Clusters::Agents::Authorizations::CiAccess::ProjectAuthorization.with_available_ci_access_fields(project) } + + it { is_expected.to contain_exactly(agent_authorization_0, agent_authorization_1, agent_authorization_2) } + end +end diff --git a/spec/models/concerns/clusters/agents/authorizations/user_access/scopes_spec.rb b/spec/models/concerns/clusters/agents/authorizations/user_access/scopes_spec.rb new file mode 100644 index 00000000000..9da7dde0afd --- /dev/null +++ b/spec/models/concerns/clusters/agents/authorizations/user_access/scopes_spec.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Clusters::Agents::Authorizations::UserAccess::Scopes, feature_category: :deployment_management do + describe '.for_agent' do + let_it_be(:agent_1) { create(:cluster_agent) } + let_it_be(:agent_2) { create(:cluster_agent) } + let_it_be(:authorization_1) { create(:agent_user_access_project_authorization, agent: agent_1) } + let_it_be(:authorization_2) { create(:agent_user_access_project_authorization, agent: agent_2) } + + subject { Clusters::Agents::Authorizations::UserAccess::ProjectAuthorization.for_agent(agent_1) } + + it { is_expected.to contain_exactly(authorization_1) } + end + + describe '.preloaded' do + let_it_be(:authorization) { create(:agent_user_access_project_authorization) } + + subject { Clusters::Agents::Authorizations::UserAccess::ProjectAuthorization.preloaded } + + it 'preloads the associated entities' do + expect(subject.first.association(:agent)).to be_loaded + expect(subject.first.agent.association(:project)).to be_loaded + end + end +end diff --git a/spec/models/concerns/database_event_tracking_spec.rb b/spec/models/concerns/database_event_tracking_spec.rb index 976462b4174..502cecaaf76 100644 --- a/spec/models/concerns/database_event_tracking_spec.rb +++ b/spec/models/concerns/database_event_tracking_spec.rb @@ -3,6 +3,10 @@ require 'spec_helper' RSpec.describe DatabaseEventTracking, :snowplow do + before do + allow(Gitlab::Tracking).to receive(:database_event).and_call_original + end + let(:test_class) do Class.new(ActiveRecord::Base) do include DatabaseEventTracking @@ -17,7 +21,7 @@ RSpec.describe DatabaseEventTracking, :snowplow do context 'if event emmiter failed' do before do - allow(Gitlab::Tracking).to receive(:event).and_raise(StandardError) # rubocop:disable RSpec/ExpectGitlabTracking + allow(Gitlab::Tracking).to receive(:database_event).and_raise(StandardError) # rubocop:disable RSpec/ExpectGitlabTracking end it 'tracks the exception' do @@ -35,7 +39,7 @@ RSpec.describe DatabaseEventTracking, :snowplow do it 'does not track the event' do create_test_class_record - expect_no_snowplow_event + expect_no_snowplow_event(tracking_method: :database_event) end end @@ -46,24 +50,48 @@ RSpec.describe DatabaseEventTracking, :snowplow do it 'when created' do create_test_class_record - expect_snowplow_event(category: category, action: "#{event}_create", label: 'application_setting_terms', - property: 'create', namespace: nil, "id" => 1) + expect_snowplow_event( + tracking_method: :database_event, + category: category, + action: "#{event}_create", + label: 'application_setting_terms', + property: 'create', + namespace: nil, + project: nil, + "id" => 1 + ) end it 'when updated' do create_test_class_record test_class.first.update!(id: 3) - expect_snowplow_event(category: category, action: "#{event}_update", label: 'application_setting_terms', - property: 'update', namespace: nil, "id" => 3) + expect_snowplow_event( + tracking_method: :database_event, + category: category, + action: "#{event}_update", + label: 'application_setting_terms', + property: 'update', + namespace: nil, + project: nil, + "id" => 3 + ) end it 'when destroyed' do create_test_class_record test_class.first.destroy! - expect_snowplow_event(category: category, action: "#{event}_destroy", label: 'application_setting_terms', - property: 'destroy', namespace: nil, "id" => 1) + expect_snowplow_event( + tracking_method: :database_event, + category: category, + action: "#{event}_destroy", + label: 'application_setting_terms', + property: 'destroy', + namespace: nil, + project: nil, + "id" => 1 + ) end end end diff --git a/spec/models/concerns/deployment_platform_spec.rb b/spec/models/concerns/deployment_platform_spec.rb index bd1afe844ac..9b086e9785e 100644 --- a/spec/models/concerns/deployment_platform_spec.rb +++ b/spec/models/concerns/deployment_platform_spec.rb @@ -56,13 +56,23 @@ RSpec.describe DeploymentPlatform do context 'when project does not have a cluster but has group clusters' do let!(:default_cluster) do - create(:cluster, :provided_by_user, - cluster_type: :group_type, groups: [group], environment_scope: '*') + create( + :cluster, + :provided_by_user, + cluster_type: :group_type, + groups: [group], + environment_scope: '*' + ) end let!(:cluster) do - create(:cluster, :provided_by_user, - cluster_type: :group_type, environment_scope: 'review/*', groups: [group]) + create( + :cluster, + :provided_by_user, + cluster_type: :group_type, + environment_scope: 'review/*', + groups: [group] + ) end let(:environment) { 'review/name' } @@ -99,8 +109,13 @@ RSpec.describe DeploymentPlatform do context 'when parent_group has a cluster with default scope' do let!(:parent_group_cluster) do - create(:cluster, :provided_by_user, - cluster_type: :group_type, environment_scope: '*', groups: [parent_group]) + create( + :cluster, + :provided_by_user, + cluster_type: :group_type, + environment_scope: '*', + groups: [parent_group] + ) end it_behaves_like 'matching environment scope' @@ -108,8 +123,13 @@ RSpec.describe DeploymentPlatform do context 'when parent_group has a cluster that is an exact match' do let!(:parent_group_cluster) do - create(:cluster, :provided_by_user, - cluster_type: :group_type, environment_scope: 'review/name', groups: [parent_group]) + create( + :cluster, + :provided_by_user, + cluster_type: :group_type, + environment_scope: 'review/name', + groups: [parent_group] + ) end it_behaves_like 'matching environment scope' @@ -160,8 +180,13 @@ RSpec.describe DeploymentPlatform do let!(:cluster) { create(:cluster, :provided_by_user, environment_scope: 'review/*', projects: [project]) } let!(:group_default_cluster) do - create(:cluster, :provided_by_user, - cluster_type: :group_type, groups: [group], environment_scope: '*') + create( + :cluster, + :provided_by_user, + cluster_type: :group_type, + groups: [group], + environment_scope: '*' + ) end let(:environment) { 'review/name' } diff --git a/spec/models/concerns/each_batch_spec.rb b/spec/models/concerns/each_batch_spec.rb index 2c75d4d5c41..75c5cac899b 100644 --- a/spec/models/concerns/each_batch_spec.rb +++ b/spec/models/concerns/each_batch_spec.rb @@ -171,4 +171,36 @@ RSpec.describe EachBatch do end end end + + describe '.each_batch_count' do + let_it_be(:users) { create_list(:user, 5, updated_at: 1.day.ago) } + + it 'counts the records' do + count, last_value = User.each_batch_count + + expect(count).to eq(5) + expect(last_value).to eq(nil) + end + + context 'when using a different column' do + it 'returns correct count' do + count, _ = User.each_batch_count(column: :email, of: 2) + + expect(count).to eq(5) + end + end + + context 'when stopping and resuming the counting' do + it 'returns the correct count' do + count, last_value = User.each_batch_count(of: 1) do |current_count, _current_value| + current_count == 3 # stop when count reaches 3 + end + + expect(count).to eq(3) + + final_count, _ = User.each_batch_count(of: 1, last_value: last_value, last_count: count) + expect(final_count).to eq(5) + end + end + end end diff --git a/spec/models/concerns/expirable_spec.rb b/spec/models/concerns/expirable_spec.rb index 50dfb138ac9..68a25917ce1 100644 --- a/spec/models/concerns/expirable_spec.rb +++ b/spec/models/concerns/expirable_spec.rb @@ -3,40 +3,52 @@ require 'spec_helper' RSpec.describe Expirable do - describe 'ProjectMember' do - let_it_be(:no_expire) { create(:project_member) } - let_it_be(:expire_later) { create(:project_member, expires_at: 8.days.from_now) } - let_it_be(:expired) { create(:project_member, expires_at: 1.day.from_now) } + let_it_be(:no_expire) { create(:project_member) } + let_it_be(:expire_later) { create(:project_member, expires_at: 8.days.from_now) } + let_it_be(:expired) { create(:project_member, expires_at: 1.day.from_now) } - before do - travel_to(3.days.from_now) - end + before do + travel_to(3.days.from_now) + end - describe '.expired' do - it { expect(ProjectMember.expired).to match_array([expired]) } - end + describe '.expired' do + it { expect(ProjectMember.expired).to match_array([expired]) } - describe '.not_expired' do - it { expect(ProjectMember.not_expired).to include(no_expire, expire_later) } - it { expect(ProjectMember.not_expired).not_to include(expired) } - end + it 'scopes the query when multiple models are expirable' do + expired_access_token = create(:personal_access_token, :expired, user: no_expire.user) - describe '#expired?' do - it { expect(no_expire.expired?).to eq(false) } - it { expect(expire_later.expired?).to eq(false) } - it { expect(expired.expired?).to eq(true) } + expect(PersonalAccessToken.expired.joins(user: :members)).to match_array([expired_access_token]) + expect(PersonalAccessToken.joins(user: :members).merge(ProjectMember.expired)).to eq([]) end - describe '#expires?' do - it { expect(no_expire.expires?).to eq(false) } - it { expect(expire_later.expires?).to eq(true) } - it { expect(expired.expires?).to eq(true) } - end + it 'works with a timestamp expired_at field', time_travel_to: '2022-03-14T11:30:00Z' do + expired_deploy_token = create(:deploy_token, expires_at: 5.minutes.ago.iso8601) - describe '#expires_soon?' do - it { expect(no_expire.expires_soon?).to eq(false) } - it { expect(expire_later.expires_soon?).to eq(true) } - it { expect(expired.expires_soon?).to eq(true) } + # Here verify that `expires_at` in the SQL uses `Time.current` instead of `Date.current` + expect(DeployToken.expired).to match_array([expired_deploy_token]) end end + + describe '.not_expired' do + it { expect(ProjectMember.not_expired).to include(no_expire, expire_later) } + it { expect(ProjectMember.not_expired).not_to include(expired) } + end + + describe '#expired?' do + it { expect(no_expire.expired?).to eq(false) } + it { expect(expire_later.expired?).to eq(false) } + it { expect(expired.expired?).to eq(true) } + end + + describe '#expires?' do + it { expect(no_expire.expires?).to eq(false) } + it { expect(expire_later.expires?).to eq(true) } + it { expect(expired.expires?).to eq(true) } + end + + describe '#expires_soon?' do + it { expect(no_expire.expires_soon?).to eq(false) } + it { expect(expire_later.expires_soon?).to eq(true) } + it { expect(expired.expires_soon?).to eq(true) } + end end diff --git a/spec/models/concerns/has_user_type_spec.rb b/spec/models/concerns/has_user_type_spec.rb index bd128112113..b5abd114f9a 100644 --- a/spec/models/concerns/has_user_type_spec.rb +++ b/spec/models/concerns/has_user_type_spec.rb @@ -2,18 +2,19 @@ require 'spec_helper' -RSpec.describe User do +RSpec.describe User, feature_category: :system_access do specify 'types consistency checks', :aggregate_failures do expect(described_class::USER_TYPES.keys) - .to match_array(%w[human ghost alert_bot project_bot support_bot service_user security_bot visual_review_bot - migration_bot automation_bot admin_bot suggested_reviewers_bot]) + .to match_array(%w[human human_deprecated ghost alert_bot project_bot support_bot service_user security_bot + visual_review_bot migration_bot automation_bot security_policy_bot admin_bot suggested_reviewers_bot + service_account llm_bot]) expect(described_class::USER_TYPES).to include(*described_class::BOT_USER_TYPES) expect(described_class::USER_TYPES).to include(*described_class::NON_INTERNAL_USER_TYPES) expect(described_class::USER_TYPES).to include(*described_class::INTERNAL_USER_TYPES) end describe 'scopes & predicates' do - User::USER_TYPES.keys.each do |type| + User::USER_TYPES.keys.each do |type| # rubocop:disable RSpec/UselessDynamicDefinition let_it_be(type) { create(:user, username: type, user_type: type) } end let(:bots) { User::BOT_USER_TYPES.map { |type| public_send(type) } } @@ -22,7 +23,13 @@ RSpec.describe User do describe '.humans' do it 'includes humans only' do - expect(described_class.humans).to match_array([human]) + expect(described_class.humans).to match_array([human, human_deprecated]) + end + end + + describe '.human' do + it 'includes humans only' do + expect(described_class.human).to match_array([human, human_deprecated]) end end @@ -69,6 +76,7 @@ RSpec.describe User do describe '#human?' do it 'is true for humans only' do expect(human).to be_human + expect(human_deprecated).to be_human expect(alert_bot).not_to be_human expect(User.new).to be_human end diff --git a/spec/models/concerns/issuable_spec.rb b/spec/models/concerns/issuable_spec.rb index 206b3ae61cf..e11c7e98287 100644 --- a/spec/models/concerns/issuable_spec.rb +++ b/spec/models/concerns/issuable_spec.rb @@ -150,8 +150,10 @@ RSpec.describe Issuable do end it 'gives preference to state_id if present' do - issuable = MergeRequest.new('state' => 'opened', - 'state_id' => described_class::STATE_ID_MAP['merged']) + issuable = MergeRequest.new( + 'state' => 'opened', + 'state_id' => described_class::STATE_ID_MAP['merged'] + ) expect(issuable.state).to eq('merged') expect(issuable.state_id).to eq(described_class::STATE_ID_MAP['merged']) @@ -858,22 +860,16 @@ RSpec.describe Issuable do end end - describe '#first_contribution?' do + describe '#first_contribution?', feature_category: :code_review_workflow do let(:group) { create(:group) } let(:project) { create(:project, namespace: group) } let(:other_project) { create(:project) } - let(:owner) { create(:owner) } - let(:maintainer) { create(:user) } - let(:reporter) { create(:user) } let(:guest) { create(:user) } let(:contributor) { create(:user) } let(:first_time_contributor) { create(:user) } before do - group.add_owner(owner) - project.add_maintainer(maintainer) - project.add_reporter(reporter) project.add_guest(guest) project.add_guest(contributor) project.add_guest(first_time_contributor) @@ -884,24 +880,6 @@ RSpec.describe Issuable do let(:merged_mr_other_project) { create(:merge_request, :merged, author: first_time_contributor, target_project: other_project, source_project: other_project) } context "for merge requests" do - it "is false for MAINTAINER" do - mr = create(:merge_request, author: maintainer, target_project: project, source_project: project) - - expect(mr).not_to be_first_contribution - end - - it "is false for OWNER" do - mr = create(:merge_request, author: owner, target_project: project, source_project: project) - - expect(mr).not_to be_first_contribution - end - - it "is false for REPORTER" do - mr = create(:merge_request, author: reporter, target_project: project, source_project: project) - - expect(mr).not_to be_first_contribution - end - it "is true when you don't have any merged MR" do expect(open_mr).to be_first_contribution expect(merged_mr).not_to be_first_contribution @@ -998,7 +976,7 @@ RSpec.describe Issuable do end end - describe '#incident?' do + describe '#incident_type_issue?' do where(:issuable_type, :incident) do :issue | false :incident | true @@ -1008,7 +986,7 @@ RSpec.describe Issuable do with_them do let(:issuable) { build_stubbed(issuable_type) } - subject { issuable.incident? } + subject { issuable.incident_type_issue? } it { is_expected.to eq(incident) } end diff --git a/spec/models/concerns/mentionable_spec.rb b/spec/models/concerns/mentionable_spec.rb index 7bbbd10ec8d..d9e53fb7e9a 100644 --- a/spec/models/concerns/mentionable_spec.rb +++ b/spec/models/concerns/mentionable_spec.rb @@ -225,7 +225,7 @@ RSpec.describe Commit, 'Mentionable' do end context 'with external issue tracker' do - let(:project) { create(:project, :with_jira_integration, :repository) } + let_it_be(:project) { create(:project, :with_jira_integration, :repository) } it 'is true if external issues referenced' do allow(commit.raw).to receive(:message).and_return 'JIRA-123' diff --git a/spec/models/concerns/prometheus_adapter_spec.rb b/spec/models/concerns/prometheus_adapter_spec.rb index d3a44ac8403..31ab8c23a84 100644 --- a/spec/models/concerns/prometheus_adapter_spec.rb +++ b/spec/models/concerns/prometheus_adapter_spec.rb @@ -6,7 +6,7 @@ RSpec.describe PrometheusAdapter, :use_clean_rails_memory_store_caching do include PrometheusHelpers include ReactiveCachingHelpers - let(:project) { create(:project, :with_prometheus_integration) } + let_it_be(:project) { create(:project, :with_prometheus_integration) } let(:integration) { project.prometheus_integration } let(:described_class) do diff --git a/spec/models/concerns/protected_ref_access_spec.rb b/spec/models/concerns/protected_ref_access_spec.rb deleted file mode 100644 index 750a5eba303..00000000000 --- a/spec/models/concerns/protected_ref_access_spec.rb +++ /dev/null @@ -1,45 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe ProtectedRefAccess do - include ExternalAuthorizationServiceHelpers - - subject(:protected_ref_access) do - create(:protected_branch, :maintainers_can_push).push_access_levels.first - end - - let(:project) { protected_ref_access.project } - - describe '#check_access' do - it 'is always true for admins' do - admin = create(:admin) - - expect(protected_ref_access.check_access(admin)).to be_truthy - end - - it 'is true for maintainers' do - maintainer = create(:user) - project.add_maintainer(maintainer) - - expect(protected_ref_access.check_access(maintainer)).to be_truthy - end - - it 'is for developers of the project' do - developer = create(:user) - project.add_developer(developer) - - expect(protected_ref_access.check_access(developer)).to be_falsy - end - - context 'external authorization' do - it 'is false if external authorization denies access' do - maintainer = create(:user) - project.add_maintainer(maintainer) - external_service_deny_access(maintainer, project) - - expect(protected_ref_access.check_access(maintainer)).to be_falsey - end - end - end -end diff --git a/spec/models/concerns/redis_cacheable_spec.rb b/spec/models/concerns/redis_cacheable_spec.rb index c270f23defb..bf277b094ea 100644 --- a/spec/models/concerns/redis_cacheable_spec.rb +++ b/spec/models/concerns/redis_cacheable_spec.rb @@ -50,6 +50,58 @@ RSpec.describe RedisCacheable do subject end + + context 'with existing cached attributes' do + before do + instance.cache_attributes({ existing_attr: 'value' }) + end + + it 'sets the cache attributes' do + Gitlab::Redis::Cache.with do |redis| + expect(redis).to receive(:set).with(cache_key, payload.to_json, anything).and_call_original + end + + expect { subject }.to change { instance.cached_attribute(:existing_attr) }.from('value').to(nil) + end + end + end + + describe '#merge_cache_attributes' do + subject { instance.merge_cache_attributes(payload) } + + let(:existing_attributes) { { existing_attr: 'value', name: 'value' } } + + before do + instance.cache_attributes(existing_attributes) + end + + context 'with different attribute values' do + let(:payload) { { name: 'new_value' } } + + it 'merges the cache attributes with existing values' do + Gitlab::Redis::Cache.with do |redis| + expect(redis).to receive(:set).with(cache_key, existing_attributes.merge(payload).to_json, anything) + .and_call_original + end + + subject + + expect(instance.cached_attribute(:existing_attr)).to eq 'value' + expect(instance.cached_attribute(:name)).to eq 'new_value' + end + end + + context 'with no new or changed attribute values' do + let(:payload) { { name: 'value' } } + + it 'does not try to set Redis key' do + Gitlab::Redis::Cache.with do |redis| + expect(redis).not_to receive(:set) + end + + subject + end + end end describe '#cached_attr_reader', :clean_gitlab_redis_cache do diff --git a/spec/models/concerns/require_email_verification_spec.rb b/spec/models/concerns/require_email_verification_spec.rb index 0a6293f852e..1fb54e4276f 100644 --- a/spec/models/concerns/require_email_verification_spec.rb +++ b/spec/models/concerns/require_email_verification_spec.rb @@ -15,24 +15,20 @@ RSpec.describe RequireEmailVerification, feature_category: :insider_threat do using RSpec::Parameterized::TableSyntax - where(:feature_flag_enabled, :two_factor_enabled, :skipped, :overridden) do - false | false | false | false - false | false | true | false - false | true | false | false - false | true | true | false - true | false | false | true - true | false | true | false - true | true | false | false - true | true | true | false - end + where(feature_flag_enabled: [true, false], + two_factor_enabled: [true, false], + oauth_user: [true, false], + skipped: [true, false]) with_them do let(:instance) { model.new(id: 1) } let(:another_instance) { model.new(id: 2) } + let(:overridden) { feature_flag_enabled && !two_factor_enabled && !oauth_user && !skipped } before do stub_feature_flags(require_email_verification: feature_flag_enabled ? instance : another_instance) allow(instance).to receive(:two_factor_enabled?).and_return(two_factor_enabled) + allow(instance).to receive(:identities).and_return(oauth_user ? [:google] : []) stub_feature_flags(skip_require_email_verification: skipped ? instance : another_instance) end diff --git a/spec/models/concerns/routable_spec.rb b/spec/models/concerns/routable_spec.rb index dc1002f3560..0bbe3dea812 100644 --- a/spec/models/concerns/routable_spec.rb +++ b/spec/models/concerns/routable_spec.rb @@ -74,6 +74,17 @@ RSpec.shared_examples 'routable resource with parent' do describe '#full_name' do it { expect(record.full_name).to eq "#{record.parent.human_name} / #{record.name}" } + context 'without route name' do + before do + stub_feature_flags(cached_route_lookups: true) + record.route.update_attribute(:name, nil) + end + + it 'builds full name' do + expect(record.full_name).to eq("#{record.parent.human_name} / #{record.name}") + end + end + it 'hits the cache when not preloaded' do forcibly_hit_cached_lookup(record, :full_name) diff --git a/spec/models/concerns/subscribable_spec.rb b/spec/models/concerns/subscribable_spec.rb index a60a0a5e26d..80060802de8 100644 --- a/spec/models/concerns/subscribable_spec.rb +++ b/spec/models/concerns/subscribable_spec.rb @@ -215,5 +215,31 @@ RSpec.describe Subscribable, 'Subscribable' do expect(lazy_queries.count).to eq(0) expect(preloaded_queries.count).to eq(1) end + + context 'with work items' do + let_it_be(:user) { create(:user) } + let_it_be(:project) { create(:project) } + let_it_be(:work_item) { create(:work_item, :task, project: project) } + let_it_be(:issue) { create(:issue, project: project) } + + before do + [issue, work_item].each do |item| + create(:subscription, user: user, subscribable: item, subscribed: true, project: project) + end + end + + it 'loads correct subscribable type' do + expect(issue).to receive(:subscribable_type).and_return('Issue') + issue.lazy_subscription(user, project) + + expect(work_item).to receive(:subscribable_type).and_return('Issue') + work_item.lazy_subscription(user, project) + end + + it 'matches existing subscription type' do + expect(issue.subscribed?(user, project)).to eq(true) + expect(work_item.subscribed?(user, project)).to eq(true) + end + end end end diff --git a/spec/models/concerns/taskable_spec.rb b/spec/models/concerns/taskable_spec.rb index 20de8995d13..dad0db2d898 100644 --- a/spec/models/concerns/taskable_spec.rb +++ b/spec/models/concerns/taskable_spec.rb @@ -27,6 +27,9 @@ RSpec.describe Taskable, feature_category: :team_planning do + [ ] Narrow no-break space (U+202F) + [ ] Thin space (U+2009) + + 1. [ ] Numbered 1 + 2) [x] Numbered 2 MARKDOWN end @@ -35,7 +38,9 @@ RSpec.describe Taskable, feature_category: :team_planning do TaskList::Item.new('- [ ]', 'First item'), TaskList::Item.new('- [x]', 'Second item'), TaskList::Item.new('* [x]', 'First item'), - TaskList::Item.new('* [ ]', 'Second item') + TaskList::Item.new('* [ ]', 'Second item'), + TaskList::Item.new('1. [ ]', 'Numbered 1'), + TaskList::Item.new('2) [x]', 'Numbered 2') ] end diff --git a/spec/models/concerns/token_authenticatable_spec.rb b/spec/models/concerns/token_authenticatable_spec.rb index e53fdafe3b1..7367577914c 100644 --- a/spec/models/concerns/token_authenticatable_spec.rb +++ b/spec/models/concerns/token_authenticatable_spec.rb @@ -130,10 +130,7 @@ RSpec.describe PersonalAccessToken, 'TokenAuthenticatable' do let(:token_digest) { Gitlab::CryptoHelper.sha256(token_value) } let(:user) { create(:user) } let(:personal_access_token) do - described_class.new(name: 'test-pat-01', - user_id: user.id, - scopes: [:api], - token_digest: token_digest) + described_class.new(name: 'test-pat-01', user_id: user.id, scopes: [:api], token_digest: token_digest) end before do diff --git a/spec/models/concerns/token_authenticatable_strategies/base_spec.rb b/spec/models/concerns/token_authenticatable_strategies/base_spec.rb index 89ddc797a9d..2679bd7d93b 100644 --- a/spec/models/concerns/token_authenticatable_strategies/base_spec.rb +++ b/spec/models/concerns/token_authenticatable_strategies/base_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe TokenAuthenticatableStrategies::Base do +RSpec.describe TokenAuthenticatableStrategies::Base, feature_category: :system_access do let(:instance) { double(:instance) } let(:field) { double(:field) } @@ -24,6 +24,41 @@ RSpec.describe TokenAuthenticatableStrategies::Base do end end + describe '#format_token' do + let(:strategy) { described_class.new(instance, field, options) } + + let(:instance) { build(:ci_build, name: 'build_name_for_format_option', partition_id: partition_id) } + let(:partition_id) { 100 } + let(:field) { 'token' } + let(:options) { {} } + + let(:token) { 'a_secret_token' } + + it 'returns the origin token' do + expect(strategy.format_token(instance, token)).to eq(token) + end + + context 'when format_with_prefix option is provided' do + context 'with symbol' do + let(:options) { { format_with_prefix: :partition_id_prefix_in_16_bit_encode } } + let(:partition_id_in_16_bit_encode_with_underscore) { "#{partition_id.to_s(16)}_" } + let(:formatted_token) { "#{partition_id_in_16_bit_encode_with_underscore}#{token}" } + + it 'returns a formatted token from the format_with_prefix option' do + expect(strategy.format_token(instance, token)).to eq(formatted_token) + end + end + + context 'with something else' do + let(:options) { { format_with_prefix: false } } + + it 'raise not implemented' do + expect { strategy.format_token(instance, token) }.to raise_error(NotImplementedError) + end + end + end + end + describe '.fabricate' do context 'when digest stragegy is specified' do it 'fabricates digest strategy object' do diff --git a/spec/models/concerns/token_authenticatable_strategies/encrypted_spec.rb b/spec/models/concerns/token_authenticatable_strategies/encrypted_spec.rb index 2df86804f34..3bdb0647d62 100644 --- a/spec/models/concerns/token_authenticatable_strategies/encrypted_spec.rb +++ b/spec/models/concerns/token_authenticatable_strategies/encrypted_spec.rb @@ -2,16 +2,20 @@ require 'spec_helper' -RSpec.describe TokenAuthenticatableStrategies::Encrypted do +RSpec.describe TokenAuthenticatableStrategies::Encrypted, feature_category: :system_access do let(:model) { double(:model) } let(:instance) { double(:instance) } + let(:original_token) { 'my-value' } + let(:resource) { double(:resource) } + let(:options) { other_options.merge(encrypted: encrypted_option) } + let(:other_options) { {} } let(:encrypted) do - TokenAuthenticatableStrategies::EncryptionHelper.encrypt_token('my-value') + TokenAuthenticatableStrategies::EncryptionHelper.encrypt_token(original_token) end let(:encrypted_with_static_iv) do - Gitlab::CryptoHelper.aes256_gcm_encrypt('my-value') + Gitlab::CryptoHelper.aes256_gcm_encrypt(original_token) end subject(:strategy) do @@ -19,7 +23,7 @@ RSpec.describe TokenAuthenticatableStrategies::Encrypted do end describe '#token_fields' do - let(:options) { { encrypted: :required } } + let(:encrypted_option) { :required } it 'includes the encrypted field' do expect(strategy.token_fields).to contain_exactly('some_field', 'some_field_encrypted') @@ -27,50 +31,70 @@ RSpec.describe TokenAuthenticatableStrategies::Encrypted do end describe '#find_token_authenticatable' do + shared_examples 'finds the resource' do + it 'finds the resource by cleartext' do + expect(subject.find_token_authenticatable(original_token)) + .to eq(resource) + end + end + + shared_examples 'does not find any resource' do + it 'does not find any resource by cleartext' do + expect(subject.find_token_authenticatable(original_token)) + .to be_nil + end + end + + shared_examples 'finds the resource with/without setting require_prefix_for_validation' do + let(:standard_runner_token_prefix) { 'GR1348941' } + it_behaves_like 'finds the resource' + + context 'when a require_prefix_for_validation is provided' do + let(:other_options) { { format_with_prefix: :format_with_prefix_method, require_prefix_for_validation: true } } + + before do + allow(resource).to receive(:format_with_prefix_method).and_return(standard_runner_token_prefix) + end + + it_behaves_like 'does not find any resource' + + context 'when token starts with prefix' do + let(:original_token) { "#{standard_runner_token_prefix}plain_token" } + + it_behaves_like 'finds the resource' + end + end + end + context 'when encryption is required' do - let(:options) { { encrypted: :required } } + let(:encrypted_option) { :required } + let(:resource) { double(:encrypted_resource) } - it 'finds the encrypted resource by cleartext' do + before do allow(model).to receive(:where) .and_return(model) allow(model).to receive(:find_by) .with('some_field_encrypted' => [encrypted, encrypted_with_static_iv]) - .and_return('encrypted resource') - - expect(subject.find_token_authenticatable('my-value')) - .to eq 'encrypted resource' + .and_return(resource) end - context 'when a prefix is required' do - let(:options) { { encrypted: :required, prefix: 'GR1348941' } } - - it 'finds the encrypted resource by cleartext' do - allow(model).to receive(:where) - .and_return(model) - allow(model).to receive(:find_by) - .with('some_field_encrypted' => [encrypted, encrypted_with_static_iv]) - .and_return('encrypted resource') - - expect(subject.find_token_authenticatable('my-value')) - .to be_nil - end - end + it_behaves_like 'finds the resource with/without setting require_prefix_for_validation' end context 'when encryption is optional' do - let(:options) { { encrypted: :optional } } + let(:encrypted_option) { :optional } + let(:resource) { double(:encrypted_resource) } - it 'finds the encrypted resource by cleartext' do + before do allow(model).to receive(:where) .and_return(model) allow(model).to receive(:find_by) .with('some_field_encrypted' => [encrypted, encrypted_with_static_iv]) - .and_return('encrypted resource') - - expect(subject.find_token_authenticatable('my-value')) - .to eq 'encrypted resource' + .and_return(resource) end + it_behaves_like 'finds the resource with/without setting require_prefix_for_validation' + it 'uses insecure strategy when encrypted token cannot be found' do allow(subject.send(:insecure_strategy)) .to receive(:find_token_authenticatable) @@ -85,68 +109,27 @@ RSpec.describe TokenAuthenticatableStrategies::Encrypted do expect(subject.find_token_authenticatable('my-value')) .to eq 'plaintext resource' end - - context 'when a prefix is required' do - let(:options) { { encrypted: :optional, prefix: 'GR1348941' } } - - it 'finds the encrypted resource by cleartext' do - allow(model).to receive(:where) - .and_return(model) - allow(model).to receive(:find_by) - .with('some_field_encrypted' => [encrypted, encrypted_with_static_iv]) - .and_return('encrypted resource') - - expect(subject.find_token_authenticatable('my-value')) - .to be_nil - end - end end context 'when encryption is migrating' do - let(:options) { { encrypted: :migrating } } + let(:encrypted_option) { :migrating } + let(:resource) { double(:cleartext_resource) } - it 'finds the cleartext resource by cleartext' do + before do allow(model).to receive(:where) .and_return(model) allow(model).to receive(:find_by) - .with('some_field' => 'my-value') - .and_return('cleartext resource') - - expect(subject.find_token_authenticatable('my-value')) - .to eq 'cleartext resource' + .with('some_field' => original_token) + .and_return(resource) end - it 'returns nil if resource cannot be found' do - allow(model).to receive(:where) - .and_return(model) - allow(model).to receive(:find_by) - .with('some_field' => 'my-value') - .and_return(nil) - - expect(subject.find_token_authenticatable('my-value')) - .to be_nil - end - - context 'when a prefix is required' do - let(:options) { { encrypted: :migrating, prefix: 'GR1348941' } } - - it 'finds the encrypted resource by cleartext' do - allow(model).to receive(:where) - .and_return(model) - allow(model).to receive(:find_by) - .with('some_field' => 'my-value') - .and_return('cleartext resource') - - expect(subject.find_token_authenticatable('my-value')) - .to be_nil - end - end + it_behaves_like 'finds the resource with/without setting require_prefix_for_validation' end end describe '#get_token' do context 'when encryption is required' do - let(:options) { { encrypted: :required } } + let(:encrypted_option) { :required } it 'returns decrypted token when an encrypted with static iv token is present' do allow(instance).to receive(:read_attribute) @@ -166,7 +149,7 @@ RSpec.describe TokenAuthenticatableStrategies::Encrypted do end context 'when encryption is optional' do - let(:options) { { encrypted: :optional } } + let(:encrypted_option) { :optional } it 'returns decrypted token when an encrypted token is present' do allow(instance).to receive(:read_attribute) @@ -198,7 +181,7 @@ RSpec.describe TokenAuthenticatableStrategies::Encrypted do end context 'when encryption is migrating' do - let(:options) { { encrypted: :migrating } } + let(:encrypted_option) { :migrating } it 'returns cleartext token when an encrypted token is present' do allow(instance).to receive(:read_attribute) @@ -228,7 +211,7 @@ RSpec.describe TokenAuthenticatableStrategies::Encrypted do describe '#set_token' do context 'when encryption is required' do - let(:options) { { encrypted: :required } } + let(:encrypted_option) { :required } it 'writes encrypted token and returns it' do expect(instance).to receive(:[]=) @@ -239,7 +222,7 @@ RSpec.describe TokenAuthenticatableStrategies::Encrypted do end context 'when encryption is optional' do - let(:options) { { encrypted: :optional } } + let(:encrypted_option) { :optional } it 'writes encrypted token and removes plaintext token and returns it' do expect(instance).to receive(:[]=) @@ -252,7 +235,7 @@ RSpec.describe TokenAuthenticatableStrategies::Encrypted do end context 'when encryption is migrating' do - let(:options) { { encrypted: :migrating } } + let(:encrypted_option) { :migrating } it 'writes encrypted token and writes plaintext token' do expect(instance).to receive(:[]=) diff --git a/spec/models/concerns/token_authenticatable_strategies/encryption_helper_spec.rb b/spec/models/concerns/token_authenticatable_strategies/encryption_helper_spec.rb index 671e51e3913..004298bbdd9 100644 --- a/spec/models/concerns/token_authenticatable_strategies/encryption_helper_spec.rb +++ b/spec/models/concerns/token_authenticatable_strategies/encryption_helper_spec.rb @@ -6,96 +6,26 @@ RSpec.describe TokenAuthenticatableStrategies::EncryptionHelper do let(:encrypted_token) { described_class.encrypt_token('my-value-my-value-my-value') } describe '.encrypt_token' do - context 'when dynamic_nonce feature flag is switched on' do - it 'adds nonce identifier on the beginning' do - expect(encrypted_token.first).to eq(described_class::DYNAMIC_NONCE_IDENTIFIER) - end - - it 'adds nonce at the end' do - nonce = encrypted_token.last(described_class::NONCE_SIZE) - - expect(nonce).to eq(::Digest::SHA256.hexdigest('my-value-my-value-my-value').bytes.take(described_class::NONCE_SIZE).pack('c*')) - end - - it 'encrypts token' do - expect(encrypted_token[1...-described_class::NONCE_SIZE]).not_to eq('my-value-my-value-my-value') - end + it 'adds nonce identifier on the beginning' do + expect(encrypted_token.first).to eq(described_class::DYNAMIC_NONCE_IDENTIFIER) end - context 'when dynamic_nonce feature flag is switched off' do - before do - stub_feature_flags(dynamic_nonce: false) - end - - it 'does not add nonce identifier on the beginning' do - expect(encrypted_token.first).not_to eq(described_class::DYNAMIC_NONCE_IDENTIFIER) - end - - it 'does not add nonce in the end' do - nonce = encrypted_token.last(described_class::NONCE_SIZE) - - expect(nonce).not_to eq(::Digest::SHA256.hexdigest('my-value-my-value-my-value').bytes.take(described_class::NONCE_SIZE).pack('c*')) - end + it 'adds nonce at the end' do + nonce = encrypted_token.last(described_class::NONCE_SIZE) - it 'encrypts token with static iv' do - token = Gitlab::CryptoHelper.aes256_gcm_encrypt('my-value-my-value-my-value') + expect(nonce).to eq(::Digest::SHA256.hexdigest('my-value-my-value-my-value').bytes.take(described_class::NONCE_SIZE).pack('c*')) + end - expect(encrypted_token).to eq(token) - end + it 'encrypts token' do + expect(encrypted_token[1...-described_class::NONCE_SIZE]).not_to eq('my-value-my-value-my-value') end end describe '.decrypt_token' do - context 'with feature flag switched off' do - before do - stub_feature_flags(dynamic_nonce: false) - end - - it 'decrypts token with static iv' do - encrypted_token = described_class.encrypt_token('my-value') - - expect(described_class.decrypt_token(encrypted_token)).to eq('my-value') - end - - it 'decrypts token if feature flag changed after encryption' do - encrypted_token = described_class.encrypt_token('my-value') - - expect(encrypted_token).not_to eq('my-value') - - stub_feature_flags(dynamic_nonce: true) - - expect(described_class.decrypt_token(encrypted_token)).to eq('my-value') - end - - it 'decrypts token with dynamic iv' do - iv = ::Digest::SHA256.hexdigest('my-value').bytes.take(described_class::NONCE_SIZE).pack('c*') - token = Gitlab::CryptoHelper.aes256_gcm_encrypt('my-value', nonce: iv) - encrypted_token = "#{described_class::DYNAMIC_NONCE_IDENTIFIER}#{token}#{iv}" - - expect(described_class.decrypt_token(encrypted_token)).to eq('my-value') - end - end - - context 'with feature flag switched on' do - before do - stub_feature_flags(dynamic_nonce: true) - end - - it 'decrypts token with dynamic iv' do - encrypted_token = described_class.encrypt_token('my-value') - - expect(described_class.decrypt_token(encrypted_token)).to eq('my-value') - end - - it 'decrypts token if feature flag changed after encryption' do - encrypted_token = described_class.encrypt_token('my-value') - - expect(encrypted_token).not_to eq('my-value') - - stub_feature_flags(dynamic_nonce: false) + it 'decrypts token with dynamic iv' do + encrypted_token = described_class.encrypt_token('my-value') - expect(described_class.decrypt_token(encrypted_token)).to eq('my-value') - end + expect(described_class.decrypt_token(encrypted_token)).to eq('my-value') end end end diff --git a/spec/models/concerns/uniquify_spec.rb b/spec/models/concerns/uniquify_spec.rb deleted file mode 100644 index 9b79e4d4154..00000000000 --- a/spec/models/concerns/uniquify_spec.rb +++ /dev/null @@ -1,44 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe Uniquify do - let(:uniquify) { described_class.new } - - describe "#string" do - it 'returns the given string if it does not exist' do - result = uniquify.string('test_string') { |s| false } - - expect(result).to eq('test_string') - end - - it 'returns the given string with a counter attached if the string exists' do - result = uniquify.string('test_string') { |s| s == 'test_string' } - - expect(result).to eq('test_string1') - end - - it 'increments the counter for each candidate string that also exists' do - result = uniquify.string('test_string') { |s| s == 'test_string' || s == 'test_string1' } - - expect(result).to eq('test_string2') - end - - it 'allows to pass an initial value for the counter' do - start_counting_from = 2 - uniquify = described_class.new(start_counting_from) - - result = uniquify.string('test_string') { |s| s == 'test_string' } - - expect(result).to eq('test_string2') - end - - it 'allows passing in a base function that defines the location of the counter' do - result = uniquify.string(-> (counter) { "test_#{counter}_string" }) do |s| - s == 'test__string' - end - - expect(result).to eq('test_1_string') - end - end -end diff --git a/spec/models/concerns/web_hooks/has_web_hooks_spec.rb b/spec/models/concerns/web_hooks/has_web_hooks_spec.rb new file mode 100644 index 00000000000..afb2406a969 --- /dev/null +++ b/spec/models/concerns/web_hooks/has_web_hooks_spec.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe WebHooks::HasWebHooks, feature_category: :integrations do + let(:minimal_test_class) do + Class.new do + include WebHooks::HasWebHooks + + def id + 1 + end + end + end + + before do + stub_const('MinimalTestClass', minimal_test_class) + end + + describe '#last_failure_redis_key' do + subject { MinimalTestClass.new.last_failure_redis_key } + + it { is_expected.to eq('web_hooks:last_failure:minimal_test_class-1') } + end + + describe 'last_webhook_failure', :clean_gitlab_redis_shared_state do + subject { MinimalTestClass.new.last_webhook_failure } + + it { is_expected.to eq(nil) } + + context 'when there was an older failure', :clean_gitlab_redis_shared_state do + let(:last_failure_date) { 1.month.ago.iso8601 } + + before do + Gitlab::Redis::SharedState.with { |r| r.set('web_hooks:last_failure:minimal_test_class-1', last_failure_date) } + end + + it { is_expected.to eq(last_failure_date) } + end + end +end diff --git a/spec/models/container_registry/data_repair_detail_spec.rb b/spec/models/container_registry/data_repair_detail_spec.rb new file mode 100644 index 00000000000..4d2ac5fff42 --- /dev/null +++ b/spec/models/container_registry/data_repair_detail_spec.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe ContainerRegistry::DataRepairDetail, type: :model, feature_category: :container_registry do + let_it_be(:project) { create(:project) } + + subject { described_class.new(project: project) } + + it { is_expected.to belong_to(:project).required } + + it_behaves_like 'having unique enum values' + + describe '.ongoing_since' do + let_it_be(:repair_detail1) { create(:container_registry_data_repair_detail, :ongoing, updated_at: 1.day.ago) } + let_it_be(:repair_detail2) { create(:container_registry_data_repair_detail, :ongoing, updated_at: 20.minutes.ago) } + let_it_be(:repair_detail3) do + create(:container_registry_data_repair_detail, :completed, updated_at: 20.minutes.ago) + end + + let_it_be(:repair_detail4) do + create(:container_registry_data_repair_detail, :completed, updated_at: 31.minutes.ago) + end + + subject { described_class.ongoing_since(30.minutes.ago) } + + it { is_expected.to contain_exactly(repair_detail1) } + end +end diff --git a/spec/models/container_registry/event_spec.rb b/spec/models/container_registry/event_spec.rb index 07ac35f7b6a..d5c3a5fc066 100644 --- a/spec/models/container_registry/event_spec.rb +++ b/spec/models/container_registry/event_spec.rb @@ -6,7 +6,7 @@ RSpec.describe ContainerRegistry::Event do using RSpec::Parameterized::TableSyntax let_it_be(:group) { create(:group, name: 'group') } - let_it_be(:project) { create(:project, name: 'test', namespace: group) } + let_it_be(:project) { create(:project, path: 'test', namespace: group) } describe '#supported?' do let(:raw_event) { { 'action' => action } } @@ -225,6 +225,28 @@ RSpec.describe ContainerRegistry::Event do end end + context 'when it is a manifest delete event' do + let(:raw_event) { { 'action' => 'delete', 'target' => { 'digest' => 'x' }, 'actor' => {} } } + + it 'calls the ContainerRegistryEventCounter' do + expect(::Gitlab::UsageDataCounters::ContainerRegistryEventCounter) + .to receive(:count).with('i_container_registry_delete_manifest') + + subject + end + end + + context 'when it is not a manifest delete event' do + let(:raw_event) { { 'action' => 'push', 'target' => { 'digest' => 'x' }, 'actor' => {} } } + + it 'does not call the ContainerRegistryEventCounter' do + expect(::Gitlab::UsageDataCounters::ContainerRegistryEventCounter) + .not_to receive(:count).with('i_container_registry_delete_manifest') + + subject + end + end + context 'without an actor name' do let(:raw_event) { { 'action' => 'push', 'target' => {}, 'actor' => { 'user_type' => 'personal_access_token' } } } diff --git a/spec/models/container_repository_spec.rb b/spec/models/container_repository_spec.rb index da7b54644bd..d8019e74c71 100644 --- a/spec/models/container_repository_spec.rb +++ b/spec/models/container_repository_spec.rb @@ -13,9 +13,9 @@ RSpec.describe ContainerRepository, :aggregate_failures, feature_category: :cont end before do - stub_container_registry_config(enabled: true, - api_url: 'http://registry.gitlab', - host_port: 'registry.gitlab') + stub_container_registry_config( + enabled: true, api_url: 'http://registry.gitlab', host_port: 'registry.gitlab' + ) stub_request(:get, "http://registry.gitlab/v2/group/test/my_image/tags/list?n=#{::ContainerRegistry::Client::DEFAULT_TAGS_PAGE_SIZE}") .with(headers: { 'Accept' => ContainerRegistry::Client::ACCEPTED_TYPES.join(', ') }) @@ -92,7 +92,7 @@ RSpec.describe ContainerRepository, :aggregate_failures, feature_category: :cont end end - shared_examples 'transitioning to pre_importing', skip_pre_import_success: true do + shared_examples 'transitioning to pre_importing' do before do repository.update_column(:migration_pre_import_done_at, Time.zone.now) end @@ -145,7 +145,7 @@ RSpec.describe ContainerRepository, :aggregate_failures, feature_category: :cont end end - shared_examples 'transitioning to importing', skip_import_success: true do + shared_examples 'transitioning to importing' do before do repository.update_columns(migration_import_done_at: Time.zone.now) end @@ -219,9 +219,7 @@ RSpec.describe ContainerRepository, :aggregate_failures, feature_category: :cont subject { repository.start_pre_import } before do |example| - unless example.metadata[:skip_pre_import_success] - allow(repository).to receive(:migration_pre_import).and_return(:ok) - end + allow(repository).to receive(:migration_pre_import).and_return(:ok) end it_behaves_like 'transitioning from allowed states', %w[default pre_importing importing import_aborted] @@ -234,9 +232,7 @@ RSpec.describe ContainerRepository, :aggregate_failures, feature_category: :cont subject { repository.retry_pre_import } before do |example| - unless example.metadata[:skip_pre_import_success] - allow(repository).to receive(:migration_pre_import).and_return(:ok) - end + allow(repository).to receive(:migration_pre_import).and_return(:ok) end it_behaves_like 'transitioning from allowed states', %w[pre_importing importing import_aborted] @@ -264,9 +260,7 @@ RSpec.describe ContainerRepository, :aggregate_failures, feature_category: :cont subject { repository.start_import } before do |example| - unless example.metadata[:skip_import_success] - allow(repository).to receive(:migration_import).and_return(:ok) - end + allow(repository).to receive(:migration_import).and_return(:ok) end it_behaves_like 'transitioning from allowed states', %w[pre_import_done pre_importing importing import_aborted] @@ -279,9 +273,7 @@ RSpec.describe ContainerRepository, :aggregate_failures, feature_category: :cont subject { repository.retry_import } before do |example| - unless example.metadata[:skip_import_success] - allow(repository).to receive(:migration_import).and_return(:ok) - end + allow(repository).to receive(:migration_import).and_return(:ok) end it_behaves_like 'transitioning from allowed states', %w[pre_importing importing import_aborted] @@ -374,9 +366,7 @@ RSpec.describe ContainerRepository, :aggregate_failures, feature_category: :cont subject { repository.finish_pre_import_and_start_import } before do |example| - unless example.metadata[:skip_import_success] - allow(repository).to receive(:migration_import).and_return(:ok) - end + allow(repository).to receive(:migration_import).and_return(:ok) end it_behaves_like 'transitioning from allowed states', %w[pre_importing importing import_aborted] @@ -528,6 +518,10 @@ RSpec.describe ContainerRepository, :aggregate_failures, feature_category: :cont describe '#each_tags_page' do let(:page_size) { 100 } + before do + allow(repository).to receive(:migrated?).and_return(true) + end + shared_examples 'iterating through a page' do |expected_tags: true| it 'iterates through one page' do expect(repository.gitlab_api_client).to receive(:tags) @@ -660,7 +654,7 @@ RSpec.describe ContainerRepository, :aggregate_failures, feature_category: :cont context 'calling on a non migrated repository' do before do - repository.update!(created_at: described_class::MIGRATION_PHASE_1_ENDED_AT - 3.days) + allow(repository).to receive(:migrated?).and_return(false) end it 'raises an Argument error' do @@ -695,9 +689,12 @@ RSpec.describe ContainerRepository, :aggregate_failures, feature_category: :cont describe '#delete_tags!' do let(:repository) do - create(:container_repository, name: 'my_image', - tags: { latest: '123', rc1: '234' }, - project: project) + create( + :container_repository, + name: 'my_image', + tags: { latest: '123', rc1: '234' }, + project: project + ) end context 'when action succeeds' do @@ -725,9 +722,12 @@ RSpec.describe ContainerRepository, :aggregate_failures, feature_category: :cont describe '#delete_tag_by_name' do let(:repository) do - create(:container_repository, name: 'my_image', - tags: { latest: '123', rc1: '234' }, - project: project) + create( + :container_repository, + name: 'my_image', + tags: { latest: '123', rc1: '234' }, + project: project + ) end context 'when action succeeds' do @@ -756,9 +756,11 @@ RSpec.describe ContainerRepository, :aggregate_failures, feature_category: :cont describe '#location' do context 'when registry is running on a custom port' do before do - stub_container_registry_config(enabled: true, - api_url: 'http://registry.gitlab:5000', - host_port: 'registry.gitlab:5000') + stub_container_registry_config( + enabled: true, + api_url: 'http://registry.gitlab:5000', + host_port: 'registry.gitlab:5000' + ) end it 'returns a full location of the repository' do @@ -795,6 +797,7 @@ RSpec.describe ContainerRepository, :aggregate_failures, feature_category: :cont freeze_time do expect { subject } .to change { repository.expiration_policy_started_at }.from(nil).to(Time.zone.now) + .and change { repository.expiration_policy_cleanup_status }.from('cleanup_unscheduled').to('cleanup_ongoing') .and change { repository.last_cleanup_deleted_tags_count }.from(10).to(nil) end end @@ -1236,6 +1239,8 @@ RSpec.describe ContainerRepository, :aggregate_failures, feature_category: :cont end before do + allow(group).to receive(:first_project_with_container_registry_tags).and_return(nil) + group.parent = test_group group.save! end @@ -1561,22 +1566,20 @@ RSpec.describe ContainerRepository, :aggregate_failures, feature_category: :cont describe '#migrated?' do subject { repository.migrated? } - it { is_expected.to eq(true) } - - context 'with a created_at older than phase 1 ends' do + context 'on gitlab.com' do before do - repository.update!(created_at: described_class::MIGRATION_PHASE_1_ENDED_AT - 3.days) + allow(::Gitlab).to receive(:com?).and_return(true) end - it { is_expected.to eq(false) } - - context 'with migration state set to import_done' do - before do - repository.update!(migration_state: 'import_done') - end + it { is_expected.to eq(true) } + end - it { is_expected.to eq(true) } + context 'not on gitlab.com' do + before do + allow(::Gitlab).to receive(:com?).and_return(false) end + + it { is_expected.to eq(false) } end end @@ -1717,4 +1720,19 @@ RSpec.describe ContainerRepository, :aggregate_failures, feature_category: :cont it { is_expected.to contain_exactly(*stale_migrations) } end + + describe '#registry' do + it 'caches the client' do + registry = repository.registry + registry1 = repository.registry + registry2 = nil + + travel_to(Time.current + Gitlab::CurrentSettings.container_registry_token_expire_delay.minutes) do + registry2 = repository.registry + end + + expect(registry1.object_id).to be(registry.object_id) + expect(registry2.object_id).not_to be(registry.object_id) + end + end end diff --git a/spec/models/customer_relations/contact_spec.rb b/spec/models/customer_relations/contact_spec.rb index 487af404a7c..6beb5323f60 100644 --- a/spec/models/customer_relations/contact_spec.rb +++ b/spec/models/customer_relations/contact_spec.rb @@ -241,8 +241,8 @@ RSpec.describe CustomerRelations::Contact, type: :model do end describe 'sorting' do - let_it_be(:organization_a) { create(:organization, name: 'a') } - let_it_be(:organization_b) { create(:organization, name: 'b') } + let_it_be(:crm_organization_a) { create(:crm_organization, name: 'a') } + let_it_be(:crm_organization_b) { create(:crm_organization, name: 'b') } let_it_be(:contact_a) { create(:contact, group: group, first_name: "c", last_name: "d") } let_it_be(:contact_b) do create(:contact, @@ -250,7 +250,7 @@ RSpec.describe CustomerRelations::Contact, type: :model do first_name: "a", last_name: "b", phone: "123", - organization: organization_a) + organization: crm_organization_a) end let_it_be(:contact_c) do @@ -259,7 +259,7 @@ RSpec.describe CustomerRelations::Contact, type: :model do first_name: "e", last_name: "d", phone: "456", - organization: organization_b) + organization: crm_organization_b) end describe '.sort_by_name' do diff --git a/spec/models/customer_relations/organization_spec.rb b/spec/models/customer_relations/organization_spec.rb index d19a0bdf6c7..7fab9fd0e80 100644 --- a/spec/models/customer_relations/organization_spec.rb +++ b/spec/models/customer_relations/organization_spec.rb @@ -10,7 +10,7 @@ RSpec.describe CustomerRelations::Organization, type: :model do end describe 'validations' do - subject { build(:organization) } + subject { build(:crm_organization) } it { is_expected.to validate_presence_of(:group) } it { is_expected.to validate_presence_of(:name) } @@ -21,13 +21,13 @@ RSpec.describe CustomerRelations::Organization, type: :model do describe '#root_group' do context 'when root group' do - subject { build(:organization, group: group) } + subject { build(:crm_organization, group: group) } it { is_expected.to be_valid } end context 'when subgroup' do - subject { build(:organization, group: create(:group, parent: group)) } + subject { build(:crm_organization, group: create(:group, parent: group)) } it { is_expected.to be_invalid } end @@ -43,23 +43,25 @@ RSpec.describe CustomerRelations::Organization, type: :model do end describe '#find_by_name' do - let!(:organiztion1) { create(:organization, group: group, name: 'Test') } - let!(:organiztion2) { create(:organization, group: create(:group), name: 'Test') } + let!(:crm_organiztion1) { create(:crm_organization, group: group, name: 'Test') } + let!(:crm_organiztion2) { create(:crm_organization, group: create(:group), name: 'Test') } it 'strips name' do - expect(described_class.find_by_name(group.id, 'TEST')).to eq([organiztion1]) + expect(described_class.find_by_name(group.id, 'TEST')).to eq([crm_organiztion1]) end end describe '#self.move_to_root_group' do let!(:old_root_group) { create(:group) } - let!(:organizations) { create_list(:organization, 4, group: old_root_group) } + let!(:crm_organizations) { create_list(:crm_organization, 4, group: old_root_group) } let!(:new_root_group) { create(:group) } - let!(:contact1) { create(:contact, group: new_root_group, organization: organizations[0]) } - let!(:contact2) { create(:contact, group: new_root_group, organization: organizations[1]) } + let!(:contact1) { create(:contact, group: new_root_group, organization: crm_organizations[0]) } + let!(:contact2) { create(:contact, group: new_root_group, organization: crm_organizations[1]) } - let!(:dupe_organization1) { create(:organization, group: new_root_group, name: organizations[1].name) } - let!(:dupe_organization2) { create(:organization, group: new_root_group, name: organizations[3].name.upcase) } + let!(:dupe_crm_organization1) { create(:crm_organization, group: new_root_group, name: crm_organizations[1].name) } + let!(:dupe_crm_organization2) do + create(:crm_organization, group: new_root_group, name: crm_organizations[3].name.upcase) + end before do old_root_group.update!(parent: new_root_group) @@ -67,22 +69,22 @@ RSpec.describe CustomerRelations::Organization, type: :model do end it 'moves organizations with unique names and deletes the rest' do - expect(organizations[0].reload.group_id).to eq(new_root_group.id) - expect(organizations[2].reload.group_id).to eq(new_root_group.id) - expect { organizations[1].reload }.to raise_error(ActiveRecord::RecordNotFound) - expect { organizations[3].reload }.to raise_error(ActiveRecord::RecordNotFound) + expect(crm_organizations[0].reload.group_id).to eq(new_root_group.id) + expect(crm_organizations[2].reload.group_id).to eq(new_root_group.id) + expect { crm_organizations[1].reload }.to raise_error(ActiveRecord::RecordNotFound) + expect { crm_organizations[3].reload }.to raise_error(ActiveRecord::RecordNotFound) end it 'updates contact.organization_id for dupes and leaves the rest untouched' do - expect(contact1.reload.organization_id).to eq(organizations[0].id) - expect(contact2.reload.organization_id).to eq(dupe_organization1.id) + expect(contact1.reload.organization_id).to eq(crm_organizations[0].id) + expect(contact2.reload.organization_id).to eq(dupe_crm_organization1.id) end end describe '.search' do - let_it_be(:organization_a) do + let_it_be(:crm_organization_a) do create( - :organization, + :crm_organization, group: group, name: "DEF", description: "ghi_st", @@ -90,9 +92,9 @@ RSpec.describe CustomerRelations::Organization, type: :model do ) end - let_it_be(:organization_b) do + let_it_be(:crm_organization_b) do create( - :organization, + :crm_organization, group: group, name: "ABC_st", description: "JKL", @@ -106,7 +108,7 @@ RSpec.describe CustomerRelations::Organization, type: :model do let(:search_term) { "" } it 'returns all group organizations' do - expect(found_organizations).to contain_exactly(organization_a, organization_b) + expect(found_organizations).to contain_exactly(crm_organization_a, crm_organization_b) end end @@ -114,42 +116,42 @@ RSpec.describe CustomerRelations::Organization, type: :model do context 'when searching for name' do let(:search_term) { "aBc" } - it { is_expected.to contain_exactly(organization_b) } + it { is_expected.to contain_exactly(crm_organization_b) } end context 'when searching for description' do let(:search_term) { "ghI" } - it { is_expected.to contain_exactly(organization_a) } + it { is_expected.to contain_exactly(crm_organization_a) } end context 'when searching for name and description' do let(:search_term) { "_st" } - it { is_expected.to contain_exactly(organization_a, organization_b) } + it { is_expected.to contain_exactly(crm_organization_a, crm_organization_b) } end end end describe '.search_by_state' do - let_it_be(:organization_a) { create(:organization, group: group, state: "inactive") } - let_it_be(:organization_b) { create(:organization, group: group, state: "active") } + let_it_be(:crm_organization_a) { create(:crm_organization, group: group, state: "inactive") } + let_it_be(:crm_organization_b) { create(:crm_organization, group: group, state: "active") } context 'when searching for organizations state' do it 'returns only inactive organizations' do - expect(group.organizations.search_by_state(:inactive)).to contain_exactly(organization_a) + expect(group.organizations.search_by_state(:inactive)).to contain_exactly(crm_organization_a) end it 'returns only active organizations' do - expect(group.organizations.search_by_state(:active)).to contain_exactly(organization_b) + expect(group.organizations.search_by_state(:active)).to contain_exactly(crm_organization_b) end end end describe '.counts_by_state' do before do - create_list(:organization, 3, group: group) - create_list(:organization, 2, group: group, state: 'inactive') + create_list(:crm_organization, 3, group: group) + create_list(:crm_organization, 2, group: group, state: 'inactive') end it 'returns correct organization counts' do @@ -168,20 +170,20 @@ RSpec.describe CustomerRelations::Organization, type: :model do end describe 'sorting' do - let_it_be(:organization_a) { create(:organization, group: group, name: "c", description: "1") } - let_it_be(:organization_b) { create(:organization, group: group, name: "a") } - let_it_be(:organization_c) { create(:organization, group: group, name: "b", description: "2") } + let_it_be(:crm_organization_a) { create(:crm_organization, group: group, name: "c", description: "1") } + let_it_be(:crm_organization_b) { create(:crm_organization, group: group, name: "a") } + let_it_be(:crm_organization_c) { create(:crm_organization, group: group, name: "b", description: "2") } describe '.sort_by_name' do it 'sorts them by name in ascendent order' do - expect(group.organizations.sort_by_name).to eq([organization_b, organization_c, organization_a]) + expect(group.organizations.sort_by_name).to eq([crm_organization_b, crm_organization_c, crm_organization_a]) end end describe '.sort_by_field' do it 'sorts them by description in descending order' do expect(group.organizations.sort_by_field('description', :desc)) - .to eq([organization_c, organization_a, organization_b]) + .to eq([crm_organization_c, crm_organization_a, crm_organization_b]) end end end diff --git a/spec/models/deployment_spec.rb b/spec/models/deployment_spec.rb index 46a1b4ce588..2a7a8d50895 100644 --- a/spec/models/deployment_spec.rb +++ b/spec/models/deployment_spec.rb @@ -170,13 +170,6 @@ RSpec.describe Deployment, feature_category: :continuous_delivery do deployment.run! end end - - it 'does not execute Deployments::DropOlderDeploymentsWorker' do - expect(Deployments::DropOlderDeploymentsWorker) - .not_to receive(:perform_async).with(deployment.id) - - deployment.run! - end end context 'when deployment succeeded' do @@ -383,8 +376,14 @@ RSpec.describe Deployment, feature_category: :continuous_delivery do let_it_be(:commits) { project.repository.commits('master', limit: 2) } let!(:deployment) do - create(:deployment, :success, project: project, environment: environment, - finished_at: 1.year.ago, sha: commits[0].sha) + create( + :deployment, + :success, + project: project, + environment: environment, + finished_at: 1.year.ago, + sha: commits[0].sha + ) end let!(:last_deployment) do @@ -1355,10 +1354,9 @@ RSpec.describe Deployment, feature_category: :continuous_delivery do subject { deployment.tags } it 'will return tags related to this deployment' do - expect(project.repository).to receive(:refs_by_oid).with(oid: deployment.sha, - limit: 100, - ref_patterns: [Gitlab::Git::TAG_REF_PREFIX]) - .and_return(["#{Gitlab::Git::TAG_REF_PREFIX}test"]) + expect(project.repository).to receive(:refs_by_oid).with( + oid: deployment.sha, limit: 100, ref_patterns: [Gitlab::Git::TAG_REF_PREFIX] + ).and_return(["#{Gitlab::Git::TAG_REF_PREFIX}test"]) is_expected.to match_array(['refs/tags/test']) end diff --git a/spec/models/design_management/design_collection_spec.rb b/spec/models/design_management/design_collection_spec.rb index bc8330c7dd3..06596d37fde 100644 --- a/spec/models/design_management/design_collection_spec.rb +++ b/spec/models/design_management/design_collection_spec.rb @@ -128,7 +128,7 @@ RSpec.describe DesignManagement::DesignCollection do describe "#repository" do it "builds a design repository" do - expect(collection.repository).to be_a(DesignManagement::Repository) + expect(collection.repository).to be_a(DesignManagement::GitRepository) end end diff --git a/spec/models/design_management/design_spec.rb b/spec/models/design_management/design_spec.rb index 57e0d1cad8b..72c0d1d1a64 100644 --- a/spec/models/design_management/design_spec.rb +++ b/spec/models/design_management/design_spec.rb @@ -55,6 +55,7 @@ RSpec.describe DesignManagement::Design, feature_category: :design_management do it { is_expected.to validate_presence_of(:issue) } it { is_expected.to validate_presence_of(:filename) } it { is_expected.to validate_length_of(:filename).is_at_most(255) } + it { is_expected.to validate_length_of(:description).is_at_most(Gitlab::Database::MAX_TEXT_SIZE_LIMIT) } it { is_expected.to validate_uniqueness_of(:filename).scoped_to(:issue_id) } it "validates that the extension is an image" do @@ -462,7 +463,7 @@ RSpec.describe DesignManagement::Design, feature_category: :design_management do it 'is a design repository' do design = build(:design, issue: issue) - expect(design.repository).to be_a(DesignManagement::Repository) + expect(design.repository).to be_a(DesignManagement::GitRepository) end end @@ -512,11 +513,11 @@ RSpec.describe DesignManagement::Design, feature_category: :design_management do end describe '#to_reference' do - let(:namespace) { build(:namespace, id: non_existing_record_id, path: 'sample-namespace') } - let(:project) { build(:project, name: 'sample-project', namespace: namespace) } - let(:group) { create(:group, name: 'Group', path: 'sample-group') } - let(:issue) { build(:issue, iid: 1, project: project) } let(:filename) { 'homescreen.jpg' } + let(:namespace) { build(:namespace, id: non_existing_record_id) } + let(:project) { build(:project, namespace: namespace) } + let(:group) { build(:group) } + let(:issue) { build(:issue, iid: 1, project: project) } let(:design) { build(:design, filename: filename, issue: issue, project: project) } context 'when nil argument' do @@ -535,7 +536,7 @@ RSpec.describe DesignManagement::Design, feature_category: :design_management do design.to_reference(group, full: true) ] - expect(refs).to all(eq 'sample-namespace/sample-project#1/designs[homescreen.jpg]') + expect(refs).to all(eq "#{project.full_path}#1/designs[homescreen.jpg]") end end @@ -546,7 +547,7 @@ RSpec.describe DesignManagement::Design, feature_category: :design_management do design.to_reference(group, full: false) ] - expect(refs).to all(eq 'sample-namespace/sample-project#1[homescreen.jpg]') + expect(refs).to all(eq "#{project.full_path}#1[homescreen.jpg]") end end @@ -595,7 +596,7 @@ RSpec.describe DesignManagement::Design, feature_category: :design_management do 'url_filename' => filename, 'issue' => issue.iid.to_s, 'namespace' => design.project.namespace.to_param, - 'project' => design.project.name + 'project' => design.project.to_param ) end diff --git a/spec/models/design_management/git_repository_spec.rb b/spec/models/design_management/git_repository_spec.rb new file mode 100644 index 00000000000..736f2ad45cf --- /dev/null +++ b/spec/models/design_management/git_repository_spec.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe DesignManagement::GitRepository, feature_category: :design_management do + let_it_be(:container_repo) { DesignManagement::Repository.new(project: create(:project)) } + let(:git_repository) { container_repo.repository } + + shared_examples 'returns parsed git attributes that enable LFS for all file types' do + it do + expect(subject.patterns).to be_a_kind_of(Hash) + expect(subject.patterns).to have_key('/designs/*') + expect(subject.patterns['/designs/*']).to eql( + { "filter" => "lfs", "diff" => "lfs", "merge" => "lfs", "text" => false } + ) + end + end + + describe '.container' do + it 'is of class DesignManagement::Repository' do + expect(git_repository.container).to be_a_kind_of(DesignManagement::Repository) + end + end + + describe "#info_attributes" do + subject { git_repository.info_attributes } + + include_examples 'returns parsed git attributes that enable LFS for all file types' + end + + describe '#attributes_at' do + subject { git_repository.attributes_at } + + include_examples 'returns parsed git attributes that enable LFS for all file types' + end + + describe '#gitattribute' do + it 'returns a gitattribute when path has gitattributes' do + expect(git_repository.gitattribute('/designs/file.txt', 'filter')).to eq('lfs') + end + + it 'returns nil when path has no gitattributes' do + expect(git_repository.gitattribute('/invalid/file.txt', 'filter')).to be_nil + end + end + + describe '#copy_gitattributes' do + it 'always returns regardless of whether given a valid or invalid ref' do + expect(git_repository.copy_gitattributes('master')).to be true + expect(git_repository.copy_gitattributes('invalid')).to be true + end + end + + describe '#attributes' do + it 'confirms that all files are LFS enabled' do + %w[png zip anything].each do |filetype| + path = "/#{DesignManagement.designs_directory}/file.#{filetype}" + attributes = git_repository.attributes(path) + + expect(attributes['filter']).to eq('lfs') + end + end + end +end diff --git a/spec/models/design_management/repository_spec.rb b/spec/models/design_management/repository_spec.rb index 0115e0c139c..74f393306dc 100644 --- a/spec/models/design_management/repository_spec.rb +++ b/spec/models/design_management/repository_spec.rb @@ -2,57 +2,24 @@ require 'spec_helper' -RSpec.describe DesignManagement::Repository do - let(:project) { create(:project) } - let(:repository) { described_class.new(project) } +RSpec.describe DesignManagement::Repository, feature_category: :design_management do + let_it_be(:project) { create(:project) } + let(:subject) { described_class.new({ project: project }) } - shared_examples 'returns parsed git attributes that enable LFS for all file types' do - it do - expect(subject.patterns).to be_a_kind_of(Hash) - expect(subject.patterns).to have_key('/designs/*') - expect(subject.patterns['/designs/*']).to eql( - { "filter" => "lfs", "diff" => "lfs", "merge" => "lfs", "text" => false } - ) - end + describe 'associations' do + it { is_expected.to belong_to(:project).inverse_of(:design_management_repository) } end - describe "#info_attributes" do - subject { repository.info_attributes } - - include_examples 'returns parsed git attributes that enable LFS for all file types' - end - - describe '#attributes_at' do - subject { repository.attributes_at } - - include_examples 'returns parsed git attributes that enable LFS for all file types' + describe 'validations' do + it { is_expected.to validate_presence_of(:project) } + it { is_expected.to validate_uniqueness_of(:project) } end - describe '#gitattribute' do - it 'returns a gitattribute when path has gitattributes' do - expect(repository.gitattribute('/designs/file.txt', 'filter')).to eq('lfs') - end - - it 'returns nil when path has no gitattributes' do - expect(repository.gitattribute('/invalid/file.txt', 'filter')).to be_nil - end - end - - describe '#copy_gitattributes' do - it 'always returns regardless of whether given a valid or invalid ref' do - expect(repository.copy_gitattributes('master')).to be true - expect(repository.copy_gitattributes('invalid')).to be true - end + it "returns the project's full path" do + expect(subject.full_path).to eq(project.full_path + Gitlab::GlRepository::DESIGN.path_suffix) end - describe '#attributes' do - it 'confirms that all files are LFS enabled' do - %w(png zip anything).each do |filetype| - path = "/#{DesignManagement.designs_directory}/file.#{filetype}" - attributes = repository.attributes(path) - - expect(attributes['filter']).to eq('lfs') - end - end + it "returns the project's disk path" do + expect(subject.disk_path).to eq(project.disk_path + Gitlab::GlRepository::DESIGN.path_suffix) end end diff --git a/spec/models/design_management/version_spec.rb b/spec/models/design_management/version_spec.rb index 8c0d7e99ae5..748bcbffd29 100644 --- a/spec/models/design_management/version_spec.rb +++ b/spec/models/design_management/version_spec.rb @@ -247,10 +247,12 @@ RSpec.describe DesignManagement::Version do context 'there are a bunch of different designs in a variety of states' do let_it_be(:version) do - create(:design_version, - created_designs: create_list(:design, 3), - modified_designs: create_list(:design, 4), - deleted_designs: create_list(:design, 5)) + create( + :design_version, + created_designs: create_list(:design, 3), + modified_designs: create_list(:design, 4), + deleted_designs: create_list(:design, 5) + ) end it 'puts them in the right buckets' do diff --git a/spec/models/diff_note_spec.rb b/spec/models/diff_note_spec.rb index a526f91ddc1..0c2a0ce45d4 100644 --- a/spec/models/diff_note_spec.rb +++ b/spec/models/diff_note_spec.rb @@ -115,11 +115,13 @@ RSpec.describe DiffNote do describe '#create_diff_file callback' do context 'merge request' do let(:position) do - Gitlab::Diff::Position.new(old_path: "files/ruby/popen.rb", - new_path: "files/ruby/popen.rb", - old_line: nil, - new_line: 9, - diff_refs: merge_request.diff_refs) + Gitlab::Diff::Position.new( + old_path: "files/ruby/popen.rb", + new_path: "files/ruby/popen.rb", + old_line: nil, + new_line: 9, + diff_refs: merge_request.diff_refs + ) end subject { build(:diff_note_on_merge_request, project: project, position: position, noteable: merge_request) } @@ -131,10 +133,12 @@ RSpec.describe DiffNote do let(:diff_file) do diffs = merge_request.diffs raw_diff = diffs.diffable.raw_diffs(diffs.diff_options.merge(paths: ['files/ruby/popen.rb'])).first - Gitlab::Diff::File.new(raw_diff, - repository: diffs.project.repository, - diff_refs: diffs.diff_refs, - fallback_diff_refs: diffs.fallback_diff_refs) + Gitlab::Diff::File.new( + raw_diff, + repository: diffs.project.repository, + diff_refs: diffs.diff_refs, + fallback_diff_refs: diffs.fallback_diff_refs + ) end let(:diff_line) { diff_file.diff_lines.first } @@ -188,10 +192,12 @@ RSpec.describe DiffNote do end it 'raises an error' do - expect { subject.save! }.to raise_error(::DiffNote::NoteDiffFileCreationError, - "Failed to find diff line for: #{diff_file.file_path}, "\ - "old_line: #{position.old_line}"\ - ", new_line: #{position.new_line}") + expect { subject.save! }.to raise_error( + ::DiffNote::NoteDiffFileCreationError, + "Failed to find diff line for: #{diff_file.file_path}, "\ + "old_line: #{position.old_line}"\ + ", new_line: #{position.new_line}" + ) end end @@ -383,8 +389,7 @@ RSpec.describe DiffNote do subject { create(:diff_note_on_commit, project: project, position: position, commit_id: commit.id) } it "doesn't update the position" do - is_expected.to have_attributes(original_position: position, - position: position) + is_expected.to have_attributes(original_position: position, position: position) end end diff --git a/spec/models/environment_spec.rb b/spec/models/environment_spec.rb index dfb7de34993..87beba680d8 100644 --- a/spec/models/environment_spec.rb +++ b/spec/models/environment_spec.rb @@ -126,7 +126,7 @@ RSpec.describe Environment, :use_clean_rails_memory_store_caching, feature_categ expect(environment.errors[:external_url].first).to eq(expected_error_message) else expect(environment.errors[:external_url]).to be_empty, - "There were unexpected errors: #{environment.errors.full_messages}" + "There were unexpected errors: #{environment.errors.full_messages}" expect(environment.external_url).to eq(source_external_url) end end @@ -660,15 +660,17 @@ RSpec.describe Environment, :use_clean_rails_memory_store_caching, feature_categ let(:build) { create(:ci_build, :success) } let!(:deployment) do - create(:deployment, :success, - environment: environment, - deployable: build, - on_stop: 'close_app') + create( + :deployment, + :success, + environment: environment, + deployable: build, + on_stop: 'close_app' + ) end let!(:close_action) do - create(:ci_build, :manual, pipeline: build.pipeline, - name: 'close_app') + create(:ci_build, :manual, pipeline: build.pipeline, name: 'close_app') end context 'when environment is available' do @@ -750,8 +752,7 @@ RSpec.describe Environment, :use_clean_rails_memory_store_caching, feature_categ before do project.add_developer(user) - create(:protected_branch, :developers_can_merge, - name: 'master', project: project) + create(:protected_branch, :developers_can_merge, name: 'master', project: project) end context 'when action did not yet finish' do @@ -774,8 +775,7 @@ RSpec.describe Environment, :use_clean_rails_memory_store_caching, feature_categ context 'if action did finish' do let!(:close_action) do - create(:ci_build, :manual, :success, - pipeline: pipeline, name: 'close_app_a') + create(:ci_build, :manual, :success, pipeline: pipeline, name: 'close_app_a') end it 'returns a new action of the same type' do @@ -1258,8 +1258,7 @@ RSpec.describe Environment, :use_clean_rails_memory_store_caching, feature_categ describe '#deployment_platform' do context 'when there is a deployment platform for environment' do let!(:cluster) do - create(:cluster, :provided_by_gcp, - environment_scope: '*', projects: [project]) + create(:cluster, :provided_by_gcp, environment_scope: '*', projects: [project]) end it 'finds a deployment platform' do @@ -1387,7 +1386,7 @@ RSpec.describe Environment, :use_clean_rails_memory_store_caching, feature_categ context 'when the environment is available' do context 'with a deployment service' do - let(:project) { create(:project, :with_prometheus_integration, :repository) } + let_it_be(:project) { create(:project, :with_prometheus_integration, :repository) } context 'and a deployment' do let!(:deployment) { create(:deployment, environment: environment) } @@ -1460,7 +1459,7 @@ RSpec.describe Environment, :use_clean_rails_memory_store_caching, feature_categ end context 'when the environment is unavailable' do - let(:project) { create(:project, :with_prometheus_integration) } + let_it_be(:project) { create(:project, :with_prometheus_integration) } before do environment.stop @@ -1487,7 +1486,7 @@ RSpec.describe Environment, :use_clean_rails_memory_store_caching, feature_categ end describe '#metrics' do - let(:project) { create(:project, :with_prometheus_integration) } + let_it_be(:project) { create(:project, :with_prometheus_integration) } subject { environment.metrics } @@ -1523,7 +1522,7 @@ RSpec.describe Environment, :use_clean_rails_memory_store_caching, feature_categ end describe '#additional_metrics' do - let(:project) { create(:project, :with_prometheus_integration) } + let_it_be(:project) { create(:project, :with_prometheus_integration) } let(:metric_params) { [] } subject { environment.additional_metrics(*metric_params) } diff --git a/spec/models/environment_status_spec.rb b/spec/models/environment_status_spec.rb index 2f1edf9ab94..9814eed8b45 100644 --- a/spec/models/environment_status_spec.rb +++ b/spec/models/environment_status_spec.rb @@ -17,6 +17,7 @@ RSpec.describe EnvironmentStatus do it { is_expected.to delegate_method(:name).to(:environment) } it { is_expected.to delegate_method(:deployed_at).to(:deployment) } it { is_expected.to delegate_method(:status).to(:deployment) } + it { is_expected.to delegate_method(:deployable).to(:deployment) } describe '#project' do subject { environment_status.project } @@ -177,11 +178,13 @@ RSpec.describe EnvironmentStatus do let(:pipeline) { create(:ci_pipeline, sha: sha, project: forked) } let(:merge_request) do - create(:merge_request, - source_project: forked, - target_project: project, - target_branch: 'master', - head_pipeline: pipeline) + create( + :merge_request, + source_project: forked, + target_project: project, + target_branch: 'master', + head_pipeline: pipeline + ) end it 'returns environment status' do @@ -198,12 +201,14 @@ RSpec.describe EnvironmentStatus do let(:pipeline) { create(:ci_pipeline, sha: sha, project: project) } let(:merge_request) do - create(:merge_request, - source_project: project, - source_branch: 'feature', - target_project: project, - target_branch: 'master', - head_pipeline: pipeline) + create( + :merge_request, + source_project: project, + source_branch: 'feature', + target_project: project, + target_branch: 'master', + head_pipeline: pipeline + ) end it 'returns environment status' do @@ -265,6 +270,7 @@ RSpec.describe EnvironmentStatus do context 'when environment is stopped' do before do + stub_feature_flags(review_apps_redeploy_mr_widget: false) environment.stop! end @@ -272,6 +278,17 @@ RSpec.describe EnvironmentStatus do expect(subject.count).to eq(0) end end + + context 'when environment is stopped and review_apps_redeploy_mr_widget is turned on' do + before do + stub_feature_flags(review_apps_redeploy_mr_widget: true) + environment.stop! + end + + it 'returns environment regardless of status' do + expect(subject.count).to eq(1) + end + end end end end diff --git a/spec/models/error_tracking/project_error_tracking_setting_spec.rb b/spec/models/error_tracking/project_error_tracking_setting_spec.rb index 26795c0ea7f..34be6ec7fa9 100644 --- a/spec/models/error_tracking/project_error_tracking_setting_spec.rb +++ b/spec/models/error_tracking/project_error_tracking_setting_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe ErrorTracking::ProjectErrorTrackingSetting do +RSpec.describe ErrorTracking::ProjectErrorTrackingSetting, feature_category: :error_tracking do include ReactiveCachingHelpers include Gitlab::Routing @@ -93,9 +93,7 @@ RSpec.describe ErrorTracking::ProjectErrorTrackingSetting do end context 'with sentry backend' do - before do - subject.integrated = false - end + subject { build(:project_error_tracking_setting, project: project) } it 'does not create a new client key' do expect { subject.save! }.not_to change { ErrorTracking::ClientKey.count } @@ -302,7 +300,7 @@ RSpec.describe ErrorTracking::ProjectErrorTrackingSetting do it { expect(result[:issue].gitlab_commit_path).to eq(nil) } end - context 'when repo commit matches first release version' do + context 'when repo commit matches first relase version' do let(:commit) { instance_double(Commit, id: commit_id) } let(:repository) { instance_double(Repository, commit: commit) } @@ -341,18 +339,19 @@ RSpec.describe ErrorTracking::ProjectErrorTrackingSetting do describe '#update_issue' do let(:result) { subject.update_issue(**opts) } - let(:opts) { { issue_id: 1, params: {} } } + let(:issue_id) { 1 } + let(:opts) { { issue_id: issue_id, params: {} } } before do allow(subject).to receive(:sentry_client).and_return(sentry_client) allow(sentry_client).to receive(:issue_details) - .with({ issue_id: 1 }) + .with({ issue_id: issue_id }) .and_return(Gitlab::ErrorTracking::DetailedError.new(project_id: sentry_project_id)) end context 'when sentry response is successful' do before do - allow(sentry_client).to receive(:update_issue).with(opts).and_return(true) + allow(sentry_client).to receive(:update_issue).with(**opts).and_return(true) end it 'returns the successful response' do @@ -362,7 +361,7 @@ RSpec.describe ErrorTracking::ProjectErrorTrackingSetting do context 'when sentry raises an error' do before do - allow(sentry_client).to receive(:update_issue).with(opts).and_raise(StandardError) + allow(sentry_client).to receive(:update_issue).with(**opts).and_raise(StandardError) end it 'returns the successful response' do @@ -391,7 +390,7 @@ RSpec.describe ErrorTracking::ProjectErrorTrackingSetting do setting.update!(sentry_project_id: nil) allow(sentry_client).to receive(:projects).and_return(sentry_projects) - allow(sentry_client).to receive(:update_issue).with(opts).and_return(true) + allow(sentry_client).to receive(:update_issue).with(**opts).and_return(true) end it 'tries to backfill it from sentry API' do @@ -419,6 +418,25 @@ RSpec.describe ErrorTracking::ProjectErrorTrackingSetting do end end end + + describe 'passing parameters to sentry client' do + include SentryClientHelpers + + let(:sentry_url) { 'https://sentrytest.gitlab.com/api/0' } + let(:sentry_request_url) { "#{sentry_url}/issues/#{issue_id}/" } + let(:token) { 'test-token' } + let(:sentry_client) { ErrorTracking::SentryClient.new(sentry_url, token) } + + before do + stub_sentry_request(sentry_request_url, :put, body: true) + + allow(sentry_client).to receive(:update_issue).and_call_original + end + + it 'returns the successful response' do + expect(result).to eq(updated: true) + end + end end describe 'slugs' do diff --git a/spec/models/event_collection_spec.rb b/spec/models/event_collection_spec.rb index 13983dcfde3..cab2444c174 100644 --- a/spec/models/event_collection_spec.rb +++ b/spec/models/event_collection_spec.rb @@ -19,8 +19,7 @@ RSpec.describe EventCollection do context 'with project events' do let_it_be(:push_event_payloads) do Array.new(9) do - create(:push_event_payload, - event: create(:push_event, project: project, author: user)) + create(:push_event_payload, event: create(:push_event, project: project, author: user)) end end diff --git a/spec/models/event_spec.rb b/spec/models/event_spec.rb index 931d12b7109..3e4fe57c59b 100644 --- a/spec/models/event_spec.rb +++ b/spec/models/event_spec.rb @@ -203,9 +203,7 @@ RSpec.describe Event, feature_category: :user_profile do let(:target) { nil } let(:event) do - described_class.new(project: project, - target: target, - author_id: author.id) + described_class.new(project: project, target: target, author_id: author.id) end context 'for an issue' do @@ -304,9 +302,7 @@ RSpec.describe Event, feature_category: :user_profile do let(:note_on_design) { create(:note_on_design, author: author, noteable: design, project: project) } let(:milestone_on_project) { create(:milestone, project: project) } let(:event) do - described_class.new(project: project, - target: target, - author_id: author.id) + described_class.new(project: project, target: target, author_id: author.id) end before do @@ -902,8 +898,10 @@ RSpec.describe Event, feature_category: :user_profile do it "deletes the redis key for if the project was inactive" do Gitlab::Redis::SharedState.with do |redis| - expect(redis).to receive(:hdel).with('inactive_projects_deletion_warning_email_notified', - "project:#{project.id}") + expect(redis).to receive(:hdel).with( + 'inactive_projects_deletion_warning_email_notified', + "project:#{project.id}" + ) end project.touch(:last_activity_at, time: 1.year.ago) @@ -1138,11 +1136,13 @@ RSpec.describe Event, feature_category: :user_profile do def create_push_event(project, user) event = create(:push_event, project: project, author: user) - create(:push_event_payload, - event: event, - commit_to: '1cf19a015df3523caf0a1f9d40c98a267d6a2fc2', - commit_count: 0, - ref: 'master') + create( + :push_event_payload, + event: event, + commit_to: '1cf19a015df3523caf0a1f9d40c98a267d6a2fc2', + commit_count: 0, + ref: 'master' + ) event end diff --git a/spec/models/generic_commit_status_spec.rb b/spec/models/generic_commit_status_spec.rb index fe22b20ecf9..c477dec237f 100644 --- a/spec/models/generic_commit_status_spec.rb +++ b/spec/models/generic_commit_status_spec.rb @@ -8,8 +8,7 @@ RSpec.describe GenericCommitStatus do let(:external_url) { 'http://example.gitlab.com/status' } let(:generic_commit_status) do - create(:generic_commit_status, pipeline: pipeline, - target_url: external_url) + create(:generic_commit_status, pipeline: pipeline, target_url: external_url) end describe 'validations' do diff --git a/spec/models/group_group_link_spec.rb b/spec/models/group_group_link_spec.rb index eec8fe0ef71..780196e6c8c 100644 --- a/spec/models/group_group_link_spec.rb +++ b/spec/models/group_group_link_spec.rb @@ -5,9 +5,28 @@ require 'spec_helper' RSpec.describe GroupGroupLink do let_it_be(:group) { create(:group) } let_it_be(:shared_group) { create(:group) } - let_it_be(:group_group_link) do - create(:group_group_link, shared_group: shared_group, - shared_with_group: group) + + describe 'validation' do + let_it_be(:group_group_link) do + create(:group_group_link, shared_group: shared_group, shared_with_group: group) + end + + it { is_expected.to validate_presence_of(:shared_group) } + + it do + is_expected.to( + validate_uniqueness_of(:shared_group_id) + .scoped_to(:shared_with_group_id) + .with_message('The group has already been shared with this group')) + end + + it { is_expected.to validate_presence_of(:shared_with_group) } + it { is_expected.to validate_presence_of(:group_access) } + + it do + is_expected.to( + validate_inclusion_of(:group_access).in_array(Gitlab::Access.values)) + end end describe 'relations' do @@ -16,42 +35,51 @@ RSpec.describe GroupGroupLink do end describe 'scopes' do - describe '.non_guests' do - let!(:group_group_link_reporter) { create :group_group_link, :reporter } - let!(:group_group_link_maintainer) { create :group_group_link, :maintainer } - let!(:group_group_link_owner) { create :group_group_link, :owner } - let!(:group_group_link_guest) { create :group_group_link, :guest } - - it 'returns all records which are greater than Guests access' do - expect(described_class.non_guests).to match_array([ - group_group_link_reporter, group_group_link, - group_group_link_maintainer, group_group_link_owner - ]) - end - end - - describe '.with_owner_or_maintainer_access' do + context 'for scopes fetching records based on access levels' do + let_it_be(:group_group_link_guest) { create :group_group_link, :guest } + let_it_be(:group_group_link_reporter) { create :group_group_link, :reporter } + let_it_be(:group_group_link_developer) { create :group_group_link, :developer } let_it_be(:group_group_link_maintainer) { create :group_group_link, :maintainer } let_it_be(:group_group_link_owner) { create :group_group_link, :owner } - let_it_be(:group_group_link_reporter) { create :group_group_link, :reporter } - let_it_be(:group_group_link_guest) { create :group_group_link, :guest } - it 'returns all records which have OWNER or MAINTAINER access' do - expect(described_class.with_owner_or_maintainer_access).to match_array([ - group_group_link_maintainer, - group_group_link_owner - ]) + describe '.non_guests' do + it 'returns all records which are greater than Guests access' do + expect(described_class.non_guests).to match_array([ + group_group_link_reporter, group_group_link_developer, + group_group_link_maintainer, group_group_link_owner + ]) + end end - end - describe '.with_owner_access' do - let_it_be(:group_group_link_maintainer) { create :group_group_link, :maintainer } - let_it_be(:group_group_link_owner) { create :group_group_link, :owner } - let_it_be(:group_group_link_reporter) { create :group_group_link, :reporter } - let_it_be(:group_group_link_guest) { create :group_group_link, :guest } + describe '.with_owner_or_maintainer_access' do + it 'returns all records which have OWNER or MAINTAINER access' do + expect(described_class.with_owner_or_maintainer_access).to match_array([ + group_group_link_maintainer, + group_group_link_owner + ]) + end + end - it 'returns all records which have OWNER access' do - expect(described_class.with_owner_access).to match_array([group_group_link_owner]) + describe '.with_owner_access' do + it 'returns all records which have OWNER access' do + expect(described_class.with_owner_access).to match_array([group_group_link_owner]) + end + end + + describe '.with_developer_access' do + it 'returns all records which have DEVELOPER access' do + expect(described_class.with_developer_access).to match_array([group_group_link_developer]) + end + end + + describe '.with_developer_maintainer_owner_access' do + it 'returns all records which have DEVELOPER, MAINTAINER or OWNER access' do + expect(described_class.with_developer_maintainer_owner_access).to match_array([ + group_group_link_developer, + group_group_link_owner, + group_group_link_maintainer + ]) + end end end @@ -93,6 +121,15 @@ RSpec.describe GroupGroupLink do let_it_be(:sub_shared_group) { create(:group, parent: shared_group) } let_it_be(:other_group) { create(:group) } + let_it_be(:group_group_link_1) do + create( + :group_group_link, + shared_group: shared_group, + shared_with_group: group, + group_access: Gitlab::Access::DEVELOPER + ) + end + let_it_be(:group_group_link_2) do create( :group_group_link, @@ -125,7 +162,7 @@ RSpec.describe GroupGroupLink do expect(described_class.all.count).to eq(4) expect(distinct_group_group_links.count).to eq(2) - expect(distinct_group_group_links).to include(group_group_link) + expect(distinct_group_group_links).to include(group_group_link_1) expect(distinct_group_group_links).not_to include(group_group_link_2) expect(distinct_group_group_links).not_to include(group_group_link_3) expect(distinct_group_group_links).to include(group_group_link_4) @@ -133,27 +170,9 @@ RSpec.describe GroupGroupLink do end end - describe 'validation' do - it { is_expected.to validate_presence_of(:shared_group) } - - it do - is_expected.to( - validate_uniqueness_of(:shared_group_id) - .scoped_to(:shared_with_group_id) - .with_message('The group has already been shared with this group')) - end - - it { is_expected.to validate_presence_of(:shared_with_group) } - it { is_expected.to validate_presence_of(:group_access) } - - it do - is_expected.to( - validate_inclusion_of(:group_access).in_array(Gitlab::Access.values)) - end - end - describe '#human_access' do it 'delegates to Gitlab::Access' do + group_group_link = create(:group_group_link, :reporter) expect(Gitlab::Access).to receive(:human_access).with(group_group_link.group_access) group_group_link.human_access @@ -161,6 +180,8 @@ RSpec.describe GroupGroupLink do end describe 'search by group name' do + let_it_be(:group_group_link) { create(:group_group_link, :reporter, shared_with_group: group) } + it { expect(described_class.search(group.name)).to eq([group_group_link]) } it { expect(described_class.search('not-a-group-name')).to be_empty } end diff --git a/spec/models/group_label_spec.rb b/spec/models/group_label_spec.rb index ec9244d5eb5..701348baf48 100644 --- a/spec/models/group_label_spec.rb +++ b/spec/models/group_label_spec.rb @@ -41,11 +41,13 @@ RSpec.describe GroupLabel do context 'cross-project' do let(:namespace) { build_stubbed(:namespace) } - let(:source_project) { build_stubbed(:project, name: 'project-1', namespace: namespace) } - let(:target_project) { build_stubbed(:project, name: 'project-2', namespace: namespace) } + let(:source_project) { build_stubbed(:project, namespace: namespace) } + let(:target_project) { build_stubbed(:project, namespace: namespace) } it 'returns a String reference to the object' do - expect(label.to_reference(source_project, target_project: target_project)).to eq %(project-1~#{label.id}) + expect(label.to_reference(source_project, target_project: target_project)).to( + eq("#{source_project.path}~#{label.id}") + ) end end @@ -56,4 +58,39 @@ RSpec.describe GroupLabel do end end end + + describe '#preloaded_parent_container' do + let_it_be(:label) { create(:group_label) } + + before do + label.reload # ensure associations are not loaded + end + + context 'when group is loaded' do + it 'does not invoke a DB query' do + label.group + + count = ActiveRecord::QueryRecorder.new { label.preloaded_parent_container }.count + expect(count).to eq(0) + expect(label.preloaded_parent_container).to eq(label.group) + end + end + + context 'when parent_container is loaded' do + it 'does not invoke a DB query' do + label.parent_container + + count = ActiveRecord::QueryRecorder.new { label.preloaded_parent_container }.count + expect(count).to eq(0) + expect(label.preloaded_parent_container).to eq(label.parent_container) + end + end + + context 'when none of them are loaded' do + it 'invokes a DB query' do + count = ActiveRecord::QueryRecorder.new { label.preloaded_parent_container }.count + expect(count).to eq(1) + end + end + end end diff --git a/spec/models/group_spec.rb b/spec/models/group_spec.rb index 0a05c558d45..67e4e128019 100644 --- a/spec/models/group_spec.rb +++ b/spec/models/group_spec.rb @@ -40,7 +40,14 @@ RSpec.describe Group, feature_category: :subgroups do it { is_expected.to have_many(:debian_distributions).class_name('Packages::Debian::GroupDistribution').dependent(:destroy) } it { is_expected.to have_many(:daily_build_group_report_results).class_name('Ci::DailyBuildGroupReportResult') } it { is_expected.to have_many(:group_callouts).class_name('Users::GroupCallout').with_foreign_key(:group_id) } + it { is_expected.to have_many(:bulk_import_exports).class_name('BulkImports::Export') } + + it do + is_expected.to have_many(:bulk_import_entities).class_name('BulkImports::Entity') + .with_foreign_key(:namespace_id).inverse_of(:group) + end + it { is_expected.to have_many(:contacts).class_name('CustomerRelations::Contact') } it { is_expected.to have_many(:organizations).class_name('CustomerRelations::Organization') } it { is_expected.to have_many(:protected_branches).inverse_of(:group).with_foreign_key(:namespace_id) } @@ -448,6 +455,8 @@ RSpec.describe Group, feature_category: :subgroups do it_behaves_like 'a BulkUsersByEmailLoad model' + it_behaves_like 'ensures runners_token is prefixed', :group + context 'after initialized' do it 'has a group_feature' do expect(described_class.new.group_feature).to be_present @@ -955,6 +964,23 @@ RSpec.describe Group, feature_category: :subgroups do end end + describe '.with_project_creation_levels' do + let_it_be(:group_1) { create(:group, project_creation_level: Gitlab::Access::NO_ONE_PROJECT_ACCESS) } + let_it_be(:group_2) { create(:group, project_creation_level: Gitlab::Access::DEVELOPER_MAINTAINER_PROJECT_ACCESS) } + let_it_be(:group_3) { create(:group, project_creation_level: Gitlab::Access::MAINTAINER_PROJECT_ACCESS) } + let_it_be(:group_4) { create(:group, project_creation_level: nil) } + + it 'returns groups with the specified project creation levels' do + result = described_class.with_project_creation_levels([ + Gitlab::Access::NO_ONE_PROJECT_ACCESS, + Gitlab::Access::MAINTAINER_PROJECT_ACCESS + ]) + + expect(result).to include(group_1, group_3) + expect(result).not_to include(group_2, group_4) + end + end + describe '.project_creation_allowed' do let_it_be(:group_1) { create(:group, project_creation_level: Gitlab::Access::NO_ONE_PROJECT_ACCESS) } let_it_be(:group_2) { create(:group, project_creation_level: Gitlab::Access::DEVELOPER_MAINTAINER_PROJECT_ACCESS) } @@ -967,6 +993,22 @@ RSpec.describe Group, feature_category: :subgroups do expect(result).to include(group_2, group_3, group_4) expect(result).not_to include(group_1) end + + context 'when the application_setting is set to `NO_ONE_PROJECT_ACCESS`' do + before do + stub_application_setting(default_project_creation: Gitlab::Access::NO_ONE_PROJECT_ACCESS) + end + + it 'only includes groups where project creation is allowed' do + result = described_class.project_creation_allowed + + expect(result).to include(group_2, group_3) + + # group_4 won't be included because it has `project_creation_level: nil`, + # and that means it behaves like the value of the application_setting will inherited. + expect(result).not_to include(group_1, group_4) + end + end end describe 'by_ids_or_paths' do @@ -2409,8 +2451,7 @@ RSpec.describe Group, feature_category: :subgroups do let(:shared_with_group) { create(:group, parent: group) } before do - create(:group_group_link, shared_group: nested_group, - shared_with_group: shared_with_group) + create(:group_group_link, shared_group: nested_group, shared_with_group: shared_with_group) end subject(:related_group_ids) { nested_group.related_group_ids } @@ -3115,11 +3156,11 @@ RSpec.describe Group, feature_category: :subgroups do describe '.organizations' do it 'returns organizations belonging to the group' do - organization1 = create(:organization, group: group) - create(:organization) - organization3 = create(:organization, group: group) + crm_organization1 = create(:crm_organization, group: group) + create(:crm_organization) + crm_organization3 = create(:crm_organization, group: group) - expect(group.organizations).to contain_exactly(organization1, organization3) + expect(group.organizations).to contain_exactly(crm_organization1, crm_organization3) end end @@ -3584,6 +3625,13 @@ RSpec.describe Group, feature_category: :subgroups do end end + describe '#content_editor_on_issues_feature_flag_enabled?' do + it_behaves_like 'checks self and root ancestor feature flag' do + let(:feature_flag) { :content_editor_on_issues } + let(:feature_flag_method) { :content_editor_on_issues_feature_flag_enabled? } + end + end + describe '#work_items_feature_flag_enabled?' do it_behaves_like 'checks self and root ancestor feature flag' do let(:feature_flag) { :work_items } @@ -3696,7 +3744,7 @@ RSpec.describe Group, feature_category: :subgroups do end end - describe '#usage_quotas_enabled?', feature_category: :subscription_cost_management, unless: Gitlab.ee? do + describe '#usage_quotas_enabled?', feature_category: :consumables_cost_management, unless: Gitlab.ee? do using RSpec::Parameterized::TableSyntax where(:feature_enabled, :root_group, :result) do diff --git a/spec/models/hooks/web_hook_spec.rb b/spec/models/hooks/web_hook_spec.rb index ad5f01fe056..254b8c2520b 100644 --- a/spec/models/hooks/web_hook_spec.rb +++ b/spec/models/hooks/web_hook_spec.rb @@ -267,7 +267,7 @@ RSpec.describe WebHook, feature_category: :integrations do end context 'without url variables' do - subject(:hook) { build_stubbed(:project_hook, project: project, url: 'http://example.com') } + subject(:hook) { build_stubbed(:project_hook, project: project, url: 'http://example.com', url_variables: nil) } it 'does not reset url variables' do hook.url = 'http://example.com/{one}/{two}' @@ -346,7 +346,7 @@ RSpec.describe WebHook, feature_category: :integrations do end it 'is 10 minutes' do - expect(hook.next_backoff).to eq(described_class::INITIAL_BACKOFF) + expect(hook.next_backoff).to eq(WebHooks::AutoDisabling::INITIAL_BACKOFF) end end @@ -356,7 +356,7 @@ RSpec.describe WebHook, feature_category: :integrations do end it 'is twice the initial value' do - expect(hook.next_backoff).to eq(2 * described_class::INITIAL_BACKOFF) + expect(hook.next_backoff).to eq(2 * WebHooks::AutoDisabling::INITIAL_BACKOFF) end end @@ -366,7 +366,7 @@ RSpec.describe WebHook, feature_category: :integrations do end it 'grows exponentially' do - expect(hook.next_backoff).to eq(2 * 2 * 2 * described_class::INITIAL_BACKOFF) + expect(hook.next_backoff).to eq(2 * 2 * 2 * WebHooks::AutoDisabling::INITIAL_BACKOFF) end end @@ -376,7 +376,7 @@ RSpec.describe WebHook, feature_category: :integrations do end it 'does not exceed the max backoff value' do - expect(hook.next_backoff).to eq(described_class::MAX_BACKOFF) + expect(hook.next_backoff).to eq(WebHooks::AutoDisabling::MAX_BACKOFF) end end end @@ -498,13 +498,13 @@ RSpec.describe WebHook, feature_category: :integrations do end it 'reduces to MAX_FAILURES' do - expect { hook.backoff! }.to change(hook, :recent_failures).to(described_class::MAX_FAILURES) + expect { hook.backoff! }.to change(hook, :recent_failures).to(WebHooks::AutoDisabling::MAX_FAILURES) end end context 'when the recent failure value is MAX_FAILURES' do before do - hook.update!(recent_failures: described_class::MAX_FAILURES, disabled_until: 1.hour.ago) + hook.update!(recent_failures: WebHooks::AutoDisabling::MAX_FAILURES, disabled_until: 1.hour.ago) end it 'does not change recent_failures' do @@ -514,7 +514,7 @@ RSpec.describe WebHook, feature_category: :integrations do context 'when we have exhausted the grace period' do before do - hook.update!(recent_failures: described_class::FAILURE_THRESHOLD) + hook.update!(recent_failures: WebHooks::AutoDisabling::FAILURE_THRESHOLD) end it 'sets disabled_until to the next backoff' do @@ -527,8 +527,8 @@ RSpec.describe WebHook, feature_category: :integrations do context 'when we have backed off MAX_FAILURES times' do before do - stub_const("#{described_class}::MAX_FAILURES", 5) - (described_class::FAILURE_THRESHOLD + 5).times { hook.backoff! } + stub_const("WebHooks::AutoDisabling::MAX_FAILURES", 5) + (WebHooks::AutoDisabling::FAILURE_THRESHOLD + 5).times { hook.backoff! } end it 'does not let the backoff count exceed the maximum failure count' do @@ -544,7 +544,7 @@ RSpec.describe WebHook, feature_category: :integrations do 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) + expect(hook.backoff_count).to eq(WebHooks::AutoDisabling::MAX_FAILURES) end end end @@ -567,7 +567,7 @@ RSpec.describe WebHook, feature_category: :integrations do end it 'does not update the hook if the the failure count exceeds the maximum value' do - hook.recent_failures = described_class::MAX_FAILURES + hook.recent_failures = WebHooks::AutoDisabling::MAX_FAILURES sql_count = ActiveRecord::QueryRecorder.new { hook.failed! }.count diff --git a/spec/models/import_export_upload_spec.rb b/spec/models/import_export_upload_spec.rb index e13f504b82a..9811dbf60e3 100644 --- a/spec/models/import_export_upload_spec.rb +++ b/spec/models/import_export_upload_spec.rb @@ -51,7 +51,7 @@ RSpec.describe ImportExportUpload do let(:after_commit_callbacks) { described_class._commit_callbacks.select { |cb| cb.kind == :after } } def find_callback(callbacks, key) - callbacks.find { |cb| cb.instance_variable_get(:@key) == key } + callbacks.find { |cb| cb.filter == key } end it 'export file is stored in after_commit callback' do diff --git a/spec/models/import_failure_spec.rb b/spec/models/import_failure_spec.rb index 9fee1b0ae7b..101da1212cf 100644 --- a/spec/models/import_failure_spec.rb +++ b/spec/models/import_failure_spec.rb @@ -8,9 +8,18 @@ RSpec.describe ImportFailure do let_it_be(:correlation_id) { 'ABC' } let_it_be(:hard_failure) { create(:import_failure, :hard_failure, project: project, correlation_id_value: correlation_id) } let_it_be(:soft_failure) { create(:import_failure, :soft_failure, project: project, correlation_id_value: correlation_id) } + let_it_be(:github_import_failure) { create(:import_failure, :github_import_failure, project: project) } let_it_be(:unrelated_failure) { create(:import_failure, project: project) } - it 'returns hard failures given a correlation ID' do + it 'returns failures with external_identifiers' do + expect(ImportFailure.with_external_identifiers).to match_array([github_import_failure]) + end + + it 'returns failures for the given correlation ID' do + expect(ImportFailure.failures_by_correlation_id(correlation_id)).to match_array([hard_failure, soft_failure]) + end + + it 'returns hard failures for the given correlation ID' do expect(ImportFailure.hard_failures_by_correlation_id(correlation_id)).to eq([hard_failure]) end @@ -45,5 +54,21 @@ RSpec.describe ImportFailure do it { is_expected.to validate_presence_of(:group) } end + + describe '#external_identifiers' do + it { is_expected.to allow_value({ note_id: 234, noteable_id: 345, noteable_type: 'MergeRequest' }).for(:external_identifiers) } + it { is_expected.not_to allow_value(nil).for(:external_identifiers) } + it { is_expected.not_to allow_value({ ids: [123] }).for(:external_identifiers) } + + it 'allows up to 3 fields' do + is_expected.not_to allow_value({ + note_id: 234, + noteable_id: 345, + noteable_type: 'MergeRequest', + object_type: 'pull_request', + extra_attribute: 'abc' + }).for(:external_identifiers) + end + end end end diff --git a/spec/models/instance_configuration_spec.rb b/spec/models/instance_configuration_spec.rb index f57667cc5d6..7710a05820c 100644 --- a/spec/models/instance_configuration_spec.rb +++ b/spec/models/instance_configuration_spec.rb @@ -73,7 +73,7 @@ RSpec.describe InstanceConfiguration do it 'returns Settings.pages' do gitlab_pages.delete(:ip_address) - expect(gitlab_pages).to eq(Settings.pages.symbolize_keys) + expect(gitlab_pages).to eq(Settings.pages.to_hash.deep_symbolize_keys) end it 'returns the GitLab\'s pages host ip address' do @@ -189,7 +189,6 @@ RSpec.describe InstanceConfiguration do plan: plan1, ci_pipeline_size: 1001, ci_active_jobs: 1002, - ci_active_pipelines: 1003, ci_project_subscriptions: 1004, ci_pipeline_schedules: 1005, ci_needs_size_limit: 1006, @@ -200,7 +199,6 @@ RSpec.describe InstanceConfiguration do plan: plan2, ci_pipeline_size: 1101, ci_active_jobs: 1102, - ci_active_pipelines: 1103, ci_project_subscriptions: 1104, ci_pipeline_schedules: 1105, ci_needs_size_limit: 1106, @@ -214,7 +212,6 @@ RSpec.describe InstanceConfiguration do expect(ci_cd_size_limits[:Plan1]).to eq({ ci_active_jobs: 1002, - ci_active_pipelines: 1003, ci_needs_size_limit: 1006, ci_pipeline_schedules: 1005, ci_pipeline_size: 1001, @@ -224,7 +221,6 @@ RSpec.describe InstanceConfiguration do }) expect(ci_cd_size_limits[:Plan2]).to eq({ ci_active_jobs: 1102, - ci_active_pipelines: 1103, ci_needs_size_limit: 1106, ci_pipeline_schedules: 1105, ci_pipeline_size: 1101, diff --git a/spec/models/integration_spec.rb b/spec/models/integration_spec.rb index a247881899f..46c30074ae7 100644 --- a/spec/models/integration_spec.rb +++ b/spec/models/integration_spec.rb @@ -1092,7 +1092,7 @@ RSpec.describe Integration, feature_category: :integrations do field :foo_dt, storage: :data_fields field :bar, type: 'password' - field :password + field :password, is_secret: true field :webhook diff --git a/spec/models/integrations/apple_app_store_spec.rb b/spec/models/integrations/apple_app_store_spec.rb index 1a57f556895..70b32a15148 100644 --- a/spec/models/integrations/apple_app_store_spec.rb +++ b/spec/models/integrations/apple_app_store_spec.rb @@ -12,6 +12,7 @@ RSpec.describe Integrations::AppleAppStore, feature_category: :mobile_devops do it { is_expected.to validate_presence_of :app_store_issuer_id } it { is_expected.to validate_presence_of :app_store_key_id } it { is_expected.to validate_presence_of :app_store_private_key } + it { is_expected.to validate_presence_of :app_store_private_key_file_name } it { is_expected.to allow_value('aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee').for(:app_store_issuer_id) } it { is_expected.not_to allow_value('abcde').for(:app_store_issuer_id) } it { is_expected.to allow_value(File.read('spec/fixtures/ssl_key.pem')).for(:app_store_private_key) } @@ -28,8 +29,8 @@ RSpec.describe Integrations::AppleAppStore, feature_category: :mobile_devops do describe '#fields' do it 'returns custom fields' do - expect(apple_app_store_integration.fields.pluck(:name)).to eq(%w[app_store_issuer_id app_store_key_id - app_store_private_key]) + expect(apple_app_store_integration.fields.pluck(:name)).to match_array(%w[app_store_issuer_id app_store_key_id + app_store_private_key app_store_private_key_file_name]) end end @@ -40,8 +41,8 @@ RSpec.describe Integrations::AppleAppStore, feature_category: :mobile_devops do end it 'returns false for an invalid request' do - allow(AppStoreConnect::Client).to receive_message_chain(:new, -:apps).and_return({ errors: [title: "error title"] }) + allow(AppStoreConnect::Client).to receive_message_chain(:new, :apps) + .and_return({ errors: [title: "error title"] }) expect(apple_app_store_integration.test[:success]).to be false end end @@ -80,6 +81,12 @@ RSpec.describe Integrations::AppleAppStore, feature_category: :mobile_devops do value: apple_app_store_integration.app_store_key_id, masked: true, public: false + }, + { + key: 'APP_STORE_CONNECT_API_KEY_IS_KEY_CONTENT_BASE64', + value: described_class::IS_KEY_CONTENT_BASE64, + masked: false, + public: false } ] diff --git a/spec/models/integrations/buildkite_spec.rb b/spec/models/integrations/buildkite_spec.rb index 5f62c68bd2b..29c649af6c6 100644 --- a/spec/models/integrations/buildkite_spec.rb +++ b/spec/models/integrations/buildkite_spec.rb @@ -146,9 +146,10 @@ RSpec.describe Integrations::Buildkite, :use_clean_rails_memory_store_caching do def stub_request(status: 200, body: nil) body ||= %q({"status":"success"}) - stub_full_request(buildkite_full_url) - .to_return(status: status, - headers: { 'Content-Type' => 'application/json' }, - body: body) + stub_full_request(buildkite_full_url).to_return( + status: status, + headers: { 'Content-Type' => 'application/json' }, + body: body + ) end end diff --git a/spec/models/integrations/campfire_spec.rb b/spec/models/integrations/campfire_spec.rb index ae923cd38fc..38d3d89cdbf 100644 --- a/spec/models/integrations/campfire_spec.rb +++ b/spec/models/integrations/campfire_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe Integrations::Campfire do +RSpec.describe Integrations::Campfire, feature_category: :integrations do include StubRequests it_behaves_like Integrations::ResetSecretFields do @@ -88,4 +88,16 @@ RSpec.describe Integrations::Campfire do expect(WebMock).not_to have_requested(:post, '*/room/.*/speak.json') end end + + describe '#log_error' do + subject { described_class.new.log_error('error') } + + it 'logs an error' do + expect(Gitlab::IntegrationsLogger).to receive(:error).with( + hash_including(integration_class: 'Integrations::Campfire', message: 'error') + ).and_call_original + + is_expected.to be_truthy + end + end end diff --git a/spec/models/integrations/chat_message/deployment_message_spec.rb b/spec/models/integrations/chat_message/deployment_message_spec.rb index 8da27ef5aa0..d16c191bd08 100644 --- a/spec/models/integrations/chat_message/deployment_message_spec.rb +++ b/spec/models/integrations/chat_message/deployment_message_spec.rb @@ -7,7 +7,7 @@ RSpec.describe Integrations::ChatMessage::DeploymentMessage do let_it_be(:user) { create(:user, name: 'John Smith', username: 'smith') } let_it_be(:namespace) { create(:namespace, name: 'myspace') } - let_it_be(:project) { create(:project, :repository, namespace: namespace, name: 'myproject') } + let_it_be(:project) { create(:project, :repository, namespace: namespace, path: 'myproject') } let_it_be(:commit) { project.commit('HEAD') } let_it_be(:ci_build) { create(:ci_build, project: project) } let_it_be(:environment) { create(:environment, name: 'myenvironment', project: project) } diff --git a/spec/models/integrations/every_integration_spec.rb b/spec/models/integrations/every_integration_spec.rb index 8666ef512fc..c39a3486eb4 100644 --- a/spec/models/integrations/every_integration_spec.rb +++ b/spec/models/integrations/every_integration_spec.rb @@ -11,9 +11,9 @@ RSpec.describe 'Every integration' do let(:integration) { integration_class.new } context 'secret fields', :aggregate_failures do - it "uses type: 'password' for all secret fields, except when bypassed" do + it "uses type: 'password' for all secret fields" do integration.fields.each do |field| - next unless Integrations::Field::SECRET_NAME.match?(field[:name]) && field[:is_secret] + next unless field[:is_secret] expect(field[:type]).to eq('password'), "Field '#{field[:name]}' should use type 'password'" diff --git a/spec/models/integrations/ewm_spec.rb b/spec/models/integrations/ewm_spec.rb index dc48a2c982f..4f4ff038b19 100644 --- a/spec/models/integrations/ewm_spec.rb +++ b/spec/models/integrations/ewm_spec.rb @@ -31,27 +31,27 @@ RSpec.describe Integrations::Ewm do describe "ReferencePatternValidation" do it "extracts bug" do - expect(described_class.reference_pattern.match("This is bug 123")[:issue]).to eq("bug 123") + expect(subject.reference_pattern.match("This is bug 123")[:issue]).to eq("bug 123") end it "extracts task" do - expect(described_class.reference_pattern.match("This is task 123.")[:issue]).to eq("task 123") + expect(subject.reference_pattern.match("This is task 123.")[:issue]).to eq("task 123") end it "extracts work item" do - expect(described_class.reference_pattern.match("This is work item 123 now")[:issue]).to eq("work item 123") + expect(subject.reference_pattern.match("This is work item 123 now")[:issue]).to eq("work item 123") end it "extracts workitem" do - expect(described_class.reference_pattern.match("workitem 123 at the beginning")[:issue]).to eq("workitem 123") + expect(subject.reference_pattern.match("workitem 123 at the beginning")[:issue]).to eq("workitem 123") end it "extracts defect" do - expect(described_class.reference_pattern.match("This is defect 123 defect")[:issue]).to eq("defect 123") + expect(subject.reference_pattern.match("This is defect 123 defect")[:issue]).to eq("defect 123") end it "extracts rtcwi" do - expect(described_class.reference_pattern.match("This is rtcwi 123")[:issue]).to eq("rtcwi 123") + expect(subject.reference_pattern.match("This is rtcwi 123")[:issue]).to eq("rtcwi 123") end end end diff --git a/spec/models/integrations/field_spec.rb b/spec/models/integrations/field_spec.rb index c30f9ef0d7b..ca71dd0e6d3 100644 --- a/spec/models/integrations/field_spec.rb +++ b/spec/models/integrations/field_spec.rb @@ -15,8 +15,8 @@ RSpec.describe ::Integrations::Field do end describe '#initialize' do - it 'sets type password for secret names' do - attrs[:name] = 'token' + it 'sets type password for secret fields' do + attrs[:is_secret] = true attrs[:type] = 'text' expect(field[:type]).to eq('password') @@ -84,7 +84,7 @@ RSpec.describe ::Integrations::Field do when :type eq 'text' when :is_secret - eq true + eq false else be_nil end @@ -175,16 +175,6 @@ RSpec.describe ::Integrations::Field do it { is_expected.to be_secret } end - %w[token api_token api_key secret_key secret_sauce password passphrase].each do |name| - context "when named #{name}" do - before do - attrs[:name] = name - end - - it { is_expected.to be_secret } - end - end - context "when named url" do before do attrs[:name] = :url diff --git a/spec/models/integrations/gitlab_slack_application_spec.rb b/spec/models/integrations/gitlab_slack_application_spec.rb new file mode 100644 index 00000000000..68476dde2a3 --- /dev/null +++ b/spec/models/integrations/gitlab_slack_application_spec.rb @@ -0,0 +1,337 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Integrations::GitlabSlackApplication, feature_category: :integrations do + include AfterNextHelpers + + it_behaves_like Integrations::BaseSlackNotification, factory: :gitlab_slack_application_integration do + before do + stub_request(:post, "#{::Slack::API::BASE_URL}/chat.postMessage").to_return(body: '{"ok":true}') + end + end + + describe 'validations' do + it { is_expected.not_to validate_presence_of(:webhook) } + end + + describe 'default values' do + it { expect(subject.category).to eq(:chat) } + + it { is_expected.not_to be_alert_events } + it { is_expected.not_to be_commit_events } + it { is_expected.not_to be_confidential_issues_events } + it { is_expected.not_to be_confidential_note_events } + it { is_expected.not_to be_deployment_events } + it { is_expected.not_to be_issues_events } + it { is_expected.not_to be_job_events } + it { is_expected.not_to be_merge_requests_events } + it { is_expected.not_to be_note_events } + it { is_expected.not_to be_pipeline_events } + it { is_expected.not_to be_push_events } + it { is_expected.not_to be_tag_push_events } + it { is_expected.not_to be_vulnerability_events } + it { is_expected.not_to be_wiki_page_events } + end + + describe '#execute' do + let_it_be(:user) { build_stubbed(:user) } + + let(:slack_integration) { build(:slack_integration) } + let(:data) { Gitlab::DataBuilder::Push.build_sample(integration.project, user) } + let(:slack_api_method_uri) { "#{::Slack::API::BASE_URL}/chat.postMessage" } + + let(:mock_message) do + instance_double(Integrations::ChatMessage::PushMessage, attachments: ['foo'], pretext: 'bar') + end + + subject(:integration) { build(:gitlab_slack_application_integration, slack_integration: slack_integration) } + + before do + allow(integration).to receive(:get_message).and_return(mock_message) + allow(integration).to receive(:log_usage) + end + + def stub_slack_request(channel: '#push_channel', success: true) + post_body = { + body: { + attachments: mock_message.attachments, + text: mock_message.pretext, + unfurl_links: false, + unfurl_media: false, + channel: channel + } + } + + response = { ok: success }.to_json + + stub_request(:post, slack_api_method_uri).with(post_body) + .to_return(body: response, headers: { 'Content-Type' => 'application/json; charset=utf-8' }) + end + + it 'notifies Slack' do + stub_slack_request + + expect(integration.execute(data)).to be true + end + + context 'when the integration is not configured for event' do + before do + integration.push_channel = nil + end + + it 'does not notify Slack' do + expect(integration.execute(data)).to be false + end + end + + context 'when Slack API responds with an error' do + it 'logs the error and API response' do + stub_slack_request(success: false) + + expect(Gitlab::IntegrationsLogger).to receive(:error).with( + { + integration_class: described_class.name, + integration_id: integration.id, + project_id: integration.project_id, + project_path: kind_of(String), + message: 'Slack API error when notifying', + api_response: { 'ok' => false } + } + ) + expect(integration.execute(data)).to be false + end + end + + context 'when there is an HTTP error' do + it 'logs the error' do + expect_next(Slack::API).to receive(:post).and_raise(Net::ReadTimeout) + expect(Gitlab::ErrorTracking).to receive(:track_and_raise_for_dev_exception).with( + kind_of(Net::ReadTimeout), + { + slack_integration_id: slack_integration.id, + integration_id: integration.id + } + ) + expect(integration.execute(data)).to be false + end + end + + context 'when configured to post to multiple Slack channels' do + before do + push_channels = '#first_channel, #second_channel' + integration.push_channel = push_channels + end + + it 'posts to both Slack channels and returns true' do + stub_slack_request(channel: '#first_channel') + stub_slack_request(channel: '#second_channel') + + expect(integration.execute(data)).to be true + end + + context 'when one of the posts responds with an error' do + it 'posts to both channels and returns true' do + stub_slack_request(channel: '#first_channel', success: false) + stub_slack_request(channel: '#second_channel') + + expect(Gitlab::IntegrationsLogger).to receive(:error).once + expect(integration.execute(data)).to be true + end + end + + context 'when both of the posts respond with an error' do + it 'posts to both channels and returns false' do + stub_slack_request(channel: '#first_channel', success: false) + stub_slack_request(channel: '#second_channel', success: false) + + expect(Gitlab::IntegrationsLogger).to receive(:error).twice + expect(integration.execute(data)).to be false + end + end + + context 'when one of the posts raises an HTTP exception' do + it 'posts to one channel and returns true' do + stub_slack_request(channel: '#second_channel') + + expect_next_instance_of(Slack::API) do |api_client| + expect(api_client).to receive(:post) + .with('chat.postMessage', hash_including(channel: '#first_channel')).and_raise(Net::ReadTimeout) + expect(api_client).to receive(:post) + .with('chat.postMessage', hash_including(channel: '#second_channel')).and_call_original + end + expect(Gitlab::ErrorTracking).to receive(:track_and_raise_for_dev_exception).once + expect(integration.execute(data)).to be true + end + end + + context 'when both of the posts raise an HTTP exception' do + it 'posts to one channel and returns true' do + stub_slack_request(channel: '#second_channel') + + expect_next(Slack::API).to receive(:post).twice.and_raise(Net::ReadTimeout) + expect(Gitlab::ErrorTracking).to receive(:track_and_raise_for_dev_exception).twice + expect(integration.execute(data)).to be false + end + end + end + end + + describe '#test' do + let(:integration) { build(:gitlab_slack_application_integration) } + + let(:slack_api_method_uri) { "#{::Slack::API::BASE_URL}/chat.postEphemeral" } + let(:response_failure) { { error: 'channel_not_found' } } + let(:response_success) { { error: 'user_not_in_channel' } } + let(:response_headers) { { 'Content-Type' => 'application/json; charset=utf-8' } } + let(:request_body) do + { + text: 'Test', + user: integration.bot_user_id + } + end + + subject(:result) { integration.test({}) } + + def stub_slack_request(channel:, success:) + response_body = success ? response_success : response_failure + + stub_request(:post, slack_api_method_uri) + .with(body: request_body.merge(channel: channel)) + .to_return(body: response_body.to_json, headers: response_headers) + end + + context 'when all channels can be posted to' do + before do + stub_slack_request(channel: anything, success: true) + end + + it 'is successful' do + is_expected.to eq({ success: true, result: nil }) + end + end + + context 'when the same channel is used for multiple events' do + let(:integration) do + build(:gitlab_slack_application_integration, all_channels: false, push_channel: '#foo', issue_channel: '#foo') + end + + it 'only tests the channel once' do + stub_slack_request(channel: '#foo', success: true) + + is_expected.to eq({ success: true, result: nil }) + expect(WebMock).to have_requested(:post, slack_api_method_uri).once + end + end + + context 'when there are channels that cannot be posted to' do + let(:unpostable_channels) { ['#push_channel', '#issue_channel'] } + + before do + stub_slack_request(channel: anything, success: true) + + unpostable_channels.each do |channel| + stub_slack_request(channel: channel, success: false) + end + end + + it 'returns an error message informing which channels cannot be posted to' do + expected_message = "Unable to post to #{unpostable_channels.to_sentence}, " \ + 'please add the GitLab Slack app to any private Slack channels' + + is_expected.to eq({ success: false, result: expected_message }) + end + + context 'when integration is not configured for notifications' do + let_it_be(:integration) { build(:gitlab_slack_application_integration, all_channels: false) } + + it 'is successful' do + is_expected.to eq({ success: true, result: nil }) + end + end + end + + context 'when integration is using legacy version of Slack app' do + before do + integration.slack_integration = build(:slack_integration, :legacy) + end + + it 'returns an error to inform the user to update their integration' do + expected_message = 'GitLab for Slack app must be reinstalled to enable notifications' + + is_expected.to eq({ success: false, result: expected_message }) + end + end + end + + context 'when the integration is active' do + before do + subject.active = true + end + + it 'is editable, and presents editable fields' do + expect(subject).to be_editable + expect(subject.fields).not_to be_empty + expect(subject.configurable_events).not_to be_empty + end + + it 'includes the expected sections' do + section_types = subject.sections.pluck(:type) + + expect(section_types).to eq( + [ + described_class::SECTION_TYPE_TRIGGER, + described_class::SECTION_TYPE_CONFIGURATION + ] + ) + end + end + + context 'when the integration is not active' do + before do + subject.active = false + end + + it 'is not editable, and presents no editable fields' do + expect(subject).not_to be_editable + expect(subject.fields).to be_empty + expect(subject.configurable_events).to be_empty + end + + it 'does not include sections' do + section_types = subject.sections.pluck(:type) + + expect(section_types).to be_empty + end + end + + describe '#description' do + specify { expect(subject.description).to be_present } + end + + describe '#upgrade_needed?' do + context 'with all_features_supported' do + subject(:integration) { create(:gitlab_slack_application_integration, :all_features_supported) } + + it 'is false' do + expect(integration).not_to be_upgrade_needed + end + end + + context 'without all_features_supported' do + subject(:integration) { create(:gitlab_slack_application_integration) } + + it 'is true' do + expect(integration).to be_upgrade_needed + end + end + + context 'without slack_integration' do + subject(:integration) { create(:gitlab_slack_application_integration, slack_integration: nil) } + + it 'is false' do + expect(integration).not_to be_upgrade_needed + end + end + end +end diff --git a/spec/models/integrations/google_play_spec.rb b/spec/models/integrations/google_play_spec.rb new file mode 100644 index 00000000000..8349ac71bc9 --- /dev/null +++ b/spec/models/integrations/google_play_spec.rb @@ -0,0 +1,101 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Integrations::GooglePlay, feature_category: :mobile_devops do + describe 'Validations' do + context 'when active' do + before do + subject.active = true + end + + it { is_expected.to validate_presence_of :service_account_key_file_name } + it { is_expected.to validate_presence_of :service_account_key } + it { is_expected.to validate_presence_of :package_name } + it { is_expected.to allow_value(File.read('spec/fixtures/service_account.json')).for(:service_account_key) } + it { is_expected.not_to allow_value(File.read('spec/fixtures/group.json')).for(:service_account_key) } + it { is_expected.to allow_value('com.example.myapp').for(:package_name) } + it { is_expected.to allow_value('com.example.myorg.myapp').for(:package_name) } + it { is_expected.to allow_value('com_us.example.my_org.my_app').for(:package_name) } + it { is_expected.to allow_value('a.a.a').for(:package_name) } + it { is_expected.to allow_value('com.example').for(:package_name) } + it { is_expected.not_to allow_value('com').for(:package_name) } + it { is_expected.not_to allow_value('com.example.my app').for(:package_name) } + it { is_expected.not_to allow_value('1com.example.myapp').for(:package_name) } + it { is_expected.not_to allow_value('com.1example.myapp').for(:package_name) } + it { is_expected.not_to allow_value('com.example._myapp').for(:package_name) } + end + end + + context 'when integration is enabled' do + let(:google_play_integration) { build(:google_play_integration) } + + describe '#fields' do + it 'returns custom fields' do + expect(google_play_integration.fields.pluck(:name)).to match_array(%w[package_name service_account_key + service_account_key_file_name]) + end + end + + describe '#test' do + it 'returns true for a successful request' do + allow_next_instance_of(Google::Apis::AndroidpublisherV3::AndroidPublisherService) do |instance| + allow(instance).to receive(:list_reviews) + end + expect(google_play_integration.test[:success]).to be true + end + + it 'returns false for an invalid request' do + allow_next_instance_of(Google::Apis::AndroidpublisherV3::AndroidPublisherService) do |instance| + allow(instance).to receive(:list_reviews).and_raise(Google::Apis::ClientError.new('error')) + end + expect(google_play_integration.test[:success]).to be false + end + end + + describe '#help' do + it 'renders prompt information' do + expect(google_play_integration.help).not_to be_empty + end + end + + describe '.to_param' do + it 'returns the name of the integration' do + expect(described_class.to_param).to eq('google_play') + end + end + + describe '#ci_variables' do + let(:google_play_integration) { build_stubbed(:google_play_integration) } + + it 'returns vars when the integration is activated' do + ci_vars = [ + { + key: 'SUPPLY_PACKAGE_NAME', + value: google_play_integration.package_name, + masked: false, + public: false + }, + { + key: 'SUPPLY_JSON_KEY_DATA', + value: google_play_integration.service_account_key, + masked: true, + public: false + } + ] + + expect(google_play_integration.ci_variables).to match_array(ci_vars) + end + end + end + + context 'when integration is disabled' do + let(:google_play_integration) { build_stubbed(:google_play_integration, active: false) } + + describe '#ci_variables' do + it 'returns an empty array' do + expect(google_play_integration.ci_variables).to match_array([]) + end + end + end +end diff --git a/spec/models/integrations/hangouts_chat_spec.rb b/spec/models/integrations/hangouts_chat_spec.rb index 288478b494e..1ebf2ec3005 100644 --- a/spec/models/integrations/hangouts_chat_spec.rb +++ b/spec/models/integrations/hangouts_chat_spec.rb @@ -116,10 +116,13 @@ RSpec.describe Integrations::HangoutsChat do context 'when commit comment event executed' do let(:commit_note) do - create(:note_on_commit, author: user, - project: project, - commit_id: project.repository.commit.id, - note: 'a comment on a commit') + create( + :note_on_commit, + author: user, + project: project, + commit_id: project.repository.commit.id, + note: 'a comment on a commit' + ) end it "adds thread key" do @@ -135,8 +138,7 @@ RSpec.describe Integrations::HangoutsChat do context 'when merge request comment event executed' do let(:merge_request_note) do - create(:note_on_merge_request, project: project, - note: "merge request note") + create(:note_on_merge_request, project: project, note: "merge request note") end it "adds thread key" do @@ -168,8 +170,7 @@ RSpec.describe Integrations::HangoutsChat do context 'when snippet comment event executed' do let(:snippet_note) do - create(:note_on_project_snippet, project: project, - note: "snippet note") + create(:note_on_project_snippet, project: project, note: "snippet note") end it "adds thread key" do diff --git a/spec/models/integrations/harbor_spec.rb b/spec/models/integrations/harbor_spec.rb index b4580028112..c4da876a0dd 100644 --- a/spec/models/integrations/harbor_spec.rb +++ b/spec/models/integrations/harbor_spec.rb @@ -48,7 +48,7 @@ RSpec.describe Integrations::Harbor do before do allow_next_instance_of(Gitlab::Harbor::Client) do |client| - allow(client).to receive(:ping).and_return(test_response) + allow(client).to receive(:check_project_availability).and_return(test_response) end end diff --git a/spec/models/integrations/jira_spec.rb b/spec/models/integrations/jira_spec.rb index a4ccae459cf..d3cb386e8e0 100644 --- a/spec/models/integrations/jira_spec.rb +++ b/spec/models/integrations/jira_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe Integrations::Jira do +RSpec.describe Integrations::Jira, feature_category: :integrations do include AssetsHelpers let_it_be(:project) { create(:project, :repository) } @@ -11,6 +11,9 @@ RSpec.describe Integrations::Jira do let(:url) { 'http://jira.example.com' } let(:api_url) { 'http://api-jira.example.com' } let(:username) { 'jira-username' } + let(:jira_auth_type) { 0 } + let(:jira_issue_prefix) { '' } + let(:jira_issue_regex) { '' } let(:password) { 'jira-password' } let(:project_key) { nil } let(:transition_id) { 'test27' } @@ -48,9 +51,39 @@ RSpec.describe Integrations::Jira do it { is_expected.to validate_presence_of(:url) } it { is_expected.to validate_presence_of(:username) } it { is_expected.to validate_presence_of(:password) } + it { is_expected.to validate_presence_of(:jira_auth_type) } + it { is_expected.to validate_length_of(:jira_issue_regex).is_at_most(255) } + it { is_expected.to validate_length_of(:jira_issue_prefix).is_at_most(255) } + it { is_expected.to validate_inclusion_of(:jira_auth_type).in_array([0, 1]) } it_behaves_like 'issue tracker integration URL attribute', :url it_behaves_like 'issue tracker integration URL attribute', :api_url + + context 'with personal_access_token_authorization' do + before do + jira_integration.jira_auth_type = 1 + end + + it { is_expected.not_to validate_presence_of(:username) } + end + + context 'when URL is for Jira Cloud' do + before do + jira_integration.url = 'https://test.atlassian.net' + end + + it 'is valid when jira_auth_type is basic' do + jira_integration.jira_auth_type = 0 + + expect(jira_integration).to be_valid + end + + it 'is invalid when jira_auth_type is PAT' do + jira_integration.jira_auth_type = 1 + + expect(jira_integration).not_to be_valid + end + end end context 'when integration is inactive' do @@ -62,6 +95,10 @@ RSpec.describe Integrations::Jira do it { is_expected.not_to validate_presence_of(:url) } it { is_expected.not_to validate_presence_of(:username) } it { is_expected.not_to validate_presence_of(:password) } + it { is_expected.not_to validate_presence_of(:jira_auth_type) } + it { is_expected.not_to validate_length_of(:jira_issue_regex).is_at_most(255) } + it { is_expected.not_to validate_length_of(:jira_issue_prefix).is_at_most(255) } + it { is_expected.not_to validate_inclusion_of(:jira_auth_type).in_array([0, 1]) } end describe 'jira_issue_transition_id' do @@ -167,7 +204,7 @@ RSpec.describe Integrations::Jira do subject(:fields) { integration.fields } it 'returns custom fields' do - expect(fields.pluck(:name)).to eq(%w[url api_url username password jira_issue_transition_id]) + expect(fields.pluck(:name)).to eq(%w[url api_url jira_auth_type username password jira_issue_regex jira_issue_prefix jira_issue_transition_id]) end end @@ -202,7 +239,7 @@ RSpec.describe Integrations::Jira do end end - describe '.reference_pattern' do + describe '#reference_pattern' do using RSpec::Parameterized::TableSyntax where(:key, :result) do @@ -216,11 +253,77 @@ RSpec.describe Integrations::Jira do '3EXT_EXT-1234' | '' 'CVE-2022-123' | '' 'CVE-123' | 'CVE-123' + 'abc-JIRA-1234' | 'JIRA-1234' end with_them do specify do - expect(described_class.reference_pattern.match(key).to_s).to eq(result) + expect(jira_integration.reference_pattern.match(key).to_s).to eq(result) + end + end + + context 'with match prefix' do + before do + jira_integration.jira_issue_prefix = 'jira#' + end + + where(:key, :result, :issue_key) do + 'jira##123' | '' | '' + 'jira#1#23#12' | '' | '' + 'jira#JIRA-1234A' | 'jira#JIRA-1234' | 'JIRA-1234' + 'jira#JIRA-1234-some_tag' | 'jira#JIRA-1234' | 'JIRA-1234' + 'JIRA-1234A' | '' | '' + 'JIRA-1234-some_tag' | '' | '' + 'myjira#JIRA-1234-some_tag' | '' | '' + 'MYjira#JIRA-1234-some_tag' | '' | '' + 'my-jira#JIRA-1234-some_tag' | 'jira#JIRA-1234' | 'JIRA-1234' + end + + with_them do + specify do + expect(jira_integration.reference_pattern.match(key).to_s).to eq(result) + + expect(jira_integration.reference_pattern.match(key)[:issue]).to eq(issue_key) unless result.empty? + end + end + end + + context 'with trailing space in jira_issue_prefix' do + before do + jira_integration.jira_issue_prefix = 'Jira# ' + end + + it 'leaves the trailing space' do + expect(jira_integration.jira_issue_prefix).to eq('Jira# ') + end + + it 'pulls the issue ID without a prefix' do + expect(jira_integration.reference_pattern.match('Jira# FOO-123')[:issue]).to eq('FOO-123') + end + end + + context 'with custom issue pattern' do + before do + jira_integration.jira_issue_regex = '[A-Z][0-9]-[0-9]+' + end + + where(:key, :result) do + 'J1-123' | 'J1-123' + 'AAbJ J1-123' | 'J1-123' + '#A1-123' | 'A1-123' + 'J1-1234-some_tag' | 'J1-1234' + 'J1-1234A' | 'J1-1234' + 'J1-1234-some_tag' | 'J1-1234' + 'JI1-123' | '' + 'J1I-123' | '' + 'JI-123' | '' + '#123' | '' + end + + with_them do + specify do + expect(jira_integration.reference_pattern.match(key).to_s).to eq(result) + end end end end @@ -251,7 +354,10 @@ RSpec.describe Integrations::Jira do project: project, url: url, api_url: api_url, + jira_auth_type: jira_auth_type, username: username, password: password, + jira_issue_regex: jira_issue_regex, + jira_issue_prefix: jira_issue_prefix, jira_issue_transition_id: transition_id } end @@ -265,8 +371,11 @@ RSpec.describe Integrations::Jira do it 'stores data in data_fields correctly' do expect(integration.jira_tracker_data.url).to eq(url) expect(integration.jira_tracker_data.api_url).to eq(api_url) + expect(integration.jira_tracker_data.jira_auth_type).to eq(jira_auth_type) expect(integration.jira_tracker_data.username).to eq(username) expect(integration.jira_tracker_data.password).to eq(password) + expect(integration.jira_tracker_data.jira_issue_regex).to eq(jira_issue_regex) + expect(integration.jira_tracker_data.jira_issue_prefix).to eq(jira_issue_prefix) expect(integration.jira_tracker_data.jira_issue_transition_id).to eq(transition_id) expect(integration.jira_tracker_data.deployment_cloud?).to be_truthy end @@ -469,15 +578,54 @@ RSpec.describe Integrations::Jira do end describe '#client' do + before do + stub_request(:get, 'http://jira.example.com/foo') + end + it 'uses the default GitLab::HTTP timeouts' do timeouts = Gitlab::HTTP::DEFAULT_TIMEOUT_OPTIONS - stub_request(:get, 'http://jira.example.com/foo') expect(Gitlab::HTTP).to receive(:httparty_perform_request) .with(Net::HTTP::Get, '/foo', hash_including(timeouts)).and_call_original jira_integration.client.get('/foo') end + + context 'with basic auth' do + before do + jira_integration.jira_auth_type = 0 + end + + it 'uses correct authorization options' do + expect_next_instance_of(JIRA::Client) do |instance| + expect(instance.request_client.options).to include( + additional_cookies: ['OBBasicAuth=fromDialog'], + auth_type: :basic, + use_cookies: true, + password: jira_integration.password, + username: jira_integration.username + ) + end + + jira_integration.client.get('/foo') + end + end + + context 'with personal access token auth' do + before do + jira_integration.jira_auth_type = 1 + end + + it 'uses correct authorization options' do + expect_next_instance_of(JIRA::Client) do |instance| + expect(instance.request_client.options).to include( + default_headers: { "Authorization" => "Bearer #{password}" } + ) + end + + jira_integration.client.get('/foo') + end + end end describe '#find_issue' do @@ -590,7 +738,6 @@ RSpec.describe Integrations::Jira do it_behaves_like 'Snowplow event tracking with RedisHLL context' do subject { close_issue } - let(:feature_flag_name) { :route_hll_to_snowplow_phase2 } let(:category) { 'Integrations::Jira' } let(:action) { 'perform_integrations_action' } let(:namespace) { project.namespace } @@ -944,7 +1091,6 @@ RSpec.describe Integrations::Jira do end it_behaves_like 'Snowplow event tracking with RedisHLL context' do - let(:feature_flag_name) { :route_hll_to_snowplow_phase2 } let(:category) { 'Integrations::Jira' } let(:action) { 'perform_integrations_action' } let(:namespace) { project.namespace } @@ -1095,9 +1241,7 @@ RSpec.describe Integrations::Jira do expect(integration.web_url).to eq('') end - it 'includes Atlassian referrer for gitlab.com' do - allow(Gitlab).to receive(:com?).and_return(true) - + it 'includes Atlassian referrer for SaaS', :saas do expect(integration.web_url).to eq("http://jira.test.com/path?#{described_class::ATLASSIAN_REFERRER_GITLAB_COM.to_query}") allow(Gitlab).to receive(:staging?).and_return(true) diff --git a/spec/models/integrations/mattermost_slash_commands_spec.rb b/spec/models/integrations/mattermost_slash_commands_spec.rb index 070adb9ba93..e393a905f45 100644 --- a/spec/models/integrations/mattermost_slash_commands_spec.rb +++ b/spec/models/integrations/mattermost_slash_commands_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe Integrations::MattermostSlashCommands do +RSpec.describe Integrations::MattermostSlashCommands, feature_category: :integrations do it_behaves_like Integrations::BaseSlashCommands describe 'Mattermost API' do @@ -123,12 +123,5 @@ RSpec.describe Integrations::MattermostSlashCommands do end end end - - describe '#chat_responder' do - it 'returns the responder to use for Mattermost' do - expect(described_class.new.chat_responder) - .to eq(Gitlab::Chat::Responder::Mattermost) - end - end end end diff --git a/spec/models/integrations/prometheus_spec.rb b/spec/models/integrations/prometheus_spec.rb index aa248abd3bb..8aa9b12c4f0 100644 --- a/spec/models/integrations/prometheus_spec.rb +++ b/spec/models/integrations/prometheus_spec.rb @@ -90,37 +90,6 @@ RSpec.describe Integrations::Prometheus, :use_clean_rails_memory_store_caching, end end end - - context 'with self-monitoring project and internal Prometheus' do - before do - integration.api_url = 'http://localhost:9090' - - stub_application_setting(self_monitoring_project_id: project.id) - stub_config(prometheus: { enable: true, server_address: 'localhost:9090' }) - end - - it 'allows self-monitoring project to connect to internal Prometheus' do - aggregate_failures do - ['127.0.0.1', '192.168.2.3'].each do |url| - allow(Addrinfo).to receive(:getaddrinfo).with(domain, any_args).and_return([Addrinfo.tcp(url, 80)]) - - expect(integration.can_query?).to be true - end - end - end - - it 'does not allow self-monitoring project to connect to other local URLs' do - integration.api_url = 'http://localhost:8000' - - aggregate_failures do - ['127.0.0.1', '192.168.2.3'].each do |url| - allow(Addrinfo).to receive(:getaddrinfo).with(domain, any_args).and_return([Addrinfo.tcp(url, 80)]) - - expect(integration.can_query?).to be false - end - end - end - end end end @@ -218,23 +187,6 @@ RSpec.describe Integrations::Prometheus, :use_clean_rails_memory_store_caching, it 'blocks local requests' do expect(integration.prometheus_client).to be_nil end - - context 'with self-monitoring project and internal Prometheus URL' do - before do - stub_application_setting(allow_local_requests_from_web_hooks_and_services: false) - stub_application_setting(self_monitoring_project_id: project.id) - - stub_config(prometheus: { - enable: true, - server_address: api_url - }) - end - - it 'allows local requests' do - expect(integration.prometheus_client).not_to be_nil - expect { integration.prometheus_client.ping }.not_to raise_error - end - end end context 'behind IAP' do @@ -332,7 +284,7 @@ RSpec.describe Integrations::Prometheus, :use_clean_rails_memory_store_caching, context 'cluster belongs to projects group' do let_it_be(:group) { create(:group) } - let(:project) { create(:project, :with_prometheus_integration, group: group) } + let_it_be(:project) { create(:project, :with_prometheus_integration, group: group) } let_it_be(:cluster) { create(:cluster_for_group, groups: [group]) } it 'returns true' do diff --git a/spec/models/integrations/redmine_spec.rb b/spec/models/integrations/redmine_spec.rb index 59997d2b6f6..8785fc8a1ed 100644 --- a/spec/models/integrations/redmine_spec.rb +++ b/spec/models/integrations/redmine_spec.rb @@ -38,11 +38,11 @@ RSpec.describe Integrations::Redmine do end end - describe '.reference_pattern' do + describe '#reference_pattern' do it_behaves_like 'allows project key on reference pattern' it 'does allow # on the reference' do - expect(described_class.reference_pattern.match('#123')[:issue]).to eq('123') + expect(subject.reference_pattern.match('#123')[:issue]).to eq('123') end end end diff --git a/spec/models/integrations/slack_slash_commands_spec.rb b/spec/models/integrations/slack_slash_commands_spec.rb index 22cbaa777cd..f373fc2a2de 100644 --- a/spec/models/integrations/slack_slash_commands_spec.rb +++ b/spec/models/integrations/slack_slash_commands_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe Integrations::SlackSlashCommands do +RSpec.describe Integrations::SlackSlashCommands, feature_category: :integrations do it_behaves_like Integrations::BaseSlashCommands describe '#trigger' do @@ -40,11 +40,4 @@ RSpec.describe Integrations::SlackSlashCommands do end end end - - describe '#chat_responder' do - it 'returns the responder to use for Slack' do - expect(described_class.new.chat_responder) - .to eq(Gitlab::Chat::Responder::Slack) - end - end end diff --git a/spec/models/integrations/slack_workspace/api_scope_spec.rb b/spec/models/integrations/slack_workspace/api_scope_spec.rb new file mode 100644 index 00000000000..92052983242 --- /dev/null +++ b/spec/models/integrations/slack_workspace/api_scope_spec.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Integrations::SlackWorkspace::ApiScope, feature_category: :integrations do + describe '.find_or_initialize_by_names' do + it 'acts as insert into a global set of scope names' do + expect { described_class.find_or_initialize_by_names(%w[foo bar baz]) } + .to change { described_class.count }.by(3) + + expect { described_class.find_or_initialize_by_names(%w[bar baz foo buzz]) } + .to change { described_class.count }.by(1) + + expect { described_class.find_or_initialize_by_names(%w[baz foo]) } + .to change { described_class.count }.by(0) + + expect(described_class.pluck(:name)).to match_array(%w[foo bar baz buzz]) + end + end +end diff --git a/spec/models/integrations/squash_tm_spec.rb b/spec/models/integrations/squash_tm_spec.rb new file mode 100644 index 00000000000..f12e37dae6d --- /dev/null +++ b/spec/models/integrations/squash_tm_spec.rb @@ -0,0 +1,117 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Integrations::SquashTm, feature_category: :integrations do + it_behaves_like Integrations::HasWebHook do + let_it_be(:project) { create(:project) } + + let(:integration) { build(:squash_tm_integration, project: project) } + let(:hook_url) { "#{integration.url}?token={token}" } + end + + it_behaves_like Integrations::ResetSecretFields do + let(:integration) { subject } + end + + describe 'Validations' do + context 'when integration is active' do + before do + subject.active = true + end + + it { is_expected.to validate_presence_of(:url) } + it { is_expected.to allow_value('https://example.com').for(:url) } + it { is_expected.not_to allow_value(nil).for(:url) } + it { is_expected.not_to allow_value('').for(:url) } + it { is_expected.not_to allow_value('foo').for(:url) } + it { is_expected.not_to allow_value('example.com').for(:url) } + + it { is_expected.not_to validate_presence_of(:token) } + it { is_expected.to validate_length_of(:token).is_at_most(255) } + it { is_expected.to allow_value(nil).for(:token) } + it { is_expected.to allow_value('foo').for(:token) } + end + + context 'when integration is inactive' do + before do + subject.active = false + end + + it { is_expected.not_to validate_presence_of(:url) } + it { is_expected.not_to validate_presence_of(:token) } + end + end + + describe '#execute' do + let(:integration) { build(:squash_tm_integration, project: build(:project)) } + + let(:squash_tm_hook_url) do + "#{integration.url}?token=#{integration.token}" + end + + let_it_be(:user) { create(:user) } + let_it_be(:issue) { create(:issue) } + let(:data) { issue.to_hook_data(user) } + + before do + stub_request(:post, squash_tm_hook_url) + end + + it 'calls Squash TM API' do + integration.execute(data) + + expect(a_request(:post, squash_tm_hook_url)).to have_been_made.once + end + end + + describe '#test' do + let(:integration) { build(:squash_tm_integration) } + + let(:squash_tm_hook_url) do + "#{integration.url}?token=#{integration.token}" + end + + subject(:result) { integration.test({}) } + + context 'when server is responding' do + let(:body) { 'OK' } + let(:status) { 200 } + + before do + stub_request(:post, squash_tm_hook_url) + .to_return(status: status, body: body) + end + + it { is_expected.to eq(success: true, result: 'OK') } + end + + context 'when server rejects the request' do + let(:body) { 'Unauthorized' } + let(:status) { 401 } + + before do + stub_request(:post, squash_tm_hook_url) + .to_return(status: status, body: body) + end + + it { is_expected.to eq(success: false, result: body) } + end + + context 'when test request executes with errors' do + before do + allow(integration).to receive(:execute_web_hook!) + .with({}, "Test Configuration Hook") + .and_raise(StandardError, 'error message') + end + + it { is_expected.to eq(success: false, result: 'error message') } + end + end + + describe '.default_test_event' do + subject { described_class.default_test_event } + + it { is_expected.to eq('issue') } + end +end diff --git a/spec/models/integrations/youtrack_spec.rb b/spec/models/integrations/youtrack_spec.rb index 618ebcbb76a..69dda244413 100644 --- a/spec/models/integrations/youtrack_spec.rb +++ b/spec/models/integrations/youtrack_spec.rb @@ -26,15 +26,15 @@ RSpec.describe Integrations::Youtrack do end end - describe '.reference_pattern' do + describe '#reference_pattern' do it_behaves_like 'allows project key on reference pattern' it 'does allow project prefix on the reference' do - expect(described_class.reference_pattern.match('YT-123')[:issue]).to eq('YT-123') + expect(subject.reference_pattern.match('YT-123')[:issue]).to eq('YT-123') end it 'allows lowercase project key on the reference' do - expect(described_class.reference_pattern.match('yt-123')[:issue]).to eq('yt-123') + expect(subject.reference_pattern.match('yt-123')[:issue]).to eq('yt-123') end end diff --git a/spec/models/internal_id_spec.rb b/spec/models/internal_id_spec.rb index f0007e1203c..59ade8783e5 100644 --- a/spec/models/internal_id_spec.rb +++ b/spec/models/internal_id_spec.rb @@ -7,7 +7,7 @@ RSpec.describe InternalId do let(:usage) { :issues } let(:issue) { build(:issue, project: project) } let(:id_subject) { issue } - let(:scope) { { project: project } } + let(:scope) { { namespace: project.project_namespace } } let(:init) { ->(issue, scope) { issue&.project&.issues&.size || Issue.where(**scope).count } } it_behaves_like 'having unique enum values' @@ -17,7 +17,7 @@ RSpec.describe InternalId do end describe '.flush_records!' do - subject { described_class.flush_records!(project: project) } + subject { described_class.flush_records!(namespace: project.project_namespace) } let(:another_project) { create(:project) } @@ -27,11 +27,11 @@ RSpec.describe InternalId do end it 'deletes all records for the given project' do - expect { subject }.to change { described_class.where(project: project).count }.from(1).to(0) + expect { subject }.to change { described_class.where(namespace: project.project_namespace).count }.from(1).to(0) end it 'retains records for other projects' do - expect { subject }.not_to change { described_class.where(project: another_project).count } + expect { subject }.not_to change { described_class.where(namespace: another_project.project_namespace).count } end it 'does not allow an empty filter' do @@ -51,7 +51,7 @@ RSpec.describe InternalId do subject described_class.first.tap do |record| - expect(record.project).to eq(project) + expect(record.namespace).to eq(project.project_namespace) expect(record.usage).to eq(usage.to_s) end end @@ -182,7 +182,7 @@ RSpec.describe InternalId do subject described_class.first.tap do |record| - expect(record.project).to eq(project) + expect(record.namespace).to eq(project.project_namespace) expect(record.usage).to eq(usage.to_s) expect(record.last_value).to eq(value) end diff --git a/spec/models/issue_spec.rb b/spec/models/issue_spec.rb index e29318a7e83..6ae33fe2642 100644 --- a/spec/models/issue_spec.rb +++ b/spec/models/issue_spec.rb @@ -12,7 +12,6 @@ RSpec.describe Issue, feature_category: :team_planning do describe "Associations" do it { is_expected.to belong_to(:milestone) } - it { is_expected.to belong_to(:iteration) } it { is_expected.to belong_to(:project) } it { is_expected.to belong_to(:work_item_type).class_name('WorkItems::Type') } it { is_expected.to belong_to(:moved_to).class_name('Issue') } @@ -38,6 +37,7 @@ RSpec.describe Issue, feature_category: :team_planning do it { is_expected.to have_many(:issue_customer_relations_contacts) } it { is_expected.to have_many(:customer_relations_contacts) } it { is_expected.to have_many(:incident_management_timeline_events) } + it { is_expected.to have_many(:assignment_events).class_name('ResourceEvents::IssueAssignmentEvent').inverse_of(:issue) } describe 'versions.most_recent' do it 'returns the most recent version' do @@ -63,13 +63,18 @@ RSpec.describe Issue, feature_category: :team_planning do it_behaves_like 'AtomicInternalId' do let(:internal_id_attribute) { :iid } let(:instance) { build(:issue) } - let(:scope) { :project } - let(:scope_attrs) { { project: instance.project } } + let(:scope) { :namespace } + let(:scope_attrs) { { namespace: instance.project.project_namespace } } let(:usage) { :issues } end end describe 'validations' do + it { is_expected.not_to allow_value(nil).for(:confidential) } + it { is_expected.to allow_value(true, false).for(:confidential) } + end + + describe 'custom validations' do subject(:valid?) { issue.valid? } describe 'due_date_after_start_date' do @@ -156,7 +161,7 @@ RSpec.describe Issue, feature_category: :team_planning do it 'is possible to change type only between selected types' do issue = create(:issue, old_type, project: reusable_project) - issue.work_item_type_id = WorkItems::Type.default_by_type(new_type).id + issue.assign_attributes(work_item_type: WorkItems::Type.default_by_type(new_type), issue_type: new_type) expect(issue.valid?).to eq(is_valid) end @@ -215,7 +220,7 @@ RSpec.describe Issue, feature_category: :team_planning do subject { create(:issue, project: reusable_project) } describe 'callbacks' do - describe '#ensure_metrics' do + describe '#ensure_metrics!' do it 'creates metrics after saving' do expect(subject.metrics).to be_persisted expect(Issue::Metrics.count).to eq(1) @@ -250,7 +255,7 @@ RSpec.describe Issue, feature_category: :team_planning do describe '#ensure_work_item_type' do let_it_be(:issue_type) { create(:work_item_type, :issue, :default) } - let_it_be(:task_type) { create(:work_item_type, :issue, :default) } + let_it_be(:incident_type) { create(:work_item_type, :incident, :default) } let_it_be(:project) { create(:project) } context 'when a type was already set' do @@ -267,9 +272,9 @@ RSpec.describe Issue, feature_category: :team_planning do expect(issue.work_item_type_id).to eq(issue_type.id) expect(WorkItems::Type).not_to receive(:default_by_type) - issue.update!(work_item_type: task_type, issue_type: 'task') + issue.update!(work_item_type: incident_type, issue_type: :incident) - expect(issue.work_item_type_id).to eq(task_type.id) + expect(issue.work_item_type_id).to eq(incident_type.id) end it 'ensures a work item type if updated to nil' do @@ -296,13 +301,36 @@ RSpec.describe Issue, feature_category: :team_planning do expect(issue.work_item_type_id).to be_nil expect(WorkItems::Type).not_to receive(:default_by_type) - issue.update!(work_item_type: task_type, issue_type: 'task') + issue.update!(work_item_type: incident_type, issue_type: :incident) - expect(issue.work_item_type_id).to eq(task_type.id) + expect(issue.work_item_type_id).to eq(incident_type.id) end end end + describe '#check_issue_type_in_sync' do + it 'raises an error if issue_type is out of sync' do + issue = build(:issue, issue_type: :issue, work_item_type: WorkItems::Type.default_by_type(:task)) + + expect do + issue.save! + end.to raise_error(Issue::IssueTypeOutOfSyncError) + end + + it 'uses attributes to compare both issue_type values' do + issue_type = WorkItems::Type.default_by_type(:issue) + issue = build(:issue, issue_type: :issue, work_item_type: issue_type) + + attributes = double(:attributes) + allow(issue).to receive(:attributes).and_return(attributes) + + expect(attributes).to receive(:[]).with('issue_type').twice.and_return('issue') + expect(issue_type).to receive(:base_type).and_call_original + + issue.save! + end + end + describe '#record_create_action' do it 'records the creation action after saving' do expect(Gitlab::UsageDataCounters::IssueActivityUniqueCounter).to receive(:track_issue_created_action) @@ -575,47 +603,47 @@ RSpec.describe Issue, feature_category: :team_planning do end describe '#to_reference' do - let(:namespace) { build(:namespace, path: 'sample-namespace') } - let(:project) { build(:project, name: 'sample-project', namespace: namespace) } - let(:issue) { build(:issue, iid: 1, project: project) } + let_it_be(:namespace) { create(:namespace) } + let_it_be(:project) { create(:project, namespace: namespace) } + let_it_be(:issue) { create(:issue, project: project) } context 'when nil argument' do it 'returns issue id' do - expect(issue.to_reference).to eq "#1" + expect(issue.to_reference).to eq "##{issue.iid}" end it 'returns complete path to the issue with full: true' do - expect(issue.to_reference(full: true)).to eq 'sample-namespace/sample-project#1' + expect(issue.to_reference(full: true)).to eq "#{project.full_path}##{issue.iid}" end end context 'when argument is a project' do context 'when same project' do it 'returns issue id' do - expect(issue.to_reference(project)).to eq("#1") + expect(issue.to_reference(project)).to eq("##{issue.iid}") end it 'returns full reference with full: true' do - expect(issue.to_reference(project, full: true)).to eq 'sample-namespace/sample-project#1' + expect(issue.to_reference(project, full: true)).to eq "#{project.full_path}##{issue.iid}" end end context 'when cross-project in same namespace' do let(:another_project) do - build(:project, name: 'another-project', namespace: project.namespace) + create(:project, namespace: project.namespace) end it 'returns a cross-project reference' do - expect(issue.to_reference(another_project)).to eq "sample-project#1" + expect(issue.to_reference(another_project)).to eq "#{project.path}##{issue.iid}" end end context 'when cross-project in different namespace' do let(:another_namespace) { build(:namespace, id: non_existing_record_id, path: 'another-namespace') } - let(:another_namespace_project) { build(:project, path: 'another-project', namespace: another_namespace) } + let(:another_namespace_project) { build(:project, namespace: another_namespace) } it 'returns complete path to the issue' do - expect(issue.to_reference(another_namespace_project)).to eq 'sample-namespace/sample-project#1' + expect(issue.to_reference(another_namespace_project)).to eq "#{project.full_path}##{issue.iid}" end end end @@ -623,11 +651,11 @@ RSpec.describe Issue, feature_category: :team_planning do context 'when argument is a namespace' do context 'when same as issue' do it 'returns path to the issue with the project name' do - expect(issue.to_reference(namespace)).to eq 'sample-project#1' + expect(issue.to_reference(namespace)).to eq "#{project.path}##{issue.iid}" end it 'returns full reference with full: true' do - expect(issue.to_reference(namespace, full: true)).to eq 'sample-namespace/sample-project#1' + expect(issue.to_reference(namespace, full: true)).to eq "#{project.full_path}##{issue.iid}" end end @@ -635,12 +663,111 @@ RSpec.describe Issue, feature_category: :team_planning do let(:group) { build(:group, name: 'Group', path: 'sample-group') } it 'returns full path to the issue with full: true' do - expect(issue.to_reference(group)).to eq 'sample-namespace/sample-project#1' + expect(issue.to_reference(group)).to eq "#{project.full_path}##{issue.iid}" end end end end + describe '#to_reference with table syntax' do + using RSpec::Parameterized::TableSyntax + + let_it_be(:user) { create(:user) } + let_it_be(:user_namespace) { user.namespace } + + let_it_be(:parent) { create(:group) } + let_it_be(:group) { create(:group, parent: parent) } + let_it_be(:another_group) { create(:group) } + + let_it_be(:project) { create(:project, namespace: group) } + let_it_be(:project_namespace) { project.project_namespace } + let_it_be(:same_namespace_project) { create(:project, namespace: group) } + let_it_be(:same_namespace_project_namespace) { same_namespace_project.project_namespace } + + let_it_be(:another_namespace_project) { create(:project) } + let_it_be(:another_namespace_project_namespace) { another_namespace_project.project_namespace } + + let_it_be(:project_issue) { build(:issue, project: project, iid: 123) } + let_it_be(:project_issue_full_reference) { "#{project.full_path}##{project_issue.iid}" } + + let_it_be(:group_issue) { build(:issue, namespace: group, iid: 123) } + let_it_be(:group_issue_full_reference) { "#{group.full_path}##{group_issue.iid}" } + + # this one is just theoretically possible, not smth to be supported for real + let_it_be(:user_issue) { build(:issue, namespace: user_namespace, iid: 123) } + let_it_be(:user_issue_full_reference) { "#{user_namespace.full_path}##{user_issue.iid}" } + + # namespace would be group, project namespace or user namespace + where(:issue, :full, :from, :result) do + ref(:project_issue) | false | nil | lazy { "##{issue.iid}" } + ref(:project_issue) | true | nil | ref(:project_issue_full_reference) + ref(:project_issue) | false | ref(:group) | lazy { "#{project.path}##{issue.iid}" } + ref(:project_issue) | true | ref(:group) | ref(:project_issue_full_reference) + ref(:project_issue) | false | ref(:parent) | ref(:project_issue_full_reference) + ref(:project_issue) | true | ref(:parent) | ref(:project_issue_full_reference) + ref(:project_issue) | false | ref(:project) | lazy { "##{issue.iid}" } + ref(:project_issue) | true | ref(:project) | ref(:project_issue_full_reference) + ref(:project_issue) | false | ref(:project_namespace) | lazy { "##{issue.iid}" } + ref(:project_issue) | true | ref(:project_namespace) | ref(:project_issue_full_reference) + ref(:project_issue) | false | ref(:same_namespace_project) | lazy { "#{project.path}##{issue.iid}" } + ref(:project_issue) | true | ref(:same_namespace_project) | ref(:project_issue_full_reference) + ref(:project_issue) | false | ref(:same_namespace_project_namespace) | lazy { "#{project.path}##{issue.iid}" } + ref(:project_issue) | true | ref(:same_namespace_project_namespace) | ref(:project_issue_full_reference) + ref(:project_issue) | false | ref(:another_group) | ref(:project_issue_full_reference) + ref(:project_issue) | true | ref(:another_group) | ref(:project_issue_full_reference) + ref(:project_issue) | false | ref(:another_namespace_project) | ref(:project_issue_full_reference) + ref(:project_issue) | true | ref(:another_namespace_project) | ref(:project_issue_full_reference) + ref(:project_issue) | false | ref(:another_namespace_project_namespace) | ref(:project_issue_full_reference) + ref(:project_issue) | true | ref(:another_namespace_project_namespace) | ref(:project_issue_full_reference) + ref(:project_issue) | false | ref(:user_namespace) | ref(:project_issue_full_reference) + ref(:project_issue) | true | ref(:user_namespace) | ref(:project_issue_full_reference) + + ref(:group_issue) | false | nil | lazy { "##{issue.iid}" } + ref(:group_issue) | true | nil | ref(:group_issue_full_reference) + ref(:group_issue) | false | ref(:user_namespace) | ref(:group_issue_full_reference) + ref(:group_issue) | true | ref(:user_namespace) | ref(:group_issue_full_reference) + ref(:group_issue) | false | ref(:group) | lazy { "##{issue.iid}" } + ref(:group_issue) | true | ref(:group) | ref(:group_issue_full_reference) + ref(:group_issue) | false | ref(:parent) | lazy { "#{group.path}##{issue.iid}" } + ref(:group_issue) | true | ref(:parent) | ref(:group_issue_full_reference) + ref(:group_issue) | false | ref(:project) | lazy { "#{group.path}##{issue.iid}" } + ref(:group_issue) | true | ref(:project) | ref(:group_issue_full_reference) + ref(:group_issue) | false | ref(:project_namespace) | lazy { "#{group.path}##{issue.iid}" } + ref(:group_issue) | true | ref(:project_namespace) | ref(:group_issue_full_reference) + ref(:group_issue) | false | ref(:another_group) | ref(:group_issue_full_reference) + ref(:group_issue) | true | ref(:another_group) | ref(:group_issue_full_reference) + ref(:group_issue) | false | ref(:another_namespace_project) | ref(:group_issue_full_reference) + ref(:group_issue) | true | ref(:another_namespace_project) | ref(:group_issue_full_reference) + ref(:group_issue) | false | ref(:another_namespace_project_namespace) | ref(:group_issue_full_reference) + ref(:group_issue) | true | ref(:another_namespace_project_namespace) | ref(:group_issue_full_reference) + + ref(:user_issue) | false | nil | lazy { "##{issue.iid}" } + ref(:user_issue) | true | nil | ref(:user_issue_full_reference) + ref(:user_issue) | false | ref(:user_namespace) | lazy { "##{issue.iid}" } + ref(:user_issue) | true | ref(:user_namespace) | ref(:user_issue_full_reference) + ref(:user_issue) | false | ref(:group) | ref(:user_issue_full_reference) + ref(:user_issue) | true | ref(:group) | ref(:user_issue_full_reference) + ref(:user_issue) | false | ref(:parent) | ref(:user_issue_full_reference) + ref(:user_issue) | true | ref(:parent) | ref(:user_issue_full_reference) + ref(:user_issue) | false | ref(:project) | ref(:user_issue_full_reference) + ref(:user_issue) | true | ref(:project) | ref(:user_issue_full_reference) + ref(:user_issue) | false | ref(:project_namespace) | ref(:user_issue_full_reference) + ref(:user_issue) | true | ref(:project_namespace) | ref(:user_issue_full_reference) + ref(:user_issue) | false | ref(:another_group) | ref(:user_issue_full_reference) + ref(:user_issue) | true | ref(:another_group) | ref(:user_issue_full_reference) + ref(:user_issue) | false | ref(:another_namespace_project) | ref(:user_issue_full_reference) + ref(:user_issue) | true | ref(:another_namespace_project) | ref(:user_issue_full_reference) + ref(:user_issue) | false | ref(:another_namespace_project_namespace) | ref(:user_issue_full_reference) + ref(:user_issue) | true | ref(:another_namespace_project_namespace) | ref(:user_issue_full_reference) + end + + with_them do + it 'returns correct reference' do + expect(issue.to_reference(from, full: full)).to eq(result) + end + end + end + describe '#assignee_or_author?' do let(:issue) { create(:issue, project: reusable_project) } @@ -1637,7 +1764,8 @@ RSpec.describe Issue, feature_category: :team_planning do end context 'when project in user namespace' do - let(:project) { build_stubbed(:project_empty_repo) } + let(:project_namespace) { build_stubbed(:project_namespace) } + let(:project) { build_stubbed(:project_empty_repo, project_namespace: project_namespace) } let(:project_id) { project.id } let(:namespace_id) { nil } @@ -1646,7 +1774,8 @@ RSpec.describe Issue, feature_category: :team_planning do context 'when project in a group namespace' do let(:group) { create(:group) } - let(:project) { build_stubbed(:project_empty_repo, group: group) } + let(:project_namespace) { build_stubbed(:project_namespace) } + let(:project) { build_stubbed(:project_empty_repo, group: group, project_namespace: project_namespace) } let(:project_id) { nil } let(:namespace_id) { group.id } @@ -1662,6 +1791,46 @@ RSpec.describe Issue, feature_category: :team_planning do end end + describe '#issue_type' do + let_it_be(:issue) { create(:issue) } + + context 'when the issue_type_uses_work_item_types_table feature flag is enabled' do + it 'gets the type field from the work_item_types table' do + expect(issue).to receive_message_chain(:work_item_type, :base_type) + + issue.issue_type + end + + context 'when the issue is not persisted' do + it 'uses the default work item type' do + non_persisted_issue = build(:issue, work_item_type: nil) + + expect(non_persisted_issue.issue_type).to eq(described_class::DEFAULT_ISSUE_TYPE.to_s) + end + end + end + + context 'when the issue_type_uses_work_item_types_table feature flag is disabled' do + before do + stub_feature_flags(issue_type_uses_work_item_types_table: false) + end + + it 'does not get the value from the work_item_types table' do + expect(issue).not_to receive(:work_item_type) + + issue.issue_type + end + + context 'when the issue is not persisted' do + it 'uses the default work item type' do + non_persisted_issue = build(:issue, work_item_type: nil) + + expect(non_persisted_issue.issue_type).to eq(described_class::DEFAULT_ISSUE_TYPE.to_s) + end + end + end + end + describe '#issue_type_supports?' do let_it_be(:issue) { create(:issue) } @@ -1670,6 +1839,17 @@ RSpec.describe Issue, feature_category: :team_planning do end end + describe '#supports_assignee?' do + Gitlab::DatabaseImporters::WorkItems::BaseTypeImporter::WIDGETS_FOR_TYPE.each_pair do |base_type, widgets| + specify do + issue = build(:issue, base_type) + supports_assignee = widgets.include?(:assignees) + + expect(issue.supports_assignee?).to eq(supports_assignee) + end + end + end + describe '#supports_time_tracking?' do let_it_be(:project) { create(:project) } let_it_be_with_refind(:issue) { create(:incident, project: project) } @@ -1681,10 +1861,10 @@ RSpec.describe Issue, feature_category: :team_planning do with_them do before do - issue.update!(issue_type: issue_type) + issue.update!(issue_type: issue_type, work_item_type: WorkItems::Type.default_by_type(issue_type)) end - it do + specify do expect(issue.supports_time_tracking?).to eq(supports_time_tracking) end end @@ -1701,10 +1881,10 @@ RSpec.describe Issue, feature_category: :team_planning do with_them do before do - issue.update!(issue_type: issue_type) + issue.update!(issue_type: issue_type, work_item_type: WorkItems::Type.default_by_type(issue_type)) end - it do + specify do expect(issue.supports_move_and_clone?).to eq(supports_move_and_clone) end end @@ -1815,4 +1995,24 @@ RSpec.describe Issue, feature_category: :team_planning do end end end + + describe '#work_item_type_with_default' do + subject { Issue.new.work_item_type_with_default } + + it { is_expected.to eq(WorkItems::Type.default_by_type(::Issue::DEFAULT_ISSUE_TYPE)) } + end + + describe 'issue_type enum generated methods' do + using RSpec::Parameterized::TableSyntax + + let_it_be(:issue) { create(:issue, project: reusable_project) } + + where(issue_type: WorkItems::Type.base_types.keys) + + with_them do + it 'raises an error if called' do + expect { issue.public_send("#{issue_type}?".to_sym) }.to raise_error(Issue::ForbiddenColumnUsed) + end + end + end end diff --git a/spec/models/key_spec.rb b/spec/models/key_spec.rb index f1bc7b41cee..7a46e5e7e53 100644 --- a/spec/models/key_spec.rb +++ b/spec/models/key_spec.rb @@ -165,7 +165,7 @@ RSpec.describe Key, :mailer do .with(key) .and_return(service) - expect(service).to receive(:execute) + expect(service).to receive(:execute_async) key.update_last_used_at end diff --git a/spec/models/label_spec.rb b/spec/models/label_spec.rb index ff7ac0ebd2a..26a1edcbcff 100644 --- a/spec/models/label_spec.rb +++ b/spec/models/label_spec.rb @@ -257,4 +257,15 @@ RSpec.describe Label do end end end + + describe '.pluck_titles' do + subject(:pluck_titles) { described_class.pluck_titles } + + it 'returns the audit event type of the event type filter' do + label1 = create(:label, title: "TITLE1") + label2 = create(:label, title: "TITLE2") + + expect(pluck_titles).to contain_exactly(label1.title, label2.title) + end + end end diff --git a/spec/models/member_spec.rb b/spec/models/member_spec.rb index 6a52f12553f..eea96e5e4ae 100644 --- a/spec/models/member_spec.rb +++ b/spec/models/member_spec.rb @@ -16,7 +16,6 @@ RSpec.describe Member, feature_category: :subgroups do describe 'Associations' do it { is_expected.to belong_to(:user) } it { is_expected.to belong_to(:member_namespace) } - it { is_expected.to belong_to(:member_role) } it { is_expected.to have_one(:member_task) } end @@ -173,96 +172,6 @@ RSpec.describe Member, feature_category: :subgroups do end end end - - context 'member role access level' do - let_it_be_with_reload(:member) { create(:group_member, access_level: Gitlab::Access::DEVELOPER) } - - context 'when no member role is associated' do - it 'is valid' do - expect(member).to be_valid - end - end - - context 'when member role is associated' do - let!(:member_role) do - create( - :member_role, - members: [member], - base_access_level: Gitlab::Access::DEVELOPER, - namespace: member.member_namespace - ) - end - - context 'when member role matches access level' do - it 'is valid' do - expect(member).to be_valid - end - end - - context 'when member role does not match access level' do - it 'is invalid' do - member_role.base_access_level = Gitlab::Access::MAINTAINER - - expect(member).not_to be_valid - end - end - - context 'when access_level is changed' do - it 'is invalid' do - member.access_level = Gitlab::Access::MAINTAINER - - expect(member).not_to be_valid - expect(member.errors[:access_level]).to include( - _("cannot be changed since member is associated with a custom role") - ) - end - end - end - end - - context 'member role namespace' do - let_it_be_with_reload(:member) { create(:group_member) } - - context 'when no member role is associated' do - it 'is valid' do - expect(member).to be_valid - end - end - - context 'when member role is associated' do - let_it_be(:member_role) do - create(:member_role, members: [member], namespace: member.group, base_access_level: member.access_level) - end - - context 'when member#member_namespace is a group within hierarchy of member_role#namespace' do - it 'is valid' do - member.member_namespace = create(:group, parent: member_role.namespace) - - expect(member).to be_valid - end - end - - context 'when member#member_namespace is a project within hierarchy of member_role#namespace' do - it 'is valid' do - project = create(:project, group: member_role.namespace) - member.member_namespace = Namespace.find(project.parent_id) - - expect(member).to be_valid - end - end - - context 'when member#member_namespace is outside hierarchy of member_role#namespace' do - it 'is invalid' do - member.member_namespace = create(:group) - - expect(member).not_to be_valid - expect(member.errors[:member_namespace]).to include( - _("must be in same hierarchy as custom role's namespace") - ) - end - end - end - end end describe 'Scopes & finders' do @@ -774,6 +683,24 @@ RSpec.describe Member, feature_category: :subgroups do end end + describe '.filter_by_user_type' do + let_it_be(:service_account) { create(:user, :service_account) } + let_it_be(:service_account_member) { create(:group_member, user: service_account) } + let_it_be(:other_member) { create(:group_member) } + + context 'when the user type is valid' do + it 'returns service accounts' do + expect(described_class.filter_by_user_type('service_account')).to match_array([service_account_member]) + end + end + + context 'when the user type is invalid' do + it 'returns nil' do + expect(described_class.filter_by_user_type('invalid_type')).to eq(nil) + end + end + end + describe '#accept_request' do let(:member) { create(:project_member, requested_at: Time.current.utc) } @@ -971,6 +898,14 @@ RSpec.describe Member, feature_category: :subgroups do end end + describe '.pluck_user_ids' do + let(:member) { create(:group_member) } + + it 'plucks the user ids' do + expect(described_class.where(id: member).pluck_user_ids).to match([member.user_id]) + end + end + describe '#send_invitation_reminder' do subject { member.send_invitation_reminder(0) } diff --git a/spec/models/members/member_role_spec.rb b/spec/models/members/member_role_spec.rb deleted file mode 100644 index 4bf33eb1fce..00000000000 --- a/spec/models/members/member_role_spec.rb +++ /dev/null @@ -1,107 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe MemberRole, feature_category: :authentication_and_authorization do - describe 'associations' do - it { is_expected.to belong_to(:namespace) } - it { is_expected.to have_many(:members) } - end - - describe 'validation' do - subject(:member_role) { build(:member_role) } - - it { is_expected.to validate_presence_of(:namespace) } - it { is_expected.to validate_presence_of(:base_access_level) } - - context 'for attributes_locked_after_member_associated' do - context 'when assigned to member' do - it 'cannot be changed' do - member_role.save! - member_role.members << create(:project_member) - - expect(member_role).not_to be_valid - expect(member_role.errors.messages[:base]).to include( - s_("MemberRole|cannot be changed because it is already assigned to a user. "\ - "Please create a new Member Role instead") - ) - end - end - - context 'when not assigned to member' do - it 'can be changed' do - expect(member_role).to be_valid - end - end - end - - context 'when for namespace' do - let_it_be(:root_group) { create(:group) } - - context 'when namespace is a subgroup' do - it 'is invalid' do - subgroup = create(:group, parent: root_group) - member_role.namespace = subgroup - - expect(member_role).to be_invalid - expect(member_role.errors[:namespace]).to include( - s_("MemberRole|must be top-level namespace") - ) - end - end - - context 'when namespace is a root group' do - it 'is valid' do - member_role.namespace = root_group - - expect(member_role).to be_valid - end - end - - context 'when namespace is not present' do - it 'is invalid with a different error message' do - member_role.namespace = nil - - expect(member_role).to be_invalid - expect(member_role.errors[:namespace]).to include(_("can't be blank")) - end - end - - context 'when namespace is outside hierarchy of member' do - it 'creates a validation error' do - member_role.save! - member_role.namespace = create(:group) - - expect(member_role).not_to be_valid - expect(member_role.errors[:namespace]).to include(s_("MemberRole|can't be changed")) - end - end - end - end - - describe 'callbacks' do - context 'for preventing deletion after member is associated' do - let_it_be(:member_role) { create(:member_role) } - - subject(:destroy_member_role) { member_role.destroy } # rubocop: disable Rails/SaveBang - - it 'allows deletion without any member associated' do - expect(destroy_member_role).to be_truthy - end - - it 'prevent deletion when member is associated' do - create(:group_member, { group: member_role.namespace, - access_level: Gitlab::Access::DEVELOPER, - member_role: member_role }) - member_role.members.reload - - expect(destroy_member_role).to be_falsey - expect(member_role.errors.messages[:base]) - .to( - include(s_("MemberRole|cannot be deleted because it is already assigned to a user. "\ - "Please disassociate the member role from all users before deletion.")) - ) - end - end - end -end diff --git a/spec/models/members/project_member_spec.rb b/spec/models/members/project_member_spec.rb index f0069b89494..20dae056646 100644 --- a/spec/models/members/project_member_spec.rb +++ b/spec/models/members/project_member_spec.rb @@ -9,7 +9,7 @@ RSpec.describe ProjectMember do describe 'validations' do it { is_expected.to allow_value('Project').for(:source_type) } - it { is_expected.not_to allow_value('project').for(:source_type) } + it { is_expected.not_to allow_value('Group').for(:source_type) } it { is_expected.to validate_inclusion_of(:access_level).in_array(Gitlab::Access.values) } end @@ -136,24 +136,6 @@ RSpec.describe ProjectMember do end end - describe '.add_members_to_projects' do - it 'adds the given users to the given projects' do - projects = create_list(:project, 2) - users = create_list(:user, 2) - - described_class.add_members_to_projects( - [projects.first.id, projects.second.id], - [users.first.id, users.second], - described_class::MAINTAINER) - - expect(projects.first.users).to include(users.first) - expect(projects.first.users).to include(users.second) - - expect(projects.second.users).to include(users.first) - expect(projects.second.users).to include(users.second) - end - end - describe '.truncate_teams' do before do @project_1 = create(:project) diff --git a/spec/models/merge_request/diff_llm_summary_spec.rb b/spec/models/merge_request/diff_llm_summary_spec.rb new file mode 100644 index 00000000000..a94adae9fa5 --- /dev/null +++ b/spec/models/merge_request/diff_llm_summary_spec.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe ::MergeRequest::DiffLlmSummary, feature_category: :code_review_workflow do + let_it_be_with_reload(:project) { create(:project, :repository) } + + subject(:merge_request_diff_llm_summary) { build(:merge_request_diff_llm_summary) } + + describe 'associations' do + it { is_expected.to belong_to(:merge_request_diff) } + it { is_expected.to belong_to(:user).optional } + it { is_expected.to validate_presence_of(:content) } + it { is_expected.to validate_length_of(:content).is_at_most(2056) } + it { is_expected.to validate_presence_of(:provider) } + end +end diff --git a/spec/models/merge_request/metrics_spec.rb b/spec/models/merge_request/metrics_spec.rb index 8d1d503b323..b1c2a9b1111 100644 --- a/spec/models/merge_request/metrics_spec.rb +++ b/spec/models/merge_request/metrics_spec.rb @@ -93,4 +93,12 @@ RSpec.describe MergeRequest::Metrics do end end end + + it_behaves_like 'database events tracking batch 2' do + let(:merge_request) { create(:merge_request) } + + let(:record) { merge_request.metrics } + let(:namespace) { nil } + let(:update_params) { { pipeline_id: 1, updated_at: Date.tomorrow } } + end end diff --git a/spec/models/merge_request_spec.rb b/spec/models/merge_request_spec.rb index a3c0c9a0a74..a5e68829c5d 100644 --- a/spec/models/merge_request_spec.rb +++ b/spec/models/merge_request_spec.rb @@ -20,12 +20,16 @@ RSpec.describe MergeRequest, factory_default: :keep, feature_category: :code_rev it { is_expected.to belong_to(:target_project).class_name('Project') } it { is_expected.to belong_to(:source_project).class_name('Project') } it { is_expected.to belong_to(:merge_user).class_name("User") } + + it do + is_expected.to belong_to(:head_pipeline).class_name('Ci::Pipeline').inverse_of(:merge_requests_as_head_pipeline) + end + it { is_expected.to have_many(:assignees).through(:merge_request_assignees) } it { is_expected.to have_many(:reviewers).through(:merge_request_reviewers) } it { is_expected.to have_many(:merge_request_diffs) } it { is_expected.to have_many(:user_mentions).class_name("MergeRequestUserMention") } it { is_expected.to belong_to(:milestone) } - it { is_expected.to belong_to(:iteration) } it { is_expected.to have_many(:resource_milestone_events) } it { is_expected.to have_many(:resource_state_events) } it { is_expected.to have_many(:draft_notes) } @@ -33,6 +37,7 @@ RSpec.describe MergeRequest, factory_default: :keep, feature_category: :code_rev it { is_expected.to have_many(:reviewed_by_users).through(:reviews).source(:author) } it { is_expected.to have_one(:cleanup_schedule).inverse_of(:merge_request) } it { is_expected.to have_many(:created_environments).class_name('Environment').inverse_of(:merge_request) } + it { is_expected.to have_many(:assignment_events).class_name('ResourceEvents::MergeRequestAssignmentEvent').inverse_of(:merge_request) } context 'for forks' do let!(:project) { create(:project) } @@ -393,8 +398,8 @@ RSpec.describe MergeRequest, factory_default: :keep, feature_category: :code_rev instance1 = MergeRequest.find(merge_request.id) instance2 = MergeRequest.find(merge_request.id) - instance1.ensure_metrics - instance2.ensure_metrics + instance1.ensure_metrics! + instance2.ensure_metrics! metrics_records = MergeRequest::Metrics.where(merge_request_id: merge_request.id) expect(metrics_records.size).to eq(1) @@ -760,6 +765,23 @@ RSpec.describe MergeRequest, factory_default: :keep, feature_category: :code_rev end end + context 'when scoped with :merged_before and :merged_after' do + before do + mr2.metrics.update!(merged_at: mr1.metrics.merged_at - 1.week) + mr3.metrics.update!(merged_at: mr1.metrics.merged_at + 1.week) + end + + it 'excludes merge requests outside of the date range' do + expect( + project.merge_requests.merge( + MergeRequest::Metrics + .merged_before(mr1.metrics.merged_at + 1.day) + .merged_after(mr1.metrics.merged_at - 1.day) + ).total_time_to_merge + ).to be_within(1).of(expected_total_time([mr1])) + end + end + def expected_total_time(mrs) mrs = mrs.reject { |mr| mr.merged_at.nil? } mrs.reduce(0.0) do |sum, mr| @@ -1027,7 +1049,7 @@ RSpec.describe MergeRequest, factory_default: :keep, feature_category: :code_rev end describe '#to_reference' do - let(:project) { build(:project, name: 'sample-project') } + let(:project) { build(:project) } let(:merge_request) { build(:merge_request, target_project: project, iid: 1) } it 'returns a String reference to the object' do @@ -1035,12 +1057,12 @@ RSpec.describe MergeRequest, factory_default: :keep, feature_category: :code_rev end it 'supports a cross-project reference' do - another_project = build(:project, name: 'another-project', namespace: project.namespace) - expect(merge_request.to_reference(another_project)).to eq "sample-project!1" + another_project = build(:project, namespace: project.namespace) + expect(merge_request.to_reference(another_project)).to eq "#{project.path}!1" end it 'returns a String reference with the full path' do - expect(merge_request.to_reference(full: true)).to eq(project.full_path + '!1') + expect(merge_request.to_reference(full: true)).to eq("#{project.full_path}!1") end end @@ -4024,26 +4046,11 @@ RSpec.describe MergeRequest, factory_default: :keep, feature_category: :code_rev describe '#use_merge_base_pipeline_for_comparison?' do let(:project) { create(:project, :public, :repository) } let(:merge_request) { create(:merge_request, :with_codequality_reports, source_project: project) } + let(:service_class) { Ci::CompareReportsBaseService } subject { merge_request.use_merge_base_pipeline_for_comparison?(service_class) } - context 'when service class is Ci::CompareMetricsReportsService' do - let(:service_class) { 'Ci::CompareMetricsReportsService' } - - it { is_expected.to be_truthy } - end - - context 'when service class is Ci::CompareCodequalityReportsService' do - let(:service_class) { 'Ci::CompareCodequalityReportsService' } - - it { is_expected.to be_truthy } - end - - context 'when service class is different' do - let(:service_class) { 'Ci::GenerateCoverageReportsService' } - - it { is_expected.to be_falsey } - end + it { is_expected.to eq(false) } end describe '#comparison_base_pipeline' do @@ -4051,6 +4058,7 @@ RSpec.describe MergeRequest, factory_default: :keep, feature_category: :code_rev let(:project) { create(:project, :public, :repository) } let(:merge_request) { create(:merge_request, :with_codequality_reports, source_project: project) } + let(:service_class) { ::Ci::CompareReportsBaseService } let!(:base_pipeline) do create(:ci_pipeline, :with_test_reports, @@ -4060,20 +4068,24 @@ RSpec.describe MergeRequest, factory_default: :keep, feature_category: :code_rev ) end - context 'when service class is Ci::CompareCodequalityReportsService' do - let(:service_class) { 'Ci::CompareCodequalityReportsService' } + before do + allow(merge_request).to receive(:use_merge_base_pipeline_for_comparison?) + .with(service_class).and_return(uses_merge_base) + end + + context 'when service class uses merge base pipeline' do + let(:uses_merge_base) { true } context 'when merge request has a merge request pipeline' do let(:merge_request) do - create(:merge_request, :with_merge_request_pipeline) + create(:merge_request, :with_merge_request_pipeline, source_project: project) end - let(:merge_base_pipeline) do - create(:ci_pipeline, ref: merge_request.target_branch, sha: merge_request.target_branch_sha) + let!(:merge_base_pipeline) do + create(:ci_pipeline, project: project, ref: merge_request.target_branch, sha: merge_request.target_branch_sha) end before do - merge_base_pipeline merge_request.update_head_pipeline end @@ -4082,19 +4094,27 @@ RSpec.describe MergeRequest, factory_default: :keep, feature_category: :code_rev end end - context 'when merge does not have a merge request pipeline' do - it 'returns the base_pipeline' do - expect(pipeline).to eq(base_pipeline) - end + it 'returns the base_pipeline when merge does not have a merge request pipeline' do + expect(pipeline).to eq(base_pipeline) end end - context 'when service_class is different' do - let(:service_class) { 'Ci::GenerateCoverageReportsService' } + context 'when service_class does not use merge base pipeline' do + let(:uses_merge_base) { false } it 'returns the base_pipeline' do expect(pipeline).to eq(base_pipeline) end + + context 'when merge request has a merge request pipeline' do + let(:merge_request) do + create(:merge_request, :with_merge_request_pipeline, source_project: project) + end + + it 'returns the base pipeline' do + expect(pipeline).to eq(base_pipeline) + end + end end end @@ -4279,8 +4299,11 @@ RSpec.describe MergeRequest, factory_default: :keep, feature_category: :code_rev it 'refreshes the number of open merge requests of the target project' do project = subject.target_project - expect { subject.destroy! } - .to change { project.open_merge_requests_count }.from(1).to(0) + expect do + subject.destroy! + + BatchLoader::Executor.clear_current + end.to change { project.open_merge_requests_count }.from(1).to(0) end end @@ -4306,6 +4329,14 @@ RSpec.describe MergeRequest, factory_default: :keep, feature_category: :code_rev transition! end + context 'when skip_merge_status_trigger is set to true' do + before do + subject.skip_merge_status_trigger = true + end + + it_behaves_like 'transition not triggering mergeRequestMergeStatusUpdated GraphQL subscription' + end + context 'when transaction is not committed' do it_behaves_like 'transition not triggering mergeRequestMergeStatusUpdated GraphQL subscription' do def transition! @@ -4459,7 +4490,7 @@ RSpec.describe MergeRequest, factory_default: :keep, feature_category: :code_rev let(:expected_merge_status) { 'checking' } include_examples 'for a valid state transition' - it_behaves_like 'transition triggering mergeRequestMergeStatusUpdated GraphQL subscription' + it_behaves_like 'transition not triggering mergeRequestMergeStatusUpdated GraphQL subscription' end context 'when the status is checking' do @@ -4479,7 +4510,7 @@ RSpec.describe MergeRequest, factory_default: :keep, feature_category: :code_rev let(:expected_merge_status) { 'cannot_be_merged_rechecking' } include_examples 'for a valid state transition' - it_behaves_like 'transition triggering mergeRequestMergeStatusUpdated GraphQL subscription' + it_behaves_like 'transition not triggering mergeRequestMergeStatusUpdated GraphQL subscription' end context 'when the status is cannot_be_merged' do @@ -4615,6 +4646,29 @@ RSpec.describe MergeRequest, factory_default: :keep, feature_category: :code_rev .from(true) .to(false) end + + context 'when it is a first contribution' do + let(:new_user) { create(:user) } + + before do + subject.update!(author: new_user) + end + + it 'sets first_contribution' do + subject.mark_as_merged + + expect(subject.state).to eq('merged') + expect(subject.reload.first_contribution?).to be_truthy + end + + it "doesn't set first_contribution not first contribution" do + create(:merged_merge_request, author: new_user) + + subject.mark_as_merged + + expect(subject.first_contribution?).to be_falsey + end + end end describe 'transition to cannot_be_merged' do @@ -4697,9 +4751,9 @@ RSpec.describe MergeRequest, factory_default: :keep, feature_category: :code_rev end [:closed, :merged].each do |state| - let(:state) { state } - context state do + let(:state) { state } + it 'does not notify' do expect(notification_service).not_to receive(:merge_request_unmergeable) expect(todo_service).not_to receive(:merge_request_became_unmergeable) diff --git a/spec/models/milestone_spec.rb b/spec/models/milestone_spec.rb index 9f6b1f8016b..1c43eafb576 100644 --- a/spec/models/milestone_spec.rb +++ b/spec/models/milestone_spec.rb @@ -439,8 +439,8 @@ RSpec.describe Milestone do describe '#to_reference' do let(:group) { build_stubbed(:group) } - let(:project) { build_stubbed(:project, name: 'sample-project') } - let(:another_project) { build_stubbed(:project, name: 'another-project', namespace: project.namespace) } + let(:project) { build_stubbed(:project, path: 'sample-project') } + let(:another_project) { build_stubbed(:project, path: 'another-project', namespace: project.namespace) } context 'for a project milestone' do let(:milestone) { build_stubbed(:milestone, iid: 1, project: project, name: 'milestone') } @@ -724,4 +724,12 @@ RSpec.describe Milestone do end end end + + describe '#lock_version' do + let_it_be(:milestone) { create(:milestone, project: project) } + + it 'ensures that lock_version and optimistic locking is enabled' do + expect(milestone.lock_version).to be_present + end + end end diff --git a/spec/models/ml/candidate_spec.rb b/spec/models/ml/candidate_spec.rb index 374e49aea01..fa19b723ee2 100644 --- a/spec/models/ml/candidate_spec.rb +++ b/spec/models/ml/candidate_spec.rb @@ -3,48 +3,74 @@ require 'spec_helper' RSpec.describe Ml::Candidate, factory_default: :keep, feature_category: :mlops do - let_it_be(:candidate) { create(:ml_candidates, :with_metrics_and_params, name: 'candidate0') } + let_it_be(:candidate) { create(:ml_candidates, :with_metrics_and_params, :with_artifact, name: 'candidate0') } let_it_be(:candidate2) do create(:ml_candidates, experiment: candidate.experiment, user: create(:user), name: 'candidate2') end - let_it_be(:candidate_artifact) do - FactoryBot.create(:generic_package, - name: candidate.package_name, - version: candidate.package_version, - project: candidate.project) - end - - let(:project) { candidate.experiment.project } + let(:project) { candidate.project } describe 'associations' do it { is_expected.to belong_to(:experiment) } + it { is_expected.to belong_to(:project) } it { is_expected.to belong_to(:user) } + it { is_expected.to belong_to(:package) } + it { is_expected.to belong_to(:ci_build).class_name('Ci::Build') } it { is_expected.to have_many(:params) } it { is_expected.to have_many(:metrics) } it { is_expected.to have_many(:metadata) } end + describe 'modules' do + it_behaves_like 'AtomicInternalId' do + let(:internal_id_attribute) { :internal_id } + let(:instance) { build(:ml_candidates, experiment: candidate.experiment) } + let(:scope) { :project } + let(:scope_attrs) { { project: instance.project } } + let(:usage) { :ml_candidates } + end + end + describe 'default values' do - it { expect(described_class.new.iid).to be_present } + it { expect(described_class.new.eid).to be_present } end - describe '.artifact_root' do - subject { candidate.artifact_root } + describe '.destroy' do + let_it_be(:candidate_to_destroy) do + create(:ml_candidates, :with_metrics_and_params, :with_metadata, :with_artifact) + end - it { is_expected.to eq("/ml_candidate_#{candidate.id}/-/") } + it 'destroys metrics, params and metadata, but not the artifact', :aggregate_failures do + expect { candidate_to_destroy.destroy! } + .to change { Ml::CandidateMetadata.count }.by(-2) + .and change { Ml::CandidateParam.count }.by(-2) + .and change { Ml::CandidateMetric.count }.by(-2) + .and not_change { Packages::Package.count } + end end - describe '.package_name' do - subject { candidate.package_name } + describe '.artifact_root' do + subject { candidate.artifact_root } - it { is_expected.to eq("ml_candidate_#{candidate.id}") } + it { is_expected.to eq("/#{candidate.package_name}/#{candidate.iid}/") } end describe '.package_version' do subject { candidate.package_version } - it { is_expected.to eq('-') } + it { is_expected.to eq(candidate.iid) } + end + + describe '.eid' do + let_it_be(:eid) { SecureRandom.uuid } + + let_it_be(:candidate3) do + build(:ml_candidates, :with_metrics_and_params, name: 'candidate0', eid: eid) + end + + subject { candidate3.eid } + + it { is_expected.to eq(eid) } end describe '.artifact' do @@ -52,10 +78,6 @@ RSpec.describe Ml::Candidate, factory_default: :keep, feature_category: :mlops d subject { tested_candidate.artifact } - before do - candidate_artifact - end - context 'when has logged artifacts' do it 'returns the package' do expect(subject.name).to eq(tested_candidate.package_name) @@ -69,21 +91,26 @@ RSpec.describe Ml::Candidate, factory_default: :keep, feature_category: :mlops d end end - describe '.artifact_lazy' do - context 'when candidates have same the same iid' do - before do - BatchLoader::Executor.clear_current - end + describe '#by_project_id_and_eid' do + let(:project_id) { candidate.experiment.project_id } + let(:eid) { candidate.eid } - it 'loads the correct artifacts', :aggregate_failures do - candidate.artifact_lazy - candidate2.artifact_lazy + subject { described_class.with_project_id_and_eid(project_id, eid) } - expect(Packages::Package).to receive(:joins).once.and_call_original # Only one database call + context 'when eid exists', 'and belongs to project' do + it { is_expected.to eq(candidate) } + end - expect(candidate.artifact.name).to eq(candidate.package_name) - expect(candidate2.artifact).to be_nil - end + context 'when eid exists', 'and does not belong to project' do + let(:project_id) { non_existing_record_id } + + it { is_expected.to be_nil } + end + + context 'when eid does not exist' do + let(:eid) { 'a' } + + it { is_expected.to be_nil } end end @@ -93,18 +120,18 @@ RSpec.describe Ml::Candidate, factory_default: :keep, feature_category: :mlops d subject { described_class.with_project_id_and_iid(project_id, iid) } - context 'when iid exists', 'and belongs to project' do + context 'when internal_id exists', 'and belongs to project' do it { is_expected.to eq(candidate) } end - context 'when iid exists', 'and does not belong to project' do + context 'when internal_id exists', 'and does not belong to project' do let(:project_id) { non_existing_record_id } it { is_expected.to be_nil } end - context 'when iid does not exist' do - let(:iid) { 'a' } + context 'when internal_id does not exist' do + let(:iid) { non_existing_record_id } it { is_expected.to be_nil } end @@ -130,6 +157,9 @@ RSpec.describe Ml::Candidate, factory_default: :keep, feature_category: :mlops d expect(subject.association_cached?(:latest_metrics)).to be(true) expect(subject.association_cached?(:params)).to be(true) expect(subject.association_cached?(:user)).to be(true) + expect(subject.association_cached?(:project)).to be(true) + expect(subject.association_cached?(:package)).to be(true) + expect(subject.association_cached?(:ci_build)).to be(true) end end @@ -161,6 +191,22 @@ RSpec.describe Ml::Candidate, factory_default: :keep, feature_category: :mlops d end end + describe 'from_ci?' do + subject { candidate } + + it 'is false if candidate does not have ci_build_id' do + allow(candidate).to receive(:ci_build_id).and_return(nil) + + is_expected.not_to be_from_ci + end + + it 'is true if candidate does has ci_build_id' do + allow(candidate).to receive(:ci_build_id).and_return(1) + + is_expected.to be_from_ci + end + end + describe '#order_by_metric' do let_it_be(:auc_metrics) do create(:ml_candidate_metrics, name: 'auc', value: 0.4, candidate: candidate) @@ -183,4 +229,11 @@ RSpec.describe Ml::Candidate, factory_default: :keep, feature_category: :mlops d end end end + + context 'with loose foreign key on ml_candidates.ci_build_id' do + it_behaves_like 'cleanup by a loose foreign key' do + let!(:parent) { create(:ci_build) } + let!(:model) { create(:ml_candidates, ci_build: parent) } + end + end end diff --git a/spec/models/ml/experiment_spec.rb b/spec/models/ml/experiment_spec.rb index c75331a2ab5..9738a88b5b8 100644 --- a/spec/models/ml/experiment_spec.rb +++ b/spec/models/ml/experiment_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe Ml::Experiment do +RSpec.describe Ml::Experiment, feature_category: :mlops do let_it_be(:exp) { create(:ml_experiments) } let_it_be(:exp2) { create(:ml_experiments, project: exp.project) } @@ -16,6 +16,12 @@ RSpec.describe Ml::Experiment do it { is_expected.to have_many(:metadata) } end + describe '.package_name' do + describe '.package_name' do + it { expect(exp.package_name).to eq("ml_experiment_#{exp.iid}") } + end + end + describe '#by_project_id_and_iid' do subject { described_class.by_project_id_and_iid(exp.project_id, iid) } @@ -74,4 +80,22 @@ RSpec.describe Ml::Experiment do expect(subject[exp3.id]).to eq(3) end end + + describe '#package_for_experiment?' do + using RSpec::Parameterized::TableSyntax + + subject { described_class.package_for_experiment?(package_name) } + + where(:package_name, :id) do + 'ml_experiment_1234' | true + 'ml_experiment_1234abc' | false + 'ml_experiment_abc' | false + 'ml_experiment_' | false + 'blah' | false + end + + with_them do + it { is_expected.to be(id) } + end + end end diff --git a/spec/models/namespace/aggregation_schedule_spec.rb b/spec/models/namespace/aggregation_schedule_spec.rb index 45b66fa12dd..0289e4a5462 100644 --- a/spec/models/namespace/aggregation_schedule_spec.rb +++ b/spec/models/namespace/aggregation_schedule_spec.rb @@ -5,27 +5,33 @@ require 'spec_helper' RSpec.describe Namespace::AggregationSchedule, :clean_gitlab_redis_shared_state, type: :model do include ExclusiveLeaseHelpers - let(:default_timeout) { described_class.default_lease_timeout } + let(:namespace) { create(:namespace) } + let(:aggregation_schedule) { namespace.build_aggregation_schedule } + let(:default_timeout) { aggregation_schedule.default_lease_timeout } it { is_expected.to belong_to :namespace } describe "#default_lease_timeout" do - subject(:default_lease_timeout) { default_timeout } - - it { is_expected.to eq 30.minutes.to_i } + before do + aggregation_schedule.save! + end - context 'when remove_namespace_aggregator_delay FF is disabled' do - before do - stub_feature_flags(remove_namespace_aggregator_delay: false) + context 'when reduce_aggregation_schedule_lease FF is enabled' do + it 'is 2 minutes' do + stub_feature_flags(reduce_aggregation_schedule_lease: true) + expect(aggregation_schedule.default_lease_timeout).to eq 2.minutes.to_i end + end - it { is_expected.to eq 1.hour.to_i } + context 'when reduce_aggregation_schedule_lease FF is disabled' do + it 'is 30 minutes' do + stub_feature_flags(reduce_aggregation_schedule_lease: false) + expect(aggregation_schedule.default_lease_timeout).to eq 30.minutes.to_i + end end end describe '#schedule_root_storage_statistics' do - let(:namespace) { create(:namespace) } - let(:aggregation_schedule) { namespace.build_aggregation_schedule } let(:lease_key) { "namespace:namespaces_root_statistics:#{namespace.id}" } context "when we can't obtain the lease" do diff --git a/spec/models/namespace/package_setting_spec.rb b/spec/models/namespace/package_setting_spec.rb index 2584fa597ad..fca929600a4 100644 --- a/spec/models/namespace/package_setting_spec.rb +++ b/spec/models/namespace/package_setting_spec.rb @@ -47,28 +47,30 @@ RSpec.describe Namespace::PackageSetting do context 'package types with package_settings' do # As more package types gain settings they will be added to this list [:maven_package, :generic_package].each do |format| - let_it_be(:package) { create(format, name: 'foo', version: 'beta') } # rubocop:disable Rails/SaveBang - let_it_be(:package_type) { package.package_type } - let_it_be(:package_setting) { package.project.namespace.package_settings } - - where(:duplicates_allowed, :duplicate_exception_regex, :result) do - true | '' | true - false | '' | false - false | '.*' | true - false | 'fo.*' | true - false | 'be.*' | true - end + context "with package_type:#{format}" do + let_it_be(:package) { create(format, name: 'foo', version: 'beta') } # rubocop:disable Rails/SaveBang + let_it_be(:package_type) { package.package_type } + let_it_be(:package_setting) { package.project.namespace.package_settings } + + where(:duplicates_allowed, :duplicate_exception_regex, :result) do + true | '' | true + false | '' | false + false | '.*' | true + false | 'fo.*' | true + false | 'be.*' | true + end - with_them do - context "for #{format}" do - before do - package_setting.update!( - "#{package_type}_duplicates_allowed" => duplicates_allowed, - "#{package_type}_duplicate_exception_regex" => duplicate_exception_regex - ) - end + with_them do + context "for #{format}" do + before do + package_setting.update!( + "#{package_type}_duplicates_allowed" => duplicates_allowed, + "#{package_type}_duplicate_exception_regex" => duplicate_exception_regex + ) + end - it { is_expected.to be(result) } + it { is_expected.to be(result) } + end end end end @@ -76,11 +78,13 @@ RSpec.describe Namespace::PackageSetting do context 'package types without package_settings' do [:npm_package, :conan_package, :nuget_package, :pypi_package, :composer_package, :golang_package, :debian_package].each do |format| - let_it_be(:package) { create(format) } # rubocop:disable Rails/SaveBang - let_it_be(:package_setting) { package.project.namespace.package_settings } + context "with package_type:#{format}" do + let_it_be(:package) { create(format) } # rubocop:disable Rails/SaveBang + let_it_be(:package_setting) { package.project.namespace.package_settings } - it 'raises an error' do - expect { subject }.to raise_error(Namespace::PackageSetting::PackageSettingNotImplemented) + it 'raises an error' do + expect { subject }.to raise_error(Namespace::PackageSetting::PackageSettingNotImplemented) + end end end end diff --git a/spec/models/namespace/root_storage_statistics_spec.rb b/spec/models/namespace/root_storage_statistics_spec.rb index 14ac08b545a..c2a0c8c8a7c 100644 --- a/spec/models/namespace/root_storage_statistics_spec.rb +++ b/spec/models/namespace/root_storage_statistics_spec.rb @@ -22,15 +22,13 @@ RSpec.describe Namespace::RootStorageStatistics, type: :model do end describe '#recalculate!' do - let(:namespace) { create(:group) } + let_it_be(:namespace) { create(:group) } + let(:root_storage_statistics) { create(:namespace_root_storage_statistics, namespace: namespace) } let(:project1) { create(:project, namespace: namespace) } let(:project2) { create(:project, namespace: namespace) } - let!(:project_stat1) { create(:project_statistics, project: project1, with_data: true, size_multiplier: 100) } - let!(:project_stat2) { create(:project_statistics, project: project2, with_data: true, size_multiplier: 200) } - shared_examples 'project data refresh' do it 'aggregates project statistics' do root_storage_statistics.recalculate! @@ -96,8 +94,13 @@ RSpec.describe Namespace::RootStorageStatistics, type: :model do end end - it_behaves_like 'project data refresh' - it_behaves_like 'does not include personal snippets' + context 'with project statistics' do + let!(:project_stat1) { create(:project_statistics, project: project1, with_data: true, size_multiplier: 100) } + let!(:project_stat2) { create(:project_statistics, project: project2, with_data: true, size_multiplier: 200) } + + it_behaves_like 'project data refresh' + it_behaves_like 'does not include personal snippets' + end context 'with subgroups' do let(:subgroup1) { create(:group, parent: namespace) } @@ -106,6 +109,9 @@ RSpec.describe Namespace::RootStorageStatistics, type: :model do let(:project1) { create(:project, namespace: subgroup1) } let(:project2) { create(:project, namespace: subgroup2) } + let!(:project_stat1) { create(:project_statistics, project: project1, with_data: true, size_multiplier: 100) } + let!(:project_stat2) { create(:project_statistics, project: project2, with_data: true, size_multiplier: 200) } + it_behaves_like 'project data refresh' it_behaves_like 'does not include personal snippets' end @@ -122,6 +128,9 @@ RSpec.describe Namespace::RootStorageStatistics, type: :model do let(:namespace) { root_group } + let!(:project_stat1) { create(:project_statistics, project: project1, with_data: true, size_multiplier: 100) } + let!(:project_stat2) { create(:project_statistics, project: project2, with_data: true, size_multiplier: 200) } + it 'aggregates namespace statistics' do # This group is not a descendant of the root_group so it shouldn't be included in the final stats. other_group = create(:group) @@ -168,6 +177,9 @@ RSpec.describe Namespace::RootStorageStatistics, type: :model do let(:namespace) { user.namespace } + let!(:project_stat1) { create(:project_statistics, project: project1, with_data: true, size_multiplier: 100) } + let!(:project_stat2) { create(:project_statistics, project: project2, with_data: true, size_multiplier: 200) } + it_behaves_like 'project data refresh' it 'does not aggregate namespace statistics' do @@ -210,5 +222,143 @@ RSpec.describe Namespace::RootStorageStatistics, type: :model do end end end + + context 'with forks of projects' do + it 'aggregates total private forks size' do + project = create_project(visibility_level: :private, size_multiplier: 150) + project_fork = create_fork(project, size_multiplier: 100) + + root_storage_statistics.recalculate! + + expect(root_storage_statistics.reload.private_forks_storage_size).to eq(project_fork.statistics.storage_size) + end + + it 'aggregates total public forks size' do + project = create_project(visibility_level: :public, size_multiplier: 250) + project_fork = create_fork(project, size_multiplier: 200) + + root_storage_statistics.recalculate! + + expect(root_storage_statistics.reload.public_forks_storage_size).to eq(project_fork.statistics.storage_size) + end + + it 'aggregates total internal forks size' do + project = create_project(visibility_level: :internal, size_multiplier: 70) + project_fork = create_fork(project, size_multiplier: 50) + + root_storage_statistics.recalculate! + + expect(root_storage_statistics.reload.internal_forks_storage_size).to eq(project_fork.statistics.storage_size) + end + + it 'aggregates multiple forks' do + project = create_project(size_multiplier: 175) + fork_a = create_fork(project, size_multiplier: 50) + fork_b = create_fork(project, size_multiplier: 60) + + root_storage_statistics.recalculate! + + total_size = fork_a.statistics.storage_size + fork_b.statistics.storage_size + expect(root_storage_statistics.reload.private_forks_storage_size).to eq(total_size) + end + + it 'aggregates only forks in the namespace' do + other_namespace = create(:group) + project = create_project(size_multiplier: 175) + fork_a = create_fork(project, size_multiplier: 50) + create_fork(project, size_multiplier: 50, namespace: other_namespace) + + root_storage_statistics.recalculate! + + expect(root_storage_statistics.reload.private_forks_storage_size).to eq(fork_a.statistics.storage_size) + end + + it 'aggregates forks in subgroups' do + subgroup = create(:group, parent: namespace) + project = create_project(size_multiplier: 100) + project_fork = create_fork(project, namespace: subgroup, size_multiplier: 300) + + root_storage_statistics.recalculate! + + expect(root_storage_statistics.reload.private_forks_storage_size).to eq(project_fork.statistics.storage_size) + end + + it 'aggregates forks along with total storage size' do + project = create_project(size_multiplier: 240) + project_fork = create_fork(project, size_multiplier: 100) + + root_storage_statistics.recalculate! + + root_storage_statistics.reload + expect(root_storage_statistics.private_forks_storage_size).to eq(project_fork.statistics.storage_size) + expect(root_storage_statistics.storage_size).to eq(project.statistics.storage_size + project_fork.statistics.storage_size) + end + + it 'sets the public forks storage size back to zero' do + root_storage_statistics.update!(public_forks_storage_size: 200) + + root_storage_statistics.recalculate! + + expect(root_storage_statistics.reload.public_forks_storage_size).to eq(0) + end + + it 'sets the private forks storage size back to zero' do + root_storage_statistics.update!(private_forks_storage_size: 100) + + root_storage_statistics.recalculate! + + expect(root_storage_statistics.reload.private_forks_storage_size).to eq(0) + end + + it 'sets the internal forks storage size back to zero' do + root_storage_statistics.update!(internal_forks_storage_size: 50) + + root_storage_statistics.recalculate! + + expect(root_storage_statistics.reload.internal_forks_storage_size).to eq(0) + end + + context 'when the feature flag is off' do + before do + stub_feature_flags(root_storage_statistics_calculate_forks: false) + end + + it 'does not aggregate fork storage sizes' do + project = create_project(size_multiplier: 150) + create_fork(project, size_multiplier: 100) + + root_storage_statistics.recalculate! + + expect(root_storage_statistics.reload.private_forks_storage_size).to eq(0) + end + + it 'aggregates fork sizes for enabled namespaces' do + stub_feature_flags(root_storage_statistics_calculate_forks: namespace) + project = create_project(size_multiplier: 150) + project_fork = create_fork(project, size_multiplier: 100) + + root_storage_statistics.recalculate! + + expect(root_storage_statistics.reload.private_forks_storage_size).to eq(project_fork.statistics.storage_size) + end + end + end + end + + def create_project(size_multiplier:, visibility_level: :private) + project = create(:project, visibility_level, namespace: namespace) + create(:project_statistics, project: project, with_data: true, size_multiplier: size_multiplier) + + project + end + + def create_fork(project, size_multiplier:, namespace: nil) + fork_namespace = namespace || project.namespace + project_fork = create(:project, namespace: fork_namespace, visibility_level: project.visibility_level) + create(:project_statistics, project: project_fork, with_data: true, size_multiplier: size_multiplier) + fork_network = project.fork_network || (create(:fork_network, root_project: project) && project.reload.fork_network) + create(:fork_network_member, project: project_fork, fork_network: fork_network) + + project_fork end end diff --git a/spec/models/namespace_setting_spec.rb b/spec/models/namespace_setting_spec.rb index b7cc59b5af3..ba0ce7d6f7f 100644 --- a/spec/models/namespace_setting_spec.rb +++ b/spec/models/namespace_setting_spec.rb @@ -15,6 +15,8 @@ RSpec.describe NamespaceSetting, feature_category: :subgroups, type: :model do it { is_expected.to define_enum_for(:enabled_git_access_protocol).with_values([:all, :ssh, :http]).with_suffix } describe "validations" do + it { is_expected.to validate_inclusion_of(:code_suggestions).in_array([true, false]) } + describe "#default_branch_name_content" do let_it_be(:group) { create(:group) } @@ -192,7 +194,7 @@ RSpec.describe NamespaceSetting, feature_category: :subgroups, type: :model do context 'when a group has parent groups' do let(:grandparent) { create(:group, namespace_settings: settings) } let(:parent) { create(:group, parent: grandparent) } - let!(:group) { create(:group, parent: parent) } + let!(:group) { create(:group, parent: parent) } context "when a parent group has disabled diff previews" do let(:settings) { create(:namespace_settings, show_diff_preview_in_email: false) } @@ -212,61 +214,87 @@ RSpec.describe NamespaceSetting, feature_category: :subgroups, type: :model do end end - describe '#runner_registration_enabled?' do - context 'when not a subgroup' do - let_it_be(:settings) { create(:namespace_settings) } - let_it_be(:group) { create(:group, namespace_settings: settings) } + context 'runner registration settings' do + shared_context 'with runner registration settings changing in hierarchy' do + context 'when there are no parents' do + let_it_be(:group) { create(:group) } - before do - group.update!(runner_registration_enabled: runner_registration_enabled) + it { is_expected.to be_truthy } + + context 'when no group can register runners' do + before do + stub_application_setting(valid_runner_registrars: []) + end + + it { is_expected.to be_falsey } + end end - context 'when :runner_registration_enabled is false' do - let(:runner_registration_enabled) { false } + context 'when there are parents' do + let_it_be(:grandparent) { create(:group) } + let_it_be(:parent) { create(:group, parent: grandparent) } + let_it_be(:group) { create(:group, parent: parent) } - it 'returns false' do - expect(group.runner_registration_enabled?).to be_falsey + before do + grandparent.update!(runner_registration_enabled: grandparent_runner_registration_enabled) end - it 'does not query the db' do - expect { group.runner_registration_enabled? }.not_to exceed_query_limit(0) + context 'when a parent group has runner registration disabled' do + let(:grandparent_runner_registration_enabled) { false } + + it { is_expected.to be_falsey } end - end - context 'when :runner_registration_enabled is true' do - let(:runner_registration_enabled) { true } + context 'when all parent groups have runner registration enabled' do + let(:grandparent_runner_registration_enabled) { true } - it 'returns true' do - expect(group.runner_registration_enabled?).to be_truthy + it { is_expected.to be_truthy } end end end - context 'when a group has parent groups' do - let_it_be(:grandparent) { create(:group) } - let_it_be(:parent) { create(:group, parent: grandparent) } - let_it_be(:group) { create(:group, parent: parent) } + describe '#runner_registration_enabled?' do + subject(:group_setting) { group.runner_registration_enabled? } + + let_it_be(:settings) { create(:namespace_settings) } + let_it_be(:group) { create(:group, namespace_settings: settings) } before do - grandparent.update!(runner_registration_enabled: runner_registration_enabled) + group.update!(runner_registration_enabled: group_runner_registration_enabled) end - context 'when a parent group has runner registration disabled' do - let(:runner_registration_enabled) { false } + context 'when runner registration is enabled' do + let(:group_runner_registration_enabled) { true } - it 'returns false' do - expect(group.runner_registration_enabled?).to be_falsey - end + it { is_expected.to be_truthy } + + it_behaves_like 'with runner registration settings changing in hierarchy' end - context 'when all parent groups have runner registration enabled' do - let(:runner_registration_enabled) { true } + context 'when runner registration is disabled' do + let(:group_runner_registration_enabled) { false } - it 'returns true' do - expect(group.runner_registration_enabled?).to be_truthy + it { is_expected.to be_falsey } + + it 'does not query the db' do + expect { group.runner_registration_enabled? }.not_to exceed_query_limit(0) + end + + context 'when group runner registration is disallowed' do + before do + stub_application_setting(valid_runner_registrars: []) + end + + it { is_expected.to be_falsey } end end end + + describe '#all_ancestors_have_runner_registration_enabled?' do + subject(:group_setting) { group.all_ancestors_have_runner_registration_enabled? } + + it_behaves_like 'with runner registration settings changing in hierarchy' + end end describe '#allow_runner_registration_token?' do diff --git a/spec/models/namespace_spec.rb b/spec/models/namespace_spec.rb index a0698ac30f5..3ff49938de5 100644 --- a/spec/models/namespace_spec.rb +++ b/spec/models/namespace_spec.rb @@ -31,7 +31,6 @@ RSpec.describe Namespace, feature_category: :subgroups do it { is_expected.to have_many :pending_builds } it { is_expected.to have_one :namespace_route } it { is_expected.to have_many :namespace_members } - it { is_expected.to have_many :member_roles } it { is_expected.to have_one :cluster_enabled_grant } it { is_expected.to have_many(:work_items) } it { is_expected.to have_many :achievements } @@ -91,7 +90,6 @@ RSpec.describe Namespace, feature_category: :subgroups do context 'validating the parent of a namespace' do using RSpec::Parameterized::TableSyntax - # rubocop:disable Lint/BinaryOperatorWithIdenticalOperands where(:parent_type, :child_type, :error) do nil | ref(:user_sti_name) | nil nil | ref(:group_sti_name) | nil @@ -106,7 +104,6 @@ RSpec.describe Namespace, feature_category: :subgroups do ref(:user_sti_name) | ref(:group_sti_name) | 'user namespace cannot be the parent of another namespace' ref(:user_sti_name) | ref(:project_sti_name) | nil end - # rubocop:enable Lint/BinaryOperatorWithIdenticalOperands with_them do it 'validates namespace parent' do @@ -171,19 +168,26 @@ RSpec.describe Namespace, feature_category: :subgroups do let_it_be(:parent) { create(:namespace) } - # rubocop:disable Lint/BinaryOperatorWithIdenticalOperands where(:namespace_type, :path, :valid) do - ref(:project_sti_name) | 'j' | true - ref(:project_sti_name) | 'path.' | true - ref(:project_sti_name) | 'blob' | false - ref(:group_sti_name) | 'j' | false - ref(:group_sti_name) | 'path.' | false - ref(:group_sti_name) | 'blob' | true - ref(:user_sti_name) | 'j' | false - ref(:user_sti_name) | 'path.' | false - ref(:user_sti_name) | 'blob' | true - end - # rubocop:enable Lint/BinaryOperatorWithIdenticalOperands + ref(:project_sti_name) | 'j' | true + ref(:project_sti_name) | 'path.' | false + ref(:project_sti_name) | '.path' | false + ref(:project_sti_name) | 'path.git' | false + ref(:project_sti_name) | 'namespace__path' | false + ref(:project_sti_name) | 'blob' | false + ref(:group_sti_name) | 'j' | false + ref(:group_sti_name) | 'path.' | false + ref(:group_sti_name) | '.path' | false + ref(:group_sti_name) | 'path.git' | false + ref(:group_sti_name) | 'namespace__path' | false + ref(:group_sti_name) | 'blob' | true + ref(:user_sti_name) | 'j' | false + ref(:user_sti_name) | 'path.' | false + ref(:user_sti_name) | '.path' | false + ref(:user_sti_name) | 'path.git' | false + ref(:user_sti_name) | 'namespace__path' | false + ref(:user_sti_name) | 'blob' | true + end with_them do it 'validates namespace path' do @@ -193,6 +197,26 @@ RSpec.describe Namespace, feature_category: :subgroups do expect(namespace.valid?).to be(valid) end end + + context 'when path starts or ends with a special character' do + it 'does not raise validation error for path for existing namespaces' do + parent.update_attribute(:path, '_path_') + + expect { parent.update!(name: 'Foo') }.not_to raise_error + end + end + + context 'when restrict_special_characters_in_namespace_path feature flag is disabled' do + before do + stub_feature_flags(restrict_special_characters_in_namespace_path: false) + end + + it 'allows special character at the start or end of project namespace path' do + namespace = build(:namespace, type: project_sti_name, parent: parent, path: '_path_') + + expect(namespace).to be_valid + end + end end describe '1 char path length' do @@ -237,6 +261,117 @@ RSpec.describe Namespace, feature_category: :subgroups do end end + describe "ReferencePatternValidation" do + subject { described_class.reference_pattern } + + it { is_expected.to match("@group1") } + it { is_expected.to match("@group1/group2/group3") } + it { is_expected.to match("@1234/1234/1234") } + it { is_expected.to match("@.q-w_e") } + end + + describe '#to_reference_base' do + using RSpec::Parameterized::TableSyntax + + let_it_be(:user) { create(:user) } + let_it_be(:user_namespace) { user.namespace } + + let_it_be(:parent) { create(:group) } + let_it_be(:group) { create(:group, parent: parent) } + let_it_be(:another_group) { create(:group) } + + let_it_be(:project) { create(:project, namespace: group) } + let_it_be(:project_namespace) { project.project_namespace } + + let_it_be(:another_namespace_project) { create(:project) } + let_it_be(:another_namespace_project_namespace) { another_namespace_project.project_namespace } + + # testing references with namespace being: group, project namespace and user namespace + where(:namespace, :full, :from, :result) do + ref(:parent) | false | nil | nil + ref(:parent) | true | nil | lazy { parent.full_path } + ref(:parent) | false | ref(:group) | lazy { parent.path } + ref(:parent) | true | ref(:group) | lazy { parent.full_path } + ref(:parent) | false | ref(:parent) | nil + ref(:parent) | true | ref(:parent) | lazy { parent.full_path } + ref(:parent) | false | ref(:project) | lazy { parent.path } + ref(:parent) | true | ref(:project) | lazy { parent.full_path } + ref(:parent) | false | ref(:project_namespace) | lazy { parent.path } + ref(:parent) | true | ref(:project_namespace) | lazy { parent.full_path } + ref(:parent) | false | ref(:another_group) | lazy { parent.full_path } + ref(:parent) | true | ref(:another_group) | lazy { parent.full_path } + ref(:parent) | false | ref(:another_namespace_project) | lazy { parent.full_path } + ref(:parent) | true | ref(:another_namespace_project) | lazy { parent.full_path } + ref(:parent) | false | ref(:another_namespace_project_namespace) | lazy { parent.full_path } + ref(:parent) | true | ref(:another_namespace_project_namespace) | lazy { parent.full_path } + ref(:parent) | false | ref(:user_namespace) | lazy { parent.full_path } + ref(:parent) | true | ref(:user_namespace) | lazy { parent.full_path } + + ref(:group) | false | nil | nil + ref(:group) | true | nil | lazy { group.full_path } + ref(:group) | false | ref(:group) | nil + ref(:group) | true | ref(:group) | lazy { group.full_path } + ref(:group) | false | ref(:parent) | lazy { group.path } + ref(:group) | true | ref(:parent) | lazy { group.full_path } + ref(:group) | false | ref(:project) | lazy { group.path } + ref(:group) | true | ref(:project) | lazy { group.full_path } + ref(:group) | false | ref(:project_namespace) | lazy { group.path } + ref(:group) | true | ref(:project_namespace) | lazy { group.full_path } + ref(:group) | false | ref(:another_group) | lazy { group.full_path } + ref(:group) | true | ref(:another_group) | lazy { group.full_path } + ref(:group) | false | ref(:another_namespace_project) | lazy { group.full_path } + ref(:group) | true | ref(:another_namespace_project) | lazy { group.full_path } + ref(:group) | false | ref(:another_namespace_project_namespace) | lazy { group.full_path } + ref(:group) | true | ref(:another_namespace_project_namespace) | lazy { group.full_path } + ref(:group) | false | ref(:user_namespace) | lazy { group.full_path } + ref(:group) | true | ref(:user_namespace) | lazy { group.full_path } + + ref(:project_namespace) | false | nil | nil + ref(:project_namespace) | true | nil | lazy { project_namespace.full_path } + ref(:project_namespace) | false | ref(:group) | lazy { project_namespace.path } + ref(:project_namespace) | true | ref(:group) | lazy { project_namespace.full_path } + ref(:project_namespace) | false | ref(:parent) | lazy { project_namespace.full_path } + ref(:project_namespace) | true | ref(:parent) | lazy { project_namespace.full_path } + ref(:project_namespace) | false | ref(:project) | nil + ref(:project_namespace) | true | ref(:project) | lazy { project_namespace.full_path } + ref(:project_namespace) | false | ref(:project_namespace) | nil + ref(:project_namespace) | true | ref(:project_namespace) | lazy { project_namespace.full_path } + ref(:project_namespace) | false | ref(:another_group) | lazy { project_namespace.full_path } + ref(:project_namespace) | true | ref(:another_group) | lazy { project_namespace.full_path } + ref(:project_namespace) | false | ref(:another_namespace_project) | lazy { project_namespace.full_path } + ref(:project_namespace) | true | ref(:another_namespace_project) | lazy { project_namespace.full_path } + ref(:project_namespace) | false | ref(:another_namespace_project_namespace) | lazy { project_namespace.full_path } + ref(:project_namespace) | true | ref(:another_namespace_project_namespace) | lazy { project_namespace.full_path } + ref(:project_namespace) | false | ref(:user_namespace) | lazy { project_namespace.full_path } + ref(:project_namespace) | true | ref(:user_namespace) | lazy { project_namespace.full_path } + + ref(:user_namespace) | false | nil | nil + ref(:user_namespace) | true | nil | lazy { user_namespace.full_path } + ref(:user_namespace) | false | ref(:user_namespace) | nil + ref(:user_namespace) | true | ref(:user_namespace) | lazy { user_namespace.full_path } + ref(:user_namespace) | false | ref(:group) | lazy { user_namespace.full_path } + ref(:user_namespace) | true | ref(:group) | lazy { user_namespace.full_path } + ref(:user_namespace) | false | ref(:parent) | lazy { user_namespace.full_path } + ref(:user_namespace) | true | ref(:parent) | lazy { user_namespace.full_path } + ref(:user_namespace) | false | ref(:project) | lazy { user_namespace.full_path } + ref(:user_namespace) | true | ref(:project) | lazy { user_namespace.full_path } + ref(:user_namespace) | false | ref(:project_namespace) | lazy { user_namespace.full_path } + ref(:user_namespace) | true | ref(:project_namespace) | lazy { user_namespace.full_path } + ref(:user_namespace) | false | ref(:another_group) | lazy { user_namespace.full_path } + ref(:user_namespace) | true | ref(:another_group) | lazy { user_namespace.full_path } + ref(:user_namespace) | false | ref(:another_namespace_project) | lazy { user_namespace.full_path } + ref(:user_namespace) | true | ref(:another_namespace_project) | lazy { user_namespace.full_path } + ref(:user_namespace) | false | ref(:another_namespace_project_namespace) | lazy { user_namespace.full_path } + ref(:user_namespace) | true | ref(:another_namespace_project_namespace) | lazy { user_namespace.full_path } + end + + with_them do + it 'returns correct path' do + expect(namespace.to_reference_base(from, full: full)).to eq(result) + end + end + end + describe 'handling STI', :aggregate_failures do let(:namespace_type) { nil } let(:parent) { nil } @@ -369,7 +504,6 @@ RSpec.describe Namespace, feature_category: :subgroups do it { is_expected.to delegate_method(:runner_registration_enabled).to(:namespace_settings) } it { is_expected.to delegate_method(:runner_registration_enabled?).to(:namespace_settings) } it { is_expected.to delegate_method(:allow_runner_registration_token).to(:namespace_settings) } - it { is_expected.to delegate_method(:allow_runner_registration_token?).to(:namespace_settings) } it { is_expected.to delegate_method(:maven_package_requests_forwarding).to(:package_settings) } it { is_expected.to delegate_method(:pypi_package_requests_forwarding).to(:package_settings) } it { is_expected.to delegate_method(:npm_package_requests_forwarding).to(:package_settings) } @@ -388,6 +522,38 @@ RSpec.describe Namespace, feature_category: :subgroups do is_expected.to delegate_method(:allow_runner_registration_token=).to(:namespace_settings) .with_arguments(:args) end + + describe '#allow_runner_registration_token?' do + subject { namespace.allow_runner_registration_token? } + + context 'when namespace_settings is nil' do + let_it_be(:namespace) { create(:namespace) } + + it { is_expected.to eq false } + end + + context 'when namespace_settings is not nil' do + let_it_be(:namespace) { create(:namespace, :with_namespace_settings) } + + it { is_expected.to eq true } + + context 'when namespace_settings.allow_runner_registration_token? is false' do + before do + namespace.allow_runner_registration_token = false + end + + it { is_expected.to eq false } + end + + context 'when namespace_settings.allow_runner_registration_token? is true' do + before do + namespace.allow_runner_registration_token = true + end + + it { is_expected.to eq true } + end + end + end end describe "Respond to" do @@ -436,6 +602,14 @@ RSpec.describe Namespace, feature_category: :subgroups do end end + context 'when parent is nil' do + let(:namespace) { build(:group, parent: nil) } + + it 'returns []' do + expect(namespace.traversal_ids).to eq [] + end + end + context 'when made a child group' do let!(:namespace) { create(:group) } let!(:parent_namespace) { create(:group, children: [namespace]) } @@ -458,6 +632,17 @@ RSpec.describe Namespace, feature_category: :subgroups do expect(namespace.root_ancestor).to eq new_root end end + + context 'within a transaction' do + # We would like traversal_ids to be defined within a transaction, but it's not possible yet. + # This spec exists to assert that the behavior is known. + it 'is not defined yet' do + Namespace.transaction do + group = create(:group) + expect(group.traversal_ids).to be_empty + end + end + end end context 'traversal scopes' do @@ -542,17 +727,6 @@ RSpec.describe Namespace, feature_category: :subgroups do it { expect(child.traversal_ids).to eq [parent.id, child.id] } it { expect(parent.sync_events.count).to eq 1 } it { expect(child.sync_events.count).to eq 1 } - - context 'when set_traversal_ids_on_save feature flag is disabled' do - before do - stub_feature_flags(set_traversal_ids_on_save: false) - end - - it 'only sets traversal_ids on reload' do - expect { parent.reload }.to change(parent, :traversal_ids).from([]).to([parent.id]) - expect { child.reload }.to change(child, :traversal_ids).from([]).to([parent.id, child.id]) - end - end end context 'traversal_ids on update' do @@ -565,18 +739,6 @@ RSpec.describe Namespace, feature_category: :subgroups do it 'sets the traversal_ids attribute' do expect { subject }.to change { namespace1.traversal_ids }.from([namespace1.id]).to([namespace2.id, namespace1.id]) end - - context 'when set_traversal_ids_on_save feature flag is disabled' do - before do - stub_feature_flags(set_traversal_ids_on_save: false) - end - - it 'sets traversal_ids after reload' do - subject - - expect { namespace1.reload }.to change(namespace1, :traversal_ids).from([]).to([namespace2.id, namespace1.id]) - end - end end it 'creates a Namespaces::SyncEvent using triggers' do @@ -677,24 +839,41 @@ RSpec.describe Namespace, feature_category: :subgroups do describe '#any_project_has_container_registry_tags?' do subject { namespace.any_project_has_container_registry_tags? } - let!(:project_without_registry) { create(:project, namespace: namespace) } + let(:project) { create(:project, namespace: namespace) } + + it 'returns true if there is a project with container registry tags' do + expect(namespace).to receive(:first_project_with_container_registry_tags).and_return(project) - context 'without tags' do - it { is_expected.to be_falsey } + expect(subject).to be_truthy end - context 'with tags' do - before do - repositories = create_list(:container_repository, 3) - create(:project, namespace: namespace, container_repositories: repositories) + it 'returns false if there is no project with container registry tags' do + expect(namespace).to receive(:first_project_with_container_registry_tags).and_return(nil) + + expect(subject).to be_falsey + end + end + describe '#first_project_with_container_registry_tags' do + let(:container_repository) { create(:container_repository) } + let!(:project) { create(:project, namespace: namespace, container_repositories: [container_repository]) } + + context 'when Gitlab API is not supported' do + before do stub_container_registry_config(enabled: true) + allow(ContainerRegistry::GitlabApiClient).to receive(:supports_gitlab_api?).and_return(false) end - it 'finds tags' do + it 'returns the project' do stub_container_registry_tags(repository: :any, tags: ['tag']) - is_expected.to be_truthy + expect(namespace.first_project_with_container_registry_tags).to eq(project) + end + + it 'returns no project' do + stub_container_registry_tags(repository: :any, tags: nil) + + expect(namespace.first_project_with_container_registry_tags).to be_nil end it 'does not cause N+1 query in fetching registries' do @@ -704,29 +883,24 @@ RSpec.describe Namespace, feature_category: :subgroups do other_repositories = create_list(:container_repository, 2) create(:project, namespace: namespace, container_repositories: other_repositories) - expect { namespace.any_project_has_container_registry_tags? }.not_to exceed_query_limit(control_count + 1) + expect { namespace.first_project_with_container_registry_tags }.not_to exceed_query_limit(control_count + 1) end end - end - - describe '#first_project_with_container_registry_tags' do - let(:container_repository) { create(:container_repository) } - let!(:project) { create(:project, namespace: namespace, container_repositories: [container_repository]) } - before do - stub_container_registry_config(enabled: true) - end - - it 'returns the project' do - stub_container_registry_tags(repository: :any, tags: ['tag']) - - expect(namespace.first_project_with_container_registry_tags).to eq(project) - end + context 'when Gitlab API is supported' do + before do + allow(ContainerRegistry::GitlabApiClient).to receive(:supports_gitlab_api?).and_return(true) + stub_container_registry_config(enabled: true, api_url: 'http://container-registry', key: 'spec/fixtures/x509_certificate_pk.key') + end - it 'returns no project' do - stub_container_registry_tags(repository: :any, tags: nil) + it 'calls and returns GitlabApiClient.one_project_with_container_registry_tag' do + expect(ContainerRegistry::GitlabApiClient) + .to receive(:one_project_with_container_registry_tag) + .with(namespace.full_path) + .and_return(project) - expect(namespace.first_project_with_container_registry_tags).to be_nil + expect(namespace.first_project_with_container_registry_tags).to eq(project) + end end end @@ -755,6 +929,7 @@ RSpec.describe Namespace, feature_category: :subgroups do with_them do before do + allow(ContainerRegistry::GitlabApiClient).to receive(:one_project_with_container_registry_tag).and_return(nil) stub_container_registry_config(enabled: true, api_url: 'http://container-registry', key: 'spec/fixtures/x509_certificate_pk.key') allow(Gitlab).to receive(:com?).and_return(true) allow(ContainerRegistry::GitlabApiClient).to receive(:supports_gitlab_api?).and_return(gitlab_api_supported) @@ -975,43 +1150,6 @@ RSpec.describe Namespace, feature_category: :subgroups do end end - describe '.find_by_pages_host' do - it 'finds namespace by GitLab Pages host and is case-insensitive' do - namespace = create(:namespace, name: 'topNAMEspace', path: 'topNAMEspace') - create(:namespace, name: 'annother_namespace') - host = "TopNamespace.#{Settings.pages.host.upcase}" - - expect(described_class.find_by_pages_host(host)).to eq(namespace) - end - - context 'when there is non-top-level group with searched name' do - before do - create(:group, :nested, path: 'pages') - end - - it 'ignores this group' do - host = "pages.#{Settings.pages.host.upcase}" - - expect(described_class.find_by_pages_host(host)).to be_nil - end - - it 'finds right top level group' do - group = create(:group, path: 'pages') - - host = "pages.#{Settings.pages.host.upcase}" - - expect(described_class.find_by_pages_host(host)).to eq(group) - end - end - - it "returns no result if the provided host is not subdomain of the Pages host" do - create(:namespace, name: 'namespace.io') - host = "namespace.io" - - expect(described_class.find_by_pages_host(host)).to eq(nil) - end - end - describe '.top_most' do let_it_be(:namespace) { create(:namespace) } let_it_be(:group) { create(:group) } @@ -1037,6 +1175,7 @@ RSpec.describe Namespace, feature_category: :subgroups do allow(namespace).to receive(:path_was).and_return(namespace.path) allow(namespace).to receive(:path).and_return('new_path') + allow(namespace).to receive(:first_project_with_container_registry_tags).and_return(project) end it 'raises an error about not movable project' do @@ -1447,30 +1586,6 @@ RSpec.describe Namespace, feature_category: :subgroups do end end - describe '#use_traversal_ids_for_root_ancestor?' do - let_it_be(:namespace, reload: true) { create(:namespace) } - - subject { namespace.use_traversal_ids_for_root_ancestor? } - - context 'when use_traversal_ids_for_root_ancestor feature flag is true' do - before do - stub_feature_flags(use_traversal_ids_for_root_ancestor: true) - end - - it { is_expected.to eq true } - - it_behaves_like 'disabled feature flag when traversal_ids is blank' - end - - context 'when use_traversal_ids_for_root_ancestor feature flag is false' do - before do - stub_feature_flags(use_traversal_ids_for_root_ancestor: false) - end - - it { is_expected.to eq false } - end - end - describe '#use_traversal_ids_for_ancestors?' do let_it_be(:namespace, reload: true) { create(:namespace) } @@ -1618,14 +1733,6 @@ RSpec.describe Namespace, feature_category: :subgroups do end describe '#all_projects' do - context 'when recursive approach is disabled' do - before do - stub_feature_flags(recursive_approach_for_all_projects: false) - end - - include_examples '#all_projects' - end - context 'with use_traversal_ids feature flag enabled' do before do stub_feature_flags(use_traversal_ids: true) @@ -1917,6 +2024,62 @@ RSpec.describe Namespace, feature_category: :subgroups do expect(very_deep_nested_group.root_ancestor).to eq(root_group) end end + + context 'when parent is changed' do + let(:group) { create(:group) } + let(:new_parent) { create(:group) } + + shared_examples 'updates root_ancestor' do + it do + expect { subject }.to change { group.root_ancestor }.from(group).to(new_parent) + end + end + + context 'by object' do + subject { group.parent = new_parent } + + include_examples 'updates root_ancestor' + end + + context 'by id' do + subject { group.parent_id = new_parent.id } + + include_examples 'updates root_ancestor' + end + end + + context 'within a transaction' do + context 'with a persisted parent' do + let(:parent) { create(:group) } + + it do + Namespace.transaction do + group = create(:group, parent: parent) + expect(group.root_ancestor).to eq parent + end + end + end + + context 'with a non-persisted parent' do + let(:parent) { build(:group) } + + it do + Namespace.transaction do + group = create(:group, parent: parent) + expect(group.root_ancestor).to eq parent + end + end + end + + context 'without a parent' do + it do + Namespace.transaction do + group = create(:group) + expect(group.root_ancestor).to eq group + end + end + end + end end describe '#full_path_before_last_save' do @@ -2105,34 +2268,6 @@ RSpec.describe Namespace, feature_category: :subgroups do end end - describe '#pages_virtual_domain' do - let(:project) { create(:project, namespace: namespace) } - let(:virtual_domain) { namespace.pages_virtual_domain } - - before do - project.mark_pages_as_deployed - project.update_pages_deployment!(create(:pages_deployment, project: project)) - end - - it 'returns the virual domain' do - expect(virtual_domain).to be_an_instance_of(Pages::VirtualDomain) - expect(virtual_domain.lookup_paths).not_to be_empty - expect(virtual_domain.cache_key).to match(/pages_domain_for_namespace_#{namespace.root_ancestor.id}_/) - end - - context 'when :cache_pages_domain_api is disabled' do - before do - stub_feature_flags(cache_pages_domain_api: false) - end - - it 'returns the virual domain' do - expect(virtual_domain).to be_an_instance_of(Pages::VirtualDomain) - expect(virtual_domain.lookup_paths).not_to be_empty - expect(virtual_domain.cache_key).to be_nil - end - end - end - describe '#any_project_with_pages_deployed?' do it 'returns true if any project nested under the group has pages deployed' do parent_1 = create(:group) # Three projects, one with pages @@ -2505,28 +2640,6 @@ RSpec.describe Namespace, feature_category: :subgroups do end end - describe 'storage_enforcement_date', :freeze_time do - let_it_be(:namespace) { create(:group) } - - before do - stub_feature_flags(namespace_storage_limit_bypass_date_check: false) - end - - it 'returns correct date' do - expect(namespace.storage_enforcement_date).to eql(3.months.from_now.to_date) - end - - context 'when :storage_banner_bypass_date_check is enabled' do - before do - stub_feature_flags(namespace_storage_limit_bypass_date_check: true) - end - - it 'returns the current date' do - expect(namespace.storage_enforcement_date).to eq(Date.current) - end - end - end - describe 'serialization' do let(:object) { build(:namespace) } diff --git a/spec/models/namespaces/randomized_suffix_path_spec.rb b/spec/models/namespaces/randomized_suffix_path_spec.rb index a2484030f3c..fc5ccd95ce6 100644 --- a/spec/models/namespaces/randomized_suffix_path_spec.rb +++ b/spec/models/namespaces/randomized_suffix_path_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe Namespaces::RandomizedSuffixPath, feature_category: :not_owned do +RSpec.describe Namespaces::RandomizedSuffixPath, feature_category: :shared do let(:path) { 'backintime' } subject(:suffixed_path) { described_class.new(path) } diff --git a/spec/models/note_spec.rb b/spec/models/note_spec.rb index 013070f7be5..f722415d428 100644 --- a/spec/models/note_spec.rb +++ b/spec/models/note_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe Note do +RSpec.describe Note, feature_category: :team_planning do include RepoHelpers describe 'associations' do @@ -11,6 +11,7 @@ RSpec.describe Note do it { is_expected.to belong_to(:author).class_name('User') } it { is_expected.to have_many(:todos) } + it { is_expected.to have_one(:note_metadata).inverse_of(:note).class_name('Notes::NoteMetadata') } it { is_expected.to belong_to(:review).inverse_of(:notes) } end @@ -799,20 +800,22 @@ RSpec.describe Note do describe '#system_note_with_references?' do it 'falsey for user-generated notes' do - note = create(:note, system: false) + note = build_stubbed(:note, system: false) expect(note.system_note_with_references?).to be_falsy end context 'when the note might contain cross references' do SystemNoteMetadata.new.cross_reference_types.each do |type| - let(:note) { create(:note, :system) } - let!(:metadata) { create(:system_note_metadata, note: note, action: type) } + context "with #{type}" do + let(:note) { build_stubbed(:note, :system) } + let!(:metadata) { build_stubbed(:system_note_metadata, note: note, action: type) } - it 'delegates to the cross-reference regex' do - expect(note).to receive(:matches_cross_reference_regex?).and_return(false) + it 'delegates to the cross-reference regex' do + expect(note).to receive(:matches_cross_reference_regex?).and_return(false) - note.system_note_with_references? + note.system_note_with_references? + end end end end @@ -1100,6 +1103,16 @@ RSpec.describe Note do end end + describe '#for_work_item?' do + it 'returns true for a work item' do + expect(build(:note_on_work_item).for_work_item?).to be true + end + + it 'returns false for an issue' do + expect(build(:note_on_issue).for_work_item?).to be false + end + end + describe '#for_project_snippet?' do it 'returns true for a project snippet note' do expect(build(:note_on_project_snippet).for_project_snippet?).to be true @@ -1656,6 +1669,32 @@ RSpec.describe Note do end end end + + describe '.without_hidden' do + subject { described_class.without_hidden } + + context 'when a note with a banned author exists' do + let_it_be(:banned_user) { create(:banned_user).user } + let_it_be(:banned_note) { create(:note, author: banned_user) } + + context 'when the :hidden_notes feature is disabled' do + before do + stub_feature_flags(hidden_notes: false) + end + + it { is_expected.to include(banned_note, note1) } + end + + context 'when the :hidden_notes feature is enabled' do + before do + stub_feature_flags(hidden_notes: true) + end + + it { is_expected.not_to include(banned_note) } + it { is_expected.to include(note1) } + end + end + end end describe 'banzai_render_context' do diff --git a/spec/models/notes/note_metadata_spec.rb b/spec/models/notes/note_metadata_spec.rb new file mode 100644 index 00000000000..d1b35e0cdf9 --- /dev/null +++ b/spec/models/notes/note_metadata_spec.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Notes::NoteMetadata, feature_category: :team_planning do + describe 'associations' do + it { is_expected.to belong_to(:note) } + end + + describe 'callbacks' do + let_it_be(:note) { create(:note) } + let_it_be(:email) { "#{'a' * 255}@example.com" } + + context 'with before_save :ensure_email_participant_length' do + let(:note_metadata) { create(:note_metadata, note: note, email_participant: email) } + + context 'when email length is > 255' do + let(:expected_email) { "#{'a' * 252}..." } + + it 'rewrites the email within max length' do + expect(note_metadata.email_participant.length).to eq(255) + expect(note.note_metadata.email_participant).to eq(expected_email) + end + end + + context 'when email is within permissible length' do + let(:email) { 'email@example.com' } + + it 'saves the email as-is' do + expect(note_metadata.email_participant).to eq(email) + end + end + end + end +end diff --git a/spec/models/oauth_access_token_spec.rb b/spec/models/oauth_access_token_spec.rb index fc53d926dd6..5fa590eab58 100644 --- a/spec/models/oauth_access_token_spec.rb +++ b/spec/models/oauth_access_token_spec.rb @@ -59,7 +59,7 @@ RSpec.describe OauthAccessToken do it 'uses the expires_in value' do token = OauthAccessToken.new(expires_in: 1.minute) - expect(token.expires_in).to eq 1.minute + expect(token).to be_valid end end @@ -67,7 +67,7 @@ RSpec.describe OauthAccessToken do it 'uses default value' do token = OauthAccessToken.new(expires_in: nil) - expect(token.expires_in).to eq 2.hours + expect(token).to be_invalid end end end diff --git a/spec/models/onboarding/completion_spec.rb b/spec/models/onboarding/completion_spec.rb index e1fad4255bc..dd7648f7799 100644 --- a/spec/models/onboarding/completion_spec.rb +++ b/spec/models/onboarding/completion_spec.rb @@ -2,24 +2,24 @@ require 'spec_helper' -RSpec.describe Onboarding::Completion do +RSpec.describe Onboarding::Completion, feature_category: :onboarding do + let(:completed_actions) { {} } + let(:project) { build(:project, namespace: namespace) } + let!(:onboarding_progress) { create(:onboarding_progress, namespace: namespace, **completed_actions) } + + let_it_be(:namespace) { create(:namespace) } + describe '#percentage' do - let(:completed_actions) { {} } - let!(:onboarding_progress) { create(:onboarding_progress, namespace: namespace, **completed_actions) } let(:tracked_action_columns) do - [ - *described_class::ACTION_ISSUE_IDS.keys, - *described_class::ACTION_PATHS, - :security_scan_enabled - ].map { |key| ::Onboarding::Progress.column_name(key) } + [*described_class::ACTION_PATHS, :security_scan_enabled].map do |key| + ::Onboarding::Progress.column_name(key) + end end - let_it_be(:namespace) { create(:namespace) } - - subject { described_class.new(namespace).percentage } + subject(:percentage) { described_class.new(project).percentage } context 'when no onboarding_progress exists' do - subject { described_class.new(build(:namespace)).percentage } + subject(:percentage) { described_class.new(build(:project)).percentage } it { is_expected.to eq(0) } end @@ -29,30 +29,55 @@ RSpec.describe Onboarding::Completion do end context 'when all tracked actions have been completed' do + let(:project) { build(:project, :stubbed_commit_count, namespace: namespace) } + let(:completed_actions) do tracked_action_columns.index_with { Time.current } end it { is_expected.to eq(100) } end + end + + describe '#completed?' do + subject(:completed?) { described_class.new(project).completed?(column) } + + context 'when code_added' do + let(:column) { :code_added } + + context 'when commit_count > 1' do + let(:project) { build(:project, :stubbed_commit_count, namespace: namespace) } + + it { is_expected.to eq(true) } + end + + context 'when branch_count > 1' do + let(:project) { build(:project, :stubbed_branch_count, namespace: namespace) } + + it { is_expected.to eq(true) } + end + + context 'when empty repository' do + let(:project) { build(:project, namespace: namespace) } + + it { is_expected.to eq(false) } + end + end - context 'with security_actions_continuous_onboarding experiment' do - let(:completed_actions) { Hash[tracked_action_columns.first, Time.current] } + context 'when secure_dast_run' do + let(:column) { :secure_dast_run_at } + let(:completed_actions) { { secure_dast_run_at: secure_dast_run_at } } - context 'when control' do - before do - stub_experiments(security_actions_continuous_onboarding: :control) - end + context 'when is completed' do + let(:secure_dast_run_at) { Time.current } - it { is_expected.to eq(11) } + it { is_expected.to eq(true) } end - context 'when candidate' do - before do - stub_experiments(security_actions_continuous_onboarding: :candidate) - end + context 'when is not completed' do + let(:secure_dast_run_at) { nil } - it { is_expected.to eq(9) } + it { is_expected.to eq(false) } end end end diff --git a/spec/models/onboarding/progress_spec.rb b/spec/models/onboarding/progress_spec.rb index 9d91af2487a..7d169464462 100644 --- a/spec/models/onboarding/progress_spec.rb +++ b/spec/models/onboarding/progress_spec.rb @@ -187,7 +187,7 @@ RSpec.describe Onboarding::Progress do end context 'for multiple actions' do - let(:action1) { :security_scan_enabled } + let(:action1) { :secure_dast_run } let(:action2) { :secure_dependency_scanning_run } let(:actions) { [action1, action2] } @@ -206,11 +206,11 @@ RSpec.describe Onboarding::Progress do it 'does not override timestamp', :aggregate_failures do described_class.register(namespace, [action1]) - expect(described_class.find_by_namespace_id(namespace.id).security_scan_enabled_at).not_to be_nil + expect(described_class.find_by_namespace_id(namespace.id).secure_dast_run_at).not_to be_nil expect(described_class.find_by_namespace_id(namespace.id).secure_dependency_scanning_run_at).to be_nil expect { described_class.register(namespace, [action1, action2]) }.not_to change { - described_class.find_by_namespace_id(namespace.id).security_scan_enabled_at + described_class.find_by_namespace_id(namespace.id).secure_dast_run_at } expect(described_class.find_by_namespace_id(namespace.id).secure_dependency_scanning_run_at).not_to be_nil end diff --git a/spec/models/operations/feature_flag_spec.rb b/spec/models/operations/feature_flag_spec.rb index dd1ff95a16d..4a39c5dc234 100644 --- a/spec/models/operations/feature_flag_spec.rb +++ b/spec/models/operations/feature_flag_spec.rb @@ -37,8 +37,8 @@ RSpec.describe Operations::FeatureFlag do end describe '#to_reference' do - let(:namespace) { build(:namespace, path: 'sample-namespace') } - let(:project) { build(:project, name: 'sample-project', namespace: namespace) } + let(:namespace) { build(:namespace) } + let(:project) { build(:project, namespace: namespace) } let(:feature_flag) { build(:operations_feature_flag, iid: 1, project: project) } it 'returns feature flag id' do @@ -46,7 +46,7 @@ RSpec.describe Operations::FeatureFlag do end it 'returns complete path to the feature flag with full: true' do - expect(feature_flag.to_reference(full: true)).to eq '[feature_flag:sample-namespace/sample-project/1]' + expect(feature_flag.to_reference(full: true)).to eq "[feature_flag:#{project.full_path}/1]" end end diff --git a/spec/models/organization_spec.rb b/spec/models/organization_spec.rb new file mode 100644 index 00000000000..e1aac88e640 --- /dev/null +++ b/spec/models/organization_spec.rb @@ -0,0 +1,98 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Organization, type: :model, feature_category: :cell do + let_it_be(:organization) { create(:organization) } + let_it_be(:default_organization) { create(:organization, :default) } + + describe 'validations' do + subject { create(:organization) } + + it { is_expected.to validate_presence_of(:name) } + it { is_expected.to validate_uniqueness_of(:name).case_insensitive } + it { is_expected.to validate_length_of(:name).is_at_most(255) } + end + + context 'when using scopes' do + describe '.without_default' do + it 'excludes default organization' do + expect(described_class.without_default).not_to include(default_organization) + end + + it 'includes other organizations organization' do + expect(described_class.without_default).to include(organization) + end + end + end + + describe '#id' do + context 'when organization is default' do + it 'has id 1' do + expect(default_organization.id).to eq(1) + end + end + + context 'when organization is not default' do + it 'does not have id 1' do + expect(organization.id).not_to eq(1) + end + end + end + + describe '#destroy!' do + context 'when trying to delete the default organization' do + it 'raises an error' do + expect do + default_organization.destroy! + end.to raise_error(ActiveRecord::RecordNotDestroyed, _('Cannot delete the default organization')) + end + end + + context 'when trying to delete a non-default organization' do + let(:to_be_removed) { create(:organization) } + + it 'does not raise error' do + expect { to_be_removed.destroy! }.not_to raise_error + end + end + end + + describe '#destroy' do + context 'when trying to delete the default organization' do + it 'returns false' do + expect(default_organization.destroy).to eq(false) + end + end + + context 'when trying to delete a non-default organization' do + let(:to_be_removed) { create(:organization) } + + it 'returns true' do + expect(to_be_removed.destroy).to eq(to_be_removed) + end + end + end + + describe '#default?' do + context 'when organization is default' do + it 'returns true' do + expect(default_organization.default?).to eq(true) + end + end + + context 'when organization is not default' do + it 'returns false' do + expect(organization.default?).to eq(false) + end + end + end + + describe '#name' do + context 'when organization is default' do + it 'returns Default' do + expect(default_organization.name).to eq('Default') + end + end + end +end diff --git a/spec/models/packages/debian/file_metadatum_spec.rb b/spec/models/packages/debian/file_metadatum_spec.rb index 1215adfa6a1..e86c0a71c9a 100644 --- a/spec/models/packages/debian/file_metadatum_spec.rb +++ b/spec/models/packages/debian/file_metadatum_spec.rb @@ -2,15 +2,15 @@ require 'spec_helper' -RSpec.describe Packages::Debian::FileMetadatum, type: :model do - RSpec.shared_context 'Debian file metadatum' do |factory, trait| - let_it_be_with_reload(:debian_package_file) { create(factory, trait) } +RSpec.describe Packages::Debian::FileMetadatum, type: :model, feature_category: :package_registry do + RSpec.shared_context 'with Debian file metadatum' do |package_file_trait| + let_it_be_with_reload(:debian_package_file) { create(:debian_package_file, package_file_trait) } let(:debian_file_metadatum) { debian_package_file.debian_file_metadatum } subject { debian_file_metadatum } end - RSpec.shared_examples 'Test Debian file metadatum' do |has_component, has_architecture, has_fields, has_outdated| + RSpec.shared_examples 'Test Debian file metadatum' do |has_component, has_architecture, has_fields| describe 'relationships' do it { is_expected.to belong_to(:package_file) } end @@ -51,8 +51,8 @@ RSpec.describe Packages::Debian::FileMetadatum, type: :model do describe '#fields' do if has_fields it { is_expected.to validate_presence_of(:fields) } - it { is_expected.to allow_value({ 'a': 'b' }).for(:fields) } - it { is_expected.not_to allow_value({ 'a': { 'b': 'c' } }).for(:fields) } + it { is_expected.to allow_value({ a: 'b' }).for(:fields) } + it { is_expected.not_to allow_value({ a: { b: 'c' } }).for(:fields) } else it { is_expected.to validate_absence_of(:fields) } end @@ -69,23 +69,35 @@ RSpec.describe Packages::Debian::FileMetadatum, type: :model do end end end + + describe 'scopes' do + describe '.with_file_type' do + subject { described_class.with_file_type(package_file_trait) } + + it 'returns the matching file metadatum' do + expect(subject).to match_array([debian_file_metadatum]) + end + end + end end using RSpec::Parameterized::TableSyntax - where(:factory, :trait, :has_component, :has_architecture, :has_fields) do - :debian_package_file | :unknown | false | false | false - :debian_package_file | :source | true | false | false - :debian_package_file | :dsc | true | false | true - :debian_package_file | :deb | true | true | true - :debian_package_file | :udeb | true | true | true - :debian_package_file | :buildinfo | true | false | true - :debian_package_file | :changes | false | false | true + where(:package_file_trait, :has_component, :has_architecture, :has_fields) do + :unknown | false | false | false + :source | true | false | false + :dsc | true | false | true + :deb | true | true | true + :udeb | true | true | true + :ddeb | true | true | true + :buildinfo | true | false | true + :changes | false | false | true end with_them do - include_context 'Debian file metadatum', params[:factory], params[:trait] do - it_behaves_like 'Test Debian file metadatum', params[:has_component], params[:has_architecture], params[:has_fields], params[:has_outdated] + include_context 'with Debian file metadatum', params[:package_file_trait] do + it_behaves_like 'Test Debian file metadatum', + params[:has_component], params[:has_architecture], params[:has_fields] end end end diff --git a/spec/models/packages/debian/group_distribution_spec.rb b/spec/models/packages/debian/group_distribution_spec.rb index 90fb0d0e7d8..1af23ad3ac0 100644 --- a/spec/models/packages/debian/group_distribution_spec.rb +++ b/spec/models/packages/debian/group_distribution_spec.rb @@ -2,6 +2,9 @@ require 'spec_helper' -RSpec.describe Packages::Debian::GroupDistribution do - it_behaves_like 'Debian Distribution', :debian_group_distribution, :group, false +RSpec.describe Packages::Debian::GroupDistribution, feature_category: :package_registry do + include_context 'for Debian Distribution', :debian_group_distribution, false + + it_behaves_like 'Debian Distribution for common behavior' + it_behaves_like 'Debian Distribution with group container' end diff --git a/spec/models/packages/debian/project_distribution_spec.rb b/spec/models/packages/debian/project_distribution_spec.rb index 5f4041ad9fe..2b79cdb539f 100644 --- a/spec/models/packages/debian/project_distribution_spec.rb +++ b/spec/models/packages/debian/project_distribution_spec.rb @@ -2,6 +2,9 @@ require 'spec_helper' -RSpec.describe Packages::Debian::ProjectDistribution do - it_behaves_like 'Debian Distribution', :debian_project_distribution, :project, true +RSpec.describe Packages::Debian::ProjectDistribution, feature_category: :package_registry do + include_context 'for Debian Distribution', :debian_project_distribution, true + + it_behaves_like 'Debian Distribution for common behavior' + it_behaves_like 'Debian Distribution with project container' end diff --git a/spec/models/packages/dependency_spec.rb b/spec/models/packages/dependency_spec.rb index 1575dec98c9..80ec7f77fda 100644 --- a/spec/models/packages/dependency_spec.rb +++ b/spec/models/packages/dependency_spec.rb @@ -1,7 +1,11 @@ # frozen_string_literal: true require 'spec_helper' -RSpec.describe Packages::Dependency, type: :model do +RSpec.describe Packages::Dependency, type: :model, feature_category: :package_registry do + describe 'included modules' do + it { is_expected.to include_module(EachBatch) } + end + describe 'relationships' do it { is_expected.to have_many(:dependency_links) } end @@ -110,6 +114,19 @@ RSpec.describe Packages::Dependency, type: :model do end end + describe '.orphaned' do + let_it_be(:orphaned_dependencies) { create_list(:packages_dependency, 2) } + let_it_be(:linked_dependency) do + create(:packages_dependency).tap do |dependency| + create(:packages_dependency_link, dependency: dependency) + end + end + + it 'returns orphaned dependency records' do + expect(described_class.orphaned).to contain_exactly(*orphaned_dependencies) + end + end + def build_names_and_version_patterns(*package_dependencies) result = Hash.new { |h, dependency| h[dependency.name] = dependency.version_pattern } package_dependencies.each { |dependency| result[dependency] } diff --git a/spec/models/packages/event_spec.rb b/spec/models/packages/event_spec.rb new file mode 100644 index 00000000000..58c1c1e6e92 --- /dev/null +++ b/spec/models/packages/event_spec.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Packages::Event, feature_category: :package_registry do + let(:event_type) { :push_package } + let(:event_scope) { :npm } + let(:originator_type) { :deploy_token } + + shared_examples 'handle forbidden event type' do |result: []| + let(:event_type) { :search } + + it { is_expected.to eq(result) } + end + + describe '.event_allowed?' do + subject { described_class.event_allowed?(event_type) } + + it { is_expected.to eq(true) } + + it_behaves_like 'handle forbidden event type', result: false + end + + describe '.unique_counters_for' do + subject { described_class.unique_counters_for(event_scope, event_type, originator_type) } + + it { is_expected.to contain_exactly('i_package_npm_deploy_token') } + + it_behaves_like 'handle forbidden event type' + + context 'when an originator type is quest' do + let(:originator_type) { :guest } + + it { is_expected.to eq([]) } + end + end + + describe '.counters_for' do + subject { described_class.counters_for(event_scope, event_type, originator_type) } + + it do + is_expected.to contain_exactly( + 'i_package_push_package', + 'i_package_push_package_by_deploy_token', + 'i_package_npm_push_package' + ) + end + + it_behaves_like 'handle forbidden event type' + end +end diff --git a/spec/models/packages/npm/metadata_cache_spec.rb b/spec/models/packages/npm/metadata_cache_spec.rb new file mode 100644 index 00000000000..5e7a710baf8 --- /dev/null +++ b/spec/models/packages/npm/metadata_cache_spec.rb @@ -0,0 +1,150 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Packages::Npm::MetadataCache, type: :model, feature_category: :package_registry do + let_it_be(:project) { create(:project) } + let_it_be(:package_name) { '@root/test' } + + it { is_expected.to be_a FileStoreMounter } + + describe 'relationships' do + it { is_expected.to belong_to(:project).inverse_of(:npm_metadata_caches) } + end + + describe 'validations' do + it { is_expected.to validate_presence_of(:file) } + it { is_expected.to validate_presence_of(:project) } + it { is_expected.to validate_presence_of(:size) } + + describe '#package_name' do + let_it_be(:npm_metadata_cache) { create(:npm_metadata_cache, package_name: package_name, project: project) } + + it { is_expected.to validate_presence_of(:package_name) } + + describe 'uniqueness' do + it 'ensures the package name is unique within a given project' do + expect do + create(:npm_metadata_cache, package_name: package_name, project: project) + end.to raise_error(ActiveRecord::RecordInvalid, 'Validation failed: Package name has already been taken') + end + + it 'allows duplicate file names in different projects' do + expect do + create(:npm_metadata_cache, package_name: package_name, project: create(:project)) + end.not_to raise_error + end + end + + describe 'format' do + it { is_expected.to allow_value('my.app-11.07.2018').for(:package_name) } + it { is_expected.to allow_value('@group-1/package').for(:package_name) } + it { is_expected.to allow_value('@any-scope/package').for(:package_name) } + it { is_expected.to allow_value('unscoped-package').for(:package_name) } + + it { is_expected.not_to allow_value('my(dom$$$ain)com.my-app').for(:package_name) } + it { is_expected.not_to allow_value('@inv@lid-scope/package').for(:package_name) } + it { is_expected.not_to allow_value('@scope/../../package').for(:package_name) } + it { is_expected.not_to allow_value('@scope%2e%2e%fpackage').for(:package_name) } + it { is_expected.not_to allow_value('@scope/sub/package').for(:package_name) } + end + end + end + + describe '.find_or_build' do + subject { described_class.find_or_build(package_name: package_name, project_id: project.id) } + + context 'when a metadata cache exists' do + let_it_be(:npm_metadata_cache) { create(:npm_metadata_cache, package_name: package_name, project: project) } + + it 'finds an existing metadata cache' do + expect(subject).to eq(npm_metadata_cache) + end + end + + context 'when a metadata cache not found' do + let(:package_name) { 'not_existing' } + + it 'builds a new instance', :aggregate_failures do + expect(subject).not_to be_persisted + expect(subject.package_name).to eq(package_name) + expect(subject.project_id).to eq(project.id) + end + end + end + + describe 'save callbacks' do + describe 'object_storage_key' do + let(:object_storage_key) do + Gitlab::HashedPath.new( + 'packages', 'metadata_caches', 'npm', OpenSSL::Digest::SHA256.hexdigest(package_name), + root_hash: project.id + ) + end + + before do + allow(Gitlab::HashedPath).to receive(:new).and_return(object_storage_key) + end + + context 'when the record is created' do + let(:npm_metadata_cache) { build(:npm_metadata_cache, package_name: package_name, project: project) } + + it 'sets object_storage_key' do + npm_metadata_cache.save! + + expect(npm_metadata_cache.object_storage_key).to eq(object_storage_key.to_s) + end + + context 'when using `update!`' do + let(:metadata_content) { {}.to_json } + + it 'sets object_storage_key' do + npm_metadata_cache.update!( + file: CarrierWaveStringFile.new(metadata_content), + size: metadata_content.bytesize + ) + + expect(npm_metadata_cache.object_storage_key).to eq(object_storage_key.to_s) + end + end + end + + context 'when the record is updated' do + let_it_be(:npm_metadata_cache) { create(:npm_metadata_cache, package_name: package_name, project: project) } + + let(:existing_object_storage_key) { npm_metadata_cache.object_storage_key } + let(:new_package_name) { 'updated_package_name' } + + it 'does not update object_storage_key' do + existing_object_storage_key = npm_metadata_cache.object_storage_key + + npm_metadata_cache.update!(package_name: new_package_name) + + expect(npm_metadata_cache.object_storage_key).to eq(existing_object_storage_key) + end + end + end + end + + describe 'readonly attributes' do + describe 'object_storage_key' do + let_it_be(:npm_metadata_cache) { create(:npm_metadata_cache) } + + it 'sets object_storage_key' do + expect(npm_metadata_cache.object_storage_key).to be_present + end + + context 'when the record is persisted' do + let(:new_object_storage_key) { 'object/storage/updated_key' } + + it 'does not re-set object_storage_key' do + npm_metadata_cache.object_storage_key = new_object_storage_key + + npm_metadata_cache.save! + + expect(npm_metadata_cache.object_storage_key).not_to eq(new_object_storage_key) + end + end + end + end +end diff --git a/spec/models/packages/npm/metadatum_spec.rb b/spec/models/packages/npm/metadatum_spec.rb index ff8cce5310e..92daddded7e 100644 --- a/spec/models/packages/npm/metadatum_spec.rb +++ b/spec/models/packages/npm/metadatum_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe Packages::Npm::Metadatum, type: :model do +RSpec.describe Packages::Npm::Metadatum, type: :model, feature_category: :package_registry do describe 'relationships' do it { is_expected.to belong_to(:package).inverse_of(:npm_metadatum) } end @@ -47,4 +47,16 @@ RSpec.describe Packages::Npm::Metadatum, type: :model do end end end + + describe 'scopes' do + describe '.package_id_in' do + let_it_be(:package) { create(:npm_package) } + let_it_be(:metadatum_1) { create(:npm_metadatum, package: package) } + let_it_be(:metadatum_2) { create(:npm_metadatum) } + + it 'returns metadatums with the given package ids' do + expect(described_class.package_id_in([package.id])).to contain_exactly(metadatum_1) + end + end + end end diff --git a/spec/models/packages/package_file_spec.rb b/spec/models/packages/package_file_spec.rb index 9b341034aaa..c9db1efc64a 100644 --- a/spec/models/packages/package_file_spec.rb +++ b/spec/models/packages/package_file_spec.rb @@ -153,6 +153,7 @@ RSpec.describe Packages::PackageFile, type: :model do let_it_be(:debian_changes) { debian_package.package_files.last } let_it_be(:debian_deb) { create(:debian_package_file, package: debian_package) } let_it_be(:debian_udeb) { create(:debian_package_file, :udeb, package: debian_package) } + let_it_be(:debian_ddeb) { create(:debian_package_file, :ddeb, package: debian_package) } let_it_be(:debian_contrib) do create(:debian_package_file, package: debian_package).tap do |pf| @@ -177,6 +178,17 @@ RSpec.describe Packages::PackageFile, type: :model do describe '#with_debian_architecture_name' do it { expect(described_class.with_debian_architecture_name('mipsel')).to contain_exactly(debian_mipsel) } end + + describe '#with_debian_unknown_since' do + let_it_be(:incoming) { create(:debian_incoming, project: project) } + + before do + incoming.package_files.first.debian_file_metadatum.update! updated_at: 1.day.ago + incoming.package_files.second.update! updated_at: 1.day.ago, status: :error + end + + it { expect(described_class.with_debian_unknown_since(1.hour.ago)).to contain_exactly(incoming.package_files.first) } + end end describe '.for_helm_with_channel' do diff --git a/spec/models/packages/package_spec.rb b/spec/models/packages/package_spec.rb index 992cc5c4354..e79459e0c7c 100644 --- a/spec/models/packages/package_spec.rb +++ b/spec/models/packages/package_spec.rb @@ -682,24 +682,20 @@ RSpec.describe Packages::Package, type: :model, feature_category: :package_regis end end - describe "#unique_debian_package_name" do + describe "uniqueness for package type debian" do let!(:package) { create(:debian_package) } - it "will allow a Debian package with same project, name and version, but different distribution" do - new_package = build(:debian_package, project: package.project, name: package.name, version: package.version) - expect(new_package).to be_valid - end - it "will not allow a Debian package with same project, name, version and distribution" do new_package = build(:debian_package, project: package.project, name: package.name, version: package.version) new_package.debian_publication.distribution = package.debian_publication.distribution expect(new_package).not_to be_valid - expect(new_package.errors.to_a).to include('Debian package already exists in Distribution') + expect(new_package.errors.to_a).to include('Name has already been taken') end - it "will allow a Debian package with same project, name, version, but no distribution" do + it "will not allow a Debian package with same project, name, version, but no distribution" do new_package = build(:debian_package, project: package.project, name: package.name, version: package.version, published_in: nil) - expect(new_package).to be_valid + expect(new_package).not_to be_valid + expect(new_package.errors.to_a).to include('Name has already been taken') end context 'with pending_destruction package' do @@ -713,7 +709,7 @@ RSpec.describe Packages::Package, type: :model, feature_category: :package_regis end end - Packages::Package.package_types.keys.without('conan', 'debian').each do |pt| + Packages::Package.package_types.keys.without('conan').each do |pt| context "project id, name, version and package type uniqueness for package type #{pt}" do let(:package) { create("#{pt}_package") } @@ -722,6 +718,15 @@ RSpec.describe Packages::Package, type: :model, feature_category: :package_regis expect(new_package).not_to be_valid expect(new_package.errors.to_a).to include("Name has already been taken") end + + context 'with pending_destruction package' do + let!(:package) { create("#{pt}_package", :pending_destruction) } + + it "will allow a #{pt} package with same project, name, version and package_type" do + new_package = build("#{pt}_package", project: package.project, name: package.name, version: package.version) + expect(new_package).to be_valid + end + end end end end @@ -842,6 +847,14 @@ RSpec.describe Packages::Package, type: :model, feature_category: :package_regis is_expected.to match_array([package]) end end + + describe '.preload_conan_metadatum' do + subject { described_class.preload_conan_metadatum } + + it 'loads conan metadatum' do + expect(subject.first.association(:conan_metadatum)).to be_loaded + end + end end describe '.without_nuget_temporary_name' do @@ -1235,8 +1248,18 @@ RSpec.describe Packages::Package, type: :model, feature_category: :package_regis let_it_be(:first_build_info) { create(:package_build_info, :with_pipeline, package: package) } let_it_be(:second_build_info) { create(:package_build_info, :with_pipeline, package: package) } - it 'returns the first build info' do - expect(package.original_build_info).to eq(first_build_info) + it 'returns the last build info' do + expect(package.original_build_info).to eq(second_build_info) + end + + context 'with packages_display_last_pipeline disabled' do + before do + stub_feature_flags(packages_display_last_pipeline: false) + end + + it 'returns the first build info' do + expect(package.original_build_info).to eq(first_build_info) + end end end end @@ -1402,4 +1425,36 @@ RSpec.describe Packages::Package, type: :model, feature_category: :package_regis .to change(package, :last_downloaded_at).from(nil).to(instance_of(ActiveSupport::TimeWithZone)) end end + + describe "#publish_creation_event" do + let_it_be(:project) { create(:project) } + + let(:version) { '-' } + let(:package_type) { :generic } + + subject { described_class.create!(project: project, name: 'incoming', version: version, package_type: package_type) } + + context 'when package is generic' do + it 'publishes an event' do + expect { subject } + .to publish_event(::Packages::PackageCreatedEvent) + .with({ + project_id: project.id, + id: kind_of(Numeric), + name: "incoming", + version: "-", + package_type: 'generic' + }) + end + end + + context 'when package is not generic' do + let(:package_type) { :debian } + let(:version) { 1 } + + it 'does not create event' do + expect { subject }.not_to publish_event(::Packages::PackageCreatedEvent) + end + end + end end diff --git a/spec/models/pages/lookup_path_spec.rb b/spec/models/pages/lookup_path_spec.rb index ef79ba28d5d..88fd1bd9e56 100644 --- a/spec/models/pages/lookup_path_spec.rb +++ b/spec/models/pages/lookup_path_spec.rb @@ -61,15 +61,13 @@ RSpec.describe Pages::LookupPath, feature_category: :pages do it 'uses deployment from object storage' do freeze_time do - expect(source).to( - eq({ - type: 'zip', - path: deployment.file.url(expire_at: 1.day.from_now), - global_id: "gid://gitlab/PagesDeployment/#{deployment.id}", - sha256: deployment.file_sha256, - file_size: deployment.size, - file_count: deployment.file_count - }) + expect(source).to eq( + type: 'zip', + path: deployment.file.url(expire_at: 1.day.from_now), + global_id: "gid://gitlab/PagesDeployment/#{deployment.id}", + sha256: deployment.file_sha256, + file_size: deployment.size, + file_count: deployment.file_count ) end end @@ -87,15 +85,13 @@ RSpec.describe Pages::LookupPath, feature_category: :pages do it 'uses file protocol' do freeze_time do - expect(source).to( - eq({ - type: 'zip', - path: 'file://' + deployment.file.path, - global_id: "gid://gitlab/PagesDeployment/#{deployment.id}", - sha256: deployment.file_sha256, - file_size: deployment.size, - file_count: deployment.file_count - }) + expect(source).to eq( + type: 'zip', + path: "file://#{deployment.file.path}", + global_id: "gid://gitlab/PagesDeployment/#{deployment.id}", + sha256: deployment.file_sha256, + file_size: deployment.size, + file_count: deployment.file_count ) end end @@ -108,15 +104,13 @@ RSpec.describe Pages::LookupPath, feature_category: :pages do it 'uses deployment from object storage' do freeze_time do - expect(source).to( - eq({ - type: 'zip', - path: deployment.file.url(expire_at: 1.day.from_now), - global_id: "gid://gitlab/PagesDeployment/#{deployment.id}", - sha256: deployment.file_sha256, - file_size: deployment.size, - file_count: deployment.file_count - }) + expect(source).to eq( + type: 'zip', + path: deployment.file.url(expire_at: 1.day.from_now), + global_id: "gid://gitlab/PagesDeployment/#{deployment.id}", + sha256: deployment.file_sha256, + file_size: deployment.size, + file_count: deployment.file_count ) end end @@ -143,4 +137,48 @@ RSpec.describe Pages::LookupPath, feature_category: :pages do expect(lookup_path.prefix).to eq('/myproject/') end end + + describe '#unique_host' do + let(:project) { build(:project) } + + context 'when unique domain is disabled' do + it 'returns nil' do + project.project_setting.pages_unique_domain_enabled = false + + expect(lookup_path.unique_host).to be_nil + end + end + + context 'when unique domain is enabled' do + it 'returns the project unique domain' do + project.project_setting.pages_unique_domain_enabled = true + project.project_setting.pages_unique_domain = 'unique-domain' + + expect(lookup_path.unique_host).to eq('unique-domain.example.com') + end + end + end + + describe '#root_directory' do + subject(:lookup_path) { described_class.new(project) } + + context 'when there is no deployment' do + it 'returns nil' do + expect(lookup_path.root_directory).to be_nil + end + end + + context 'when there is a deployment' do + let(:deployment) { create(:pages_deployment, project: project, root_directory: 'foo') } + + before do + project.mark_pages_as_deployed + project.pages_metadatum.update!(pages_deployment: deployment) + end + + it 'returns the deployment\'s root_directory' do + expect(lookup_path.root_directory).to eq('foo') + end + end + end end diff --git a/spec/models/pages_deployment_spec.rb b/spec/models/pages_deployment_spec.rb index 268c5006a88..767db511d85 100644 --- a/spec/models/pages_deployment_spec.rb +++ b/spec/models/pages_deployment_spec.rb @@ -59,6 +59,66 @@ RSpec.describe PagesDeployment, feature_category: :pages do end end + context 'when uploading the file' do + before do + stub_pages_object_storage(::Pages::DeploymentUploader) + end + + describe '#store_after_commit?' do + context 'when feature flag pages_deploy_upload_file_outside_transaction is disabled' do + it 'returns false' do + Feature.disable(:pages_deploy_upload_file_outside_transaction) + + deployment = create(:pages_deployment, project: project) + expect(deployment.store_after_commit?).to eq(false) + end + end + + context 'when feature flag pages_deploy_upload_file_outside_transaction is enabled' do + it 'returns true' do + deployment = create(:pages_deployment, project: project) + expect(deployment.store_after_commit?).to eq(true) + end + end + end + + context 'when feature flag pages_deploy_upload_file_outside_transaction is disabled' do + before do + Feature.disable(:pages_deploy_upload_file_outside_transaction) + end + + it 'stores the file within the transaction' do + expect_next_instance_of(PagesDeployment) do |deployment| + expect(deployment).not_to receive(:store_file_now!) + end + + create(:pages_deployment, project: project) + end + end + + context 'when feature flag pages_deploy_upload_file_outside_transaction is enabled' do + before do + Feature.enable(:pages_deploy_upload_file_outside_transaction) + end + + it 'stores the file outsize of the transaction' do + expect_next_instance_of(PagesDeployment) do |deployment| + expect(deployment).to receive(:store_file_now!) + end + + create(:pages_deployment, project: project) + end + + it 'does nothing when the file did not change' do + deployment = create(:pages_deployment, project: project) + + expect(deployment).not_to receive(:store_file_now!) + + deployment.touch + end + end + end + describe '#migrated?' do it 'returns false for normal deployment' do deployment = create(:pages_deployment) diff --git a/spec/models/pages_domain_spec.rb b/spec/models/pages_domain_spec.rb index f054fde78e7..b218d4dce09 100644 --- a/spec/models/pages_domain_spec.rb +++ b/spec/models/pages_domain_spec.rb @@ -9,7 +9,6 @@ RSpec.describe PagesDomain do describe 'associations' do it { is_expected.to belong_to(:project) } - it { is_expected.to have_many(:serverless_domain_clusters) } end describe '.for_project' do @@ -546,44 +545,6 @@ RSpec.describe PagesDomain do end end - describe '#pages_virtual_domain' do - let(:project) { create(:project) } - let(:pages_domain) { create(:pages_domain, project: project) } - - context 'when there are no pages deployed for the project' do - it 'returns nil' do - expect(pages_domain.pages_virtual_domain).to be_nil - end - end - - context 'when there are pages deployed for the project' do - let(:virtual_domain) { pages_domain.pages_virtual_domain } - - before do - project.mark_pages_as_deployed - project.update_pages_deployment!(create(:pages_deployment, project: project)) - end - - it 'returns the virual domain when there are pages deployed for the project' do - expect(virtual_domain).to be_an_instance_of(Pages::VirtualDomain) - expect(virtual_domain.lookup_paths).not_to be_empty - expect(virtual_domain.cache_key).to match(/pages_domain_for_domain_#{pages_domain.id}_/) - end - - context 'when :cache_pages_domain_api is disabled' do - before do - stub_feature_flags(cache_pages_domain_api: false) - end - - it 'returns the virual domain when there are pages deployed for the project' do - expect(virtual_domain).to be_an_instance_of(Pages::VirtualDomain) - expect(virtual_domain.lookup_paths).not_to be_empty - expect(virtual_domain.cache_key).to be_nil - end - end - end - end - describe '#validate_custom_domain_count_per_project' do let_it_be(:project) { create(:project) } diff --git a/spec/models/personal_access_token_spec.rb b/spec/models/personal_access_token_spec.rb index 2320ff669d0..bd6a7c156c4 100644 --- a/spec/models/personal_access_token_spec.rb +++ b/spec/models/personal_access_token_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe PersonalAccessToken, feature_category: :authentication_and_authorization do +RSpec.describe PersonalAccessToken, feature_category: :system_access do subject { described_class } describe '.build' do @@ -267,6 +267,41 @@ RSpec.describe PersonalAccessToken, feature_category: :authentication_and_author expect(personal_access_token).not_to be_valid expect(personal_access_token.errors[:scopes].first).to eq "can only contain available scopes" end + + context 'validates expires_at' do + let(:max_expiration_date) { described_class::MAX_PERSONAL_ACCESS_TOKEN_LIFETIME_IN_DAYS.days.from_now } + + context 'when default_pat_expiration feature flag is true' do + context 'when expires_in is less than MAX_PERSONAL_ACCESS_TOKEN_LIFETIME_IN_DAYS days' do + it 'is valid' do + personal_access_token.expires_at = max_expiration_date - 1.day + + expect(personal_access_token).to be_valid + end + end + + context 'when expires_in is more than MAX_PERSONAL_ACCESS_TOKEN_LIFETIME_IN_DAYS days' do + it 'is invalid' do + personal_access_token.expires_at = max_expiration_date + 1.day + + expect(personal_access_token).not_to be_valid + expect(personal_access_token.errors[:expires_at].first).to eq('must expire in 365 days') + end + end + end + + context 'when default_pat_expiration feature flag is false' do + before do + stub_feature_flags(default_pat_expiration: false) + end + + it 'allows any expires_at value' do + personal_access_token.expires_at = max_expiration_date + 1.day + + expect(personal_access_token).to be_valid + end + end + end end describe 'scopes' do @@ -289,7 +324,7 @@ RSpec.describe PersonalAccessToken, feature_category: :authentication_and_author let_it_be(:revoked_token) { create(:personal_access_token, revoked: true) } let_it_be(:valid_token_and_notified) { create(:personal_access_token, expires_at: 2.days.from_now, expire_notification_delivered: true) } let_it_be(:valid_token) { create(:personal_access_token, expires_at: 2.days.from_now) } - let_it_be(:long_expiry_token) { create(:personal_access_token, expires_at: '999999-12-31'.to_date) } + let_it_be(:long_expiry_token) { create(:personal_access_token, expires_at: described_class::MAX_PERSONAL_ACCESS_TOKEN_LIFETIME_IN_DAYS.days.from_now) } context 'in one day' do it "doesn't have any tokens" do @@ -405,4 +440,58 @@ RSpec.describe PersonalAccessToken, feature_category: :authentication_and_author end end end + + describe 'token format' do + let(:personal_access_token) { described_class.new } + + it 'generates a token' do + expect { personal_access_token.ensure_token } + .to change { personal_access_token.token }.from(nil).to(a_string_starting_with(described_class.token_prefix)) + end + + context 'when there is an existing token' do + let(:token) { 'an_existing_secret_token' } + + before do + personal_access_token.set_token(token) + end + + it 'does not change the existing token' do + expect { personal_access_token.ensure_token } + .not_to change { personal_access_token.token }.from(token) + end + end + end + + describe '#expires_at=' do + let(:personal_access_token) { described_class.new } + + context 'when default_pat_expiration feature flag is true' do + context 'expires_at set to empty value' do + [nil, ""].each do |expires_in_value| + it 'defaults to PersonalAccessToken::MAX_PERSONAL_ACCESS_TOKEN_LIFETIME_IN_DAYS' do + personal_access_token.expires_at = expires_in_value + + freeze_time do + expect(personal_access_token.expires_at).to eq( + PersonalAccessToken::MAX_PERSONAL_ACCESS_TOKEN_LIFETIME_IN_DAYS.days.from_now.to_date + ) + end + end + end + end + end + + context 'when default_pat_expiration feature flag is false' do + before do + stub_feature_flags(default_pat_expiration: false) + end + + it 'does not set a default' do + personal_access_token.expires_at = nil + + expect(personal_access_token.expires_at).to eq(nil) + end + end + end end diff --git a/spec/models/plan_limits_spec.rb b/spec/models/plan_limits_spec.rb index 3705cab7ef5..962bb21d761 100644 --- a/spec/models/plan_limits_spec.rb +++ b/spec/models/plan_limits_spec.rb @@ -206,6 +206,8 @@ RSpec.describe PlanLimits do ] end + # Remove ci_active_pipelines when db column is removed + # https://gitlab.com/gitlab-org/gitlab/-/issues/408141 let(:columns_with_zero) do %w[ ci_active_pipelines @@ -221,6 +223,7 @@ RSpec.describe PlanLimits do security_policy_scan_execution_schedules enforcement_limit notification_limit + project_access_token_limit ] + disabled_max_artifact_size_columns end diff --git a/spec/models/preloaders/labels_preloader_spec.rb b/spec/models/preloaders/labels_preloader_spec.rb index 07f148a0a6c..3d2a5edc8f0 100644 --- a/spec/models/preloaders/labels_preloader_spec.rb +++ b/spec/models/preloaders/labels_preloader_spec.rb @@ -18,14 +18,24 @@ RSpec.describe Preloaders::LabelsPreloader do context 'project labels' do let_it_be(:projects) { create_list(:project, 3, :public, :repository) } - let_it_be(:labels) { projects.each { |p| create(:label, project: p) } } + let_it_be(:labels) { projects.map { |p| create(:label, project: p) } } it_behaves_like 'an efficient database query' + + it 'preloads the max access level', :request_store do + labels_with_preloaded_data + + query_count = ActiveRecord::QueryRecorder.new do + projects.first.team.max_member_access_for_user_ids([user.id]) + end.count + + expect(query_count).to eq(0) + end end context 'group labels' do let_it_be(:groups) { create_list(:group, 3) } - let_it_be(:labels) { groups.each { |g| create(:group_label, group: g) } } + let_it_be(:labels) { groups.map { |g| create(:group_label, group: g) } } it_behaves_like 'an efficient database query' end diff --git a/spec/models/preloaders/runner_manager_policy_preloader_spec.rb b/spec/models/preloaders/runner_manager_policy_preloader_spec.rb new file mode 100644 index 00000000000..1977e2c5787 --- /dev/null +++ b/spec/models/preloaders/runner_manager_policy_preloader_spec.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Preloaders::RunnerManagerPolicyPreloader, feature_category: :runner_fleet do + let_it_be(:user) { create(:user) } + let_it_be(:runner1) { create(:ci_runner) } + let_it_be(:runner2) { create(:ci_runner) } + let_it_be(:runner_manager1) { create(:ci_runner_machine, runner: runner1) } + let_it_be(:runner_manager2) { create(:ci_runner_machine, runner: runner2) } + + let(:base_runner_managers) do + Project.where(id: [runner_manager1, runner_manager2]) + end + + it 'avoids N+1 queries when authorizing a list of runner managers', :request_store do + preload_runner_managers_for_policy(user) + control = ActiveRecord::QueryRecorder.new { authorize_all_runner_managers(user) } + + new_runner1 = create(:ci_runner) + new_runner2 = create(:ci_runner) + new_runner_manager1 = create(:ci_runner_machine, runner: new_runner1) + new_runner_manager2 = create(:ci_runner_machine, runner: new_runner2) + + pristine_runner_managers = Project.where(id: base_runner_managers + [new_runner_manager1, new_runner_manager2]) + + preload_runner_managers_for_policy(user, pristine_runner_managers) + expect { authorize_all_runner_managers(user, pristine_runner_managers) }.not_to exceed_query_limit(control) + end + + def authorize_all_runner_managers(current_user, runner_manager_list = base_runner_managers) + runner_manager_list.each { |runner_manager| current_user.can?(:read_runner_manager, runner_manager) } + end + + def preload_runner_managers_for_policy(current_user, runner_manager_list = base_runner_managers) + described_class.new(runner_manager_list, current_user).execute + end +end diff --git a/spec/models/preloaders/user_max_access_level_in_groups_preloader_spec.rb b/spec/models/preloaders/user_max_access_level_in_groups_preloader_spec.rb index 7d04817b621..3fba2ac003b 100644 --- a/spec/models/preloaders/user_max_access_level_in_groups_preloader_spec.rb +++ b/spec/models/preloaders/user_max_access_level_in_groups_preloader_spec.rb @@ -66,22 +66,6 @@ RSpec.describe Preloaders::UserMaxAccessLevelInGroupsPreloader do create(:group_group_link, :guest, shared_with_group: group1, shared_group: group4) end - context 'when `include_memberships_from_group_shares_in_preloader` feature flag is disabled' do - before do - stub_feature_flags(include_memberships_from_group_shares_in_preloader: false) - end - - it 'sets access_level to `NO_ACCESS` in cache for groups arising from group shares' do - described_class.new(groups, user).execute - - groups.each do |group| - cached_access_level = group.max_member_access_for_user(user) - - expect(cached_access_level).to eq(Gitlab::Access::NO_ACCESS) - end - end - end - it 'sets the right access level in cache for groups arising from group shares' do described_class.new(groups, user).execute diff --git a/spec/models/preloaders/users_max_access_level_by_project_preloader_spec.rb b/spec/models/preloaders/users_max_access_level_by_project_preloader_spec.rb new file mode 100644 index 00000000000..f5bc0c8c2f8 --- /dev/null +++ b/spec/models/preloaders/users_max_access_level_by_project_preloader_spec.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Preloaders::UsersMaxAccessLevelByProjectPreloader, feature_category: :projects do + let_it_be(:user_1) { create(:user) } + let_it_be(:user_2) { create(:user) } + let_it_be(:user_with_no_access) { create(:user) } # ensures we correctly cache NO_ACCESS + + let_it_be(:project_1) { create(:project) } + let_it_be(:project_2) { create(:project) } + let_it_be(:project_3) { create(:project) } + + before do + project_1.add_developer(user_1) + project_1.add_developer(user_2) + + project_2.add_developer(user_1) + project_2.add_developer(user_2) + + project_3.add_developer(user_1) + project_3.add_developer(user_2) + end + + describe '#execute', :request_store do + let(:project_users) do + { + project_1 => [user_1, user_with_no_access], + project_2 => user_2 + } + end + + it 'avoids N+1 queries' do + control_input = project_users + control = ActiveRecord::QueryRecorder.new do + described_class.new(project_users: control_input).execute + end + + sample_input = control_input.merge(project_3 => user_2) + sample = ActiveRecord::QueryRecorder.new do + described_class.new(project_users: sample_input).execute + end + + expect(sample).not_to exceed_query_limit(control) + end + + it 'preloads the max access level used by project policies' do + described_class.new(project_users: project_users).execute + + policy_queries = ActiveRecord::QueryRecorder.new do + project_users.each do |project, users| + Array.wrap(users).each do |user| + user.can?(:read_project, project) + end + end + end + + expect(policy_queries).not_to exceed_query_limit(0) + end + end +end diff --git a/spec/models/preloaders/users_max_access_level_in_projects_preloader_spec.rb b/spec/models/preloaders/users_max_access_level_in_projects_preloader_spec.rb deleted file mode 100644 index 7ecb6bb9861..00000000000 --- a/spec/models/preloaders/users_max_access_level_in_projects_preloader_spec.rb +++ /dev/null @@ -1,51 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -RSpec.describe Preloaders::UsersMaxAccessLevelInProjectsPreloader do - let_it_be(:user1) { create(:user) } - let_it_be(:user2) { create(:user) } - - let_it_be(:project_1) { create(:project) } - let_it_be(:project_2) { create(:project) } - let_it_be(:project_3) { create(:project) } - - let(:projects) { [project_1, project_2, project_3] } - let(:users) { [user1, user2] } - - before do - project_1.add_developer(user1) - project_1.add_developer(user2) - - project_2.add_developer(user1) - project_2.add_developer(user2) - - project_3.add_developer(user1) - project_3.add_developer(user2) - end - - context 'preload maximum access level to avoid querying project_authorizations', :request_store do - it 'avoids N+1 queries', :request_store do - Preloaders::UsersMaxAccessLevelInProjectsPreloader.new(projects: projects, users: users).execute - - expect(count_queries).to eq(0) - end - - it 'runs N queries without preloading' do - query_count_without_preload = count_queries - - Preloaders::UsersMaxAccessLevelInProjectsPreloader.new(projects: projects, users: users).execute - count_queries_with_preload = count_queries - - expect(count_queries_with_preload).to be < query_count_without_preload - end - end - - def count_queries - ActiveRecord::QueryRecorder.new do - projects.each do |project| - user1.can?(:read_project, project) - user2.can?(:read_project, project) - end - end.count - end -end diff --git a/spec/models/project_ci_cd_setting_spec.rb b/spec/models/project_ci_cd_setting_spec.rb index 2c490c33747..0a818147bfc 100644 --- a/spec/models/project_ci_cd_setting_spec.rb +++ b/spec/models/project_ci_cd_setting_spec.rb @@ -27,22 +27,8 @@ RSpec.describe ProjectCiCdSetting do end end - describe '#set_default_for_inbound_job_token_scope_enabled' do - context 'when feature flag ci_inbound_job_token_scope is enabled' do - before do - stub_feature_flags(ci_inbound_job_token_scope: true) - end - - it { is_expected.to be_inbound_job_token_scope_enabled } - end - - context 'when feature flag ci_inbound_job_token_scope is disabled' do - before do - stub_feature_flags(ci_inbound_job_token_scope: false) - end - - it { is_expected.not_to be_inbound_job_token_scope_enabled } - end + describe '#default_for_inbound_job_token_scope_enabled' do + it { is_expected.to be_inbound_job_token_scope_enabled } end describe '#default_git_depth' do diff --git a/spec/models/project_feature_spec.rb b/spec/models/project_feature_spec.rb index fe0b46c3117..87bfdd15773 100644 --- a/spec/models/project_feature_spec.rb +++ b/spec/models/project_feature_spec.rb @@ -288,7 +288,6 @@ RSpec.describe ProjectFeature, feature_category: :projects do end context 'sync packages_enabled' do - # rubocop:disable Lint/BinaryOperatorWithIdenticalOperands where(:initial_value, :new_value, :expected_result) do ProjectFeature::DISABLED | ProjectFeature::DISABLED | false ProjectFeature::DISABLED | ProjectFeature::ENABLED | true @@ -300,7 +299,6 @@ RSpec.describe ProjectFeature, feature_category: :projects do ProjectFeature::PUBLIC | ProjectFeature::ENABLED | true ProjectFeature::PUBLIC | ProjectFeature::PUBLIC | true end - # rubocop:enable Lint/BinaryOperatorWithIdenticalOperands with_them do it 'set correct value' do @@ -314,6 +312,40 @@ RSpec.describe ProjectFeature, feature_category: :projects do end end + describe '#public_packages?' do + let_it_be(:public_project) { create(:project, :public) } + + context 'with packages config enabled' do + context 'when project is private' do + it 'returns false' do + expect(project.project_feature.public_packages?).to eq(false) + end + + context 'with package_registry_access_level set to public' do + before do + project.project_feature.update!(package_registry_access_level: ProjectFeature::PUBLIC) + end + + it 'returns true' do + expect(project.project_feature.public_packages?).to eq(true) + end + end + end + + context 'when project is public' do + it 'returns true' do + expect(public_project.project_feature.public_packages?).to eq(true) + end + end + end + + it 'returns false if packages config is not enabled' do + stub_config(packages: { enabled: false }) + + expect(public_project.project_feature.public_packages?).to eq(false) + end + end + # rubocop:disable Gitlab/FeatureAvailableUsage describe '#feature_available?' do let(:features) { ProjectFeature::FEATURES } diff --git a/spec/models/project_label_spec.rb b/spec/models/project_label_spec.rb index f451c2905e6..ba9ea759c6a 100644 --- a/spec/models/project_label_spec.rb +++ b/spec/models/project_label_spec.rb @@ -119,4 +119,39 @@ RSpec.describe ProjectLabel do end end end + + describe '#preloaded_parent_container' do + let_it_be(:label) { create(:label) } + + before do + label.reload # ensure associations are not loaded + end + + context 'when project is loaded' do + it 'does not invoke a DB query' do + label.project + + count = ActiveRecord::QueryRecorder.new { label.preloaded_parent_container }.count + expect(count).to eq(0) + expect(label.preloaded_parent_container).to eq(label.project) + end + end + + context 'when parent_container is loaded' do + it 'does not invoke a DB query' do + label.parent_container + + count = ActiveRecord::QueryRecorder.new { label.preloaded_parent_container }.count + expect(count).to eq(0) + expect(label.preloaded_parent_container).to eq(label.parent_container) + end + end + + context 'when none of them are loaded' do + it 'invokes a DB query' do + count = ActiveRecord::QueryRecorder.new { label.preloaded_parent_container }.count + expect(count).to eq(1) + end + end + end end diff --git a/spec/models/project_setting_spec.rb b/spec/models/project_setting_spec.rb index feb5985818b..0a2ead0aa6b 100644 --- a/spec/models/project_setting_spec.rb +++ b/spec/models/project_setting_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe ProjectSetting, type: :model do +RSpec.describe ProjectSetting, type: :model, feature_category: :projects do using RSpec::Parameterized::TableSyntax it { is_expected.to belong_to(:project) } @@ -39,6 +39,44 @@ RSpec.describe ProjectSetting, type: :model do [nil, 'not_allowed', :invalid].each do |invalid_value| it { is_expected.not_to allow_value([invalid_value]).for(:target_platforms) } end + + context "when pages_unique_domain is required", feature_category: :pages do + it "is not required if pages_unique_domain_enabled is false" do + project_setting = build(:project_setting, pages_unique_domain_enabled: false) + + expect(project_setting).to be_valid + expect(project_setting.errors.full_messages).not_to include("Pages unique domain can't be blank") + end + + it "is required when pages_unique_domain_enabled is true" do + project_setting = build(:project_setting, pages_unique_domain_enabled: true) + + expect(project_setting).not_to be_valid + expect(project_setting.errors.full_messages).to include("Pages unique domain can't be blank") + end + + it "is required if it is already saved in the database" do + project_setting = create( + :project_setting, + pages_unique_domain: "random-unique-domain-here", + pages_unique_domain_enabled: true + ) + + project_setting.pages_unique_domain = nil + + expect(project_setting).not_to be_valid + expect(project_setting.errors.full_messages).to include("Pages unique domain can't be blank") + end + end + + it "validates uniqueness of pages_unique_domain", feature_category: :pages do + create(:project_setting, pages_unique_domain: "random-unique-domain-here") + + project_setting = build(:project_setting, pages_unique_domain: "random-unique-domain-here") + + expect(project_setting).not_to be_valid + expect(project_setting.errors.full_messages).to include("Pages unique domain has already been taken") + end end describe 'target_platforms=' do @@ -169,4 +207,34 @@ RSpec.describe ProjectSetting, type: :model do end end end + + describe '#runner_registration_enabled' do + let_it_be(:settings) { create(:project_setting) } + let_it_be(:group) { create(:group) } + let_it_be(:project) { create(:project, project_setting: settings, group: group) } + + it 'returns true' do + expect(project.runner_registration_enabled).to eq true + end + + context 'when project has runner registration disabled' do + before do + project.update!(runner_registration_enabled: false) + end + + it 'returns false' do + expect(project.runner_registration_enabled).to eq false + end + end + + context 'when all projects have runner registration disabled' do + before do + stub_application_setting(valid_runner_registrars: ['group']) + end + + it 'returns false' do + expect(project.runner_registration_enabled).to eq false + end + end + end end diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb index dfc8919e19d..e9bb01f4b23 100644 --- a/spec/models/project_spec.rb +++ b/spec/models/project_spec.rb @@ -14,6 +14,8 @@ RSpec.describe Project, factory_default: :keep, feature_category: :projects do it_behaves_like 'having unique enum values' + it_behaves_like 'ensures runners_token is prefixed', :project + describe 'associations' do it { is_expected.to belong_to(:group) } it { is_expected.to belong_to(:namespace) } @@ -25,6 +27,7 @@ RSpec.describe Project, factory_default: :keep, feature_category: :projects do it { is_expected.to have_many(:merge_requests) } it { is_expected.to have_many(:merge_request_metrics).class_name('MergeRequest::Metrics') } it { is_expected.to have_many(:issues) } + it { is_expected.to have_many(:work_items) } it { is_expected.to have_many(:incident_management_issuable_escalation_statuses).through(:issues).inverse_of(:project).class_name('IncidentManagement::IssuableEscalationStatus') } it { is_expected.to have_many(:milestones) } it { is_expected.to have_many(:project_members).dependent(:delete_all) } @@ -40,7 +43,9 @@ RSpec.describe Project, factory_default: :keep, feature_category: :projects do it { is_expected.to have_many(:protected_branches) } it { is_expected.to have_many(:exported_protected_branches) } it { is_expected.to have_one(:wiki_repository).class_name('Projects::WikiRepository').inverse_of(:project) } + it { is_expected.to have_one(:design_management_repository).class_name('DesignManagement::Repository').inverse_of(:project) } it { is_expected.to have_one(:slack_integration) } + it { is_expected.to have_one(:catalog_resource) } it { is_expected.to have_one(:microsoft_teams_integration) } it { is_expected.to have_one(:mattermost_integration) } it { is_expected.to have_one(:hangouts_chat_integration) } @@ -50,6 +55,7 @@ RSpec.describe Project, factory_default: :keep, feature_category: :projects do it { is_expected.to have_one(:packagist_integration) } it { is_expected.to have_one(:pushover_integration) } it { is_expected.to have_one(:apple_app_store_integration) } + it { is_expected.to have_one(:google_play_integration) } it { is_expected.to have_one(:asana_integration) } it { is_expected.to have_many(:boards) } it { is_expected.to have_one(:campfire_integration) } @@ -88,6 +94,8 @@ RSpec.describe Project, factory_default: :keep, feature_category: :projects do it { is_expected.to have_one(:alerting_setting).class_name('Alerting::ProjectAlertingSetting') } it { is_expected.to have_one(:mock_ci_integration) } it { is_expected.to have_one(:mock_monitoring_integration) } + it { is_expected.to have_one(:service_desk_custom_email_verification).class_name('ServiceDesk::CustomEmailVerification') } + it { is_expected.to have_one(:container_registry_data_repair_detail).class_name('ContainerRegistry::DataRepairDetail') } it { is_expected.to have_many(:commit_statuses) } it { is_expected.to have_many(:ci_pipelines) } it { is_expected.to have_many(:ci_refs) } @@ -135,7 +143,9 @@ RSpec.describe Project, factory_default: :keep, feature_category: :projects do it { is_expected.to have_many(:reviews).inverse_of(:project) } it { is_expected.to have_many(:packages).class_name('Packages::Package') } it { is_expected.to have_many(:package_files).class_name('Packages::PackageFile') } + it { is_expected.to have_many(:rpm_repository_files).class_name('Packages::Rpm::RepositoryFile').inverse_of(:project).dependent(:destroy) } it { is_expected.to have_many(:debian_distributions).class_name('Packages::Debian::ProjectDistribution').dependent(:destroy) } + it { is_expected.to have_many(:npm_metadata_caches).class_name('Packages::Npm::MetadataCache') } it { is_expected.to have_one(:packages_cleanup_policy).class_name('Packages::Cleanup::Policy').inverse_of(:project) } it { is_expected.to have_many(:pipeline_artifacts).dependent(:restrict_with_error) } it { is_expected.to have_many(:terraform_states).class_name('Terraform::State').inverse_of(:project) } @@ -780,7 +790,7 @@ RSpec.describe Project, factory_default: :keep, feature_category: :projects do let(:new_project) do build(:project, - name: project_pending_deletion.name, + path: project_pending_deletion.path, namespace: project_pending_deletion.namespace) end @@ -874,46 +884,6 @@ RSpec.describe Project, factory_default: :keep, feature_category: :projects do end end - describe '#has_packages?' do - let_it_be(:project) { create(:project, :public) } - - subject { project.has_packages?(package_type) } - - shared_examples 'returning true examples' do - let!(:package) { create("#{package_type}_package", project: project) } - - it { is_expected.to be true } - end - - shared_examples 'returning false examples' do - it { is_expected.to be false } - end - - context 'with maven packages' do - it_behaves_like 'returning true examples' do - let(:package_type) { :maven } - end - end - - context 'with npm packages' do - it_behaves_like 'returning true examples' do - let(:package_type) { :npm } - end - end - - context 'with conan packages' do - it_behaves_like 'returning true examples' do - let(:package_type) { :conan } - end - end - - context 'with no package type' do - it_behaves_like 'returning false examples' do - let(:package_type) { nil } - end - end - end - describe '#ci_pipelines' do let_it_be(:project) { create(:project) } @@ -939,6 +909,17 @@ RSpec.describe Project, factory_default: :keep, feature_category: :projects do end end + describe '#commit_notes' do + let_it_be(:project) { create(:project) } + + it "returns project's commit notes" do + note_1 = create(:note_on_commit, project: project, commit_id: 'commit_id_1') + note_2 = create(:note_on_commit, project: project, commit_id: 'commit_id_2') + + expect(project.commit_notes).to match_array([note_1, note_2]) + end + end + describe '#personal_namespace_holder?' do let_it_be(:group) { create(:group) } let_it_be(:namespace_user) { create(:user) } @@ -1115,7 +1096,6 @@ RSpec.describe Project, factory_default: :keep, feature_category: :projects do 'restrict_user_defined_variables' => '', 'runner_token_expiration_interval' => '', 'separated_caches' => 'ci_', - 'opt_in_jwt' => 'ci_', 'allow_fork_pipelines_to_run_in_parent_project' => 'ci_', 'inbound_job_token_scope_enabled' => 'ci_', 'job_token_scope_enabled' => 'ci_outbound_' @@ -1151,7 +1131,7 @@ RSpec.describe Project, factory_default: :keep, feature_category: :projects do end describe '#ci_inbound_job_token_scope_enabled?' do - it_behaves_like 'a ci_cd_settings predicate method', prefix: 'ci_' do + it_behaves_like 'a ci_cd_settings predicate method', prefix: 'ci_', default: true do let(:delegated_method) { :inbound_job_token_scope_enabled? } end end @@ -1380,6 +1360,60 @@ RSpec.describe Project, factory_default: :keep, feature_category: :projects do end end + describe '#to_reference_base' do + using RSpec::Parameterized::TableSyntax + + let_it_be(:user) { create(:user) } + let_it_be(:user_namespace) { user.namespace } + + let_it_be(:parent) { create(:group) } + let_it_be(:group) { create(:group, parent: parent) } + let_it_be(:another_group) { create(:group) } + + let_it_be(:project1) { create(:project, namespace: group) } + let_it_be(:project_namespace) { project1.project_namespace } + + # different project same group + let_it_be(:project2) { create(:project, namespace: group) } + let_it_be(:project_namespace2) { project2.project_namespace } + + # different project from different group + let_it_be(:project3) { create(:project) } + let_it_be(:project_namespace3) { project3.project_namespace } + + # testing references with namespace being: group, project namespace and user namespace + where(:project, :full, :from, :result) do + ref(:project1) | false | nil | nil + ref(:project1) | true | nil | lazy { project.full_path } + ref(:project1) | false | ref(:group) | lazy { project.path } + ref(:project1) | true | ref(:group) | lazy { project.full_path } + ref(:project1) | false | ref(:parent) | lazy { project.full_path } + ref(:project1) | true | ref(:parent) | lazy { project.full_path } + ref(:project1) | false | ref(:project1) | nil + ref(:project1) | true | ref(:project1) | lazy { project.full_path } + ref(:project1) | false | ref(:project_namespace) | nil + ref(:project1) | true | ref(:project_namespace) | lazy { project.full_path } + ref(:project1) | false | ref(:project2) | lazy { project.path } + ref(:project1) | true | ref(:project2) | lazy { project.full_path } + ref(:project1) | false | ref(:project_namespace2) | lazy { project.path } + ref(:project1) | true | ref(:project_namespace2) | lazy { project.full_path } + ref(:project1) | false | ref(:another_group) | lazy { project.full_path } + ref(:project1) | true | ref(:another_group) | lazy { project.full_path } + ref(:project1) | false | ref(:project3) | lazy { project.full_path } + ref(:project1) | true | ref(:project3) | lazy { project.full_path } + ref(:project1) | false | ref(:project_namespace3) | lazy { project.full_path } + ref(:project1) | true | ref(:project_namespace3) | lazy { project.full_path } + ref(:project1) | false | ref(:user_namespace) | lazy { project.full_path } + ref(:project1) | true | ref(:user_namespace) | lazy { project.full_path } + end + + with_them do + it 'returns correct path' do + expect(project.to_reference_base(from, full: full)).to eq(result) + end + end + end + describe '#merge_method' do where(:ff, :rebase, :method) do true | true | :ff @@ -1619,7 +1653,7 @@ RSpec.describe Project, factory_default: :keep, feature_category: :projects do context 'with namespace' do before do @group = create(:group, name: 'gitlab') - @project = create(:project, name: 'gitlabhq', namespace: @group) + @project = create(:project, path: 'gitlabhq', namespace: @group) end it { expect(@project.to_param).to eq('gitlabhq') } @@ -2213,8 +2247,8 @@ RSpec.describe Project, factory_default: :keep, feature_category: :projects do subject(:project) { build(:project, :private, namespace: namespace, service_desk_enabled: true) } before do - allow(Gitlab::IncomingEmail).to receive(:enabled?).and_return(true) - allow(Gitlab::IncomingEmail).to receive(:supports_wildcard?).and_return(true) + allow(Gitlab::Email::IncomingEmail).to receive(:enabled?).and_return(true) + allow(Gitlab::Email::IncomingEmail).to receive(:supports_wildcard?).and_return(true) end it 'is enabled' do @@ -2254,7 +2288,7 @@ RSpec.describe Project, factory_default: :keep, feature_category: :projects do context 'when service_desk_email is disabled' do before do - allow(::Gitlab::ServiceDeskEmail).to receive(:enabled?).and_return(false) + allow(::Gitlab::Email::ServiceDeskEmail).to receive(:enabled?).and_return(false) end it_behaves_like 'with incoming email address' @@ -2263,7 +2297,7 @@ RSpec.describe Project, factory_default: :keep, feature_category: :projects do context 'when service_desk_email is enabled' do before do config = double(enabled: true, address: 'foo+%{key}@bar.com') - allow(::Gitlab::ServiceDeskEmail).to receive(:config).and_return(config) + allow(::Gitlab::Email::ServiceDeskEmail).to receive(:config).and_return(config) end context 'when project_key is set' do @@ -2666,7 +2700,11 @@ RSpec.describe Project, factory_default: :keep, feature_category: :projects do end describe '#pages_url', feature_category: :pages do + let(:group_name) { 'group' } + let(:project_name) { 'project' } + let(:group) { create(:group, name: group_name) } + let(:nested_group) { create(:group, parent: group) } let(:project_path) { project_name.downcase } let(:project) do @@ -2689,101 +2727,168 @@ RSpec.describe Project, factory_default: :keep, feature_category: :projects do .and_return(['http://example.com', port].compact.join(':')) end - context 'group page' do - let(:group_name) { 'Group' } - let(:project_name) { 'group.example.com' } - - it { is_expected.to eq("http://group.example.com") } + context 'when not using pages_unique_domain' do + subject { project.pages_url(with_unique_domain: false) } - context 'mixed case path' do - let(:project_path) { 'Group.example.com' } + context 'when pages_unique_domain feature flag is disabled' do + before do + stub_feature_flags(pages_unique_domain: false) + end - it { is_expected.to eq("http://group.example.com") } + it { is_expected.to eq('http://group.example.com/project') } end - end - context 'project page' do - let(:group_name) { 'Group' } - let(:project_name) { 'Project' } + context 'when pages_unique_domain feature flag is enabled' do + before do + stub_feature_flags(pages_unique_domain: true) - it { is_expected.to eq("http://group.example.com/project") } + project.project_setting.update!( + pages_unique_domain_enabled: pages_unique_domain_enabled, + pages_unique_domain: 'unique-domain' + ) + end - context 'mixed case path' do - let(:project_path) { 'Project' } + context 'when pages_unique_domain_enabled is false' do + let(:pages_unique_domain_enabled) { false } + + it { is_expected.to eq('http://group.example.com/project') } + end - it { is_expected.to eq("http://group.example.com/Project") } + context 'when pages_unique_domain_enabled is true' do + let(:pages_unique_domain_enabled) { true } + + it { is_expected.to eq('http://group.example.com/project') } + end end end - context 'when there is an explicit port' do - let(:port) { 3000 } + context 'when using pages_unique_domain' do + subject { project.pages_url(with_unique_domain: true) } - context 'when not in dev mode' do + context 'when pages_unique_domain feature flag is disabled' do before do - stub_rails_env('production') + stub_feature_flags(pages_unique_domain: false) end - context 'group page' do - let(:group_name) { 'Group' } - let(:project_name) { 'group.example.com' } - - it { is_expected.to eq('http://group.example.com:3000/group.example.com') } + it { is_expected.to eq('http://group.example.com/project') } + end - context 'mixed case path' do - let(:project_path) { 'Group.example.com' } + context 'when pages_unique_domain feature flag is enabled' do + before do + stub_feature_flags(pages_unique_domain: true) - it { is_expected.to eq('http://group.example.com:3000/Group.example.com') } - end + project.project_setting.update!( + pages_unique_domain_enabled: pages_unique_domain_enabled, + pages_unique_domain: 'unique-domain' + ) end - context 'project page' do - let(:group_name) { 'Group' } - let(:project_name) { 'Project' } + context 'when pages_unique_domain_enabled is false' do + let(:pages_unique_domain_enabled) { false } - it { is_expected.to eq("http://group.example.com:3000/project") } + it { is_expected.to eq('http://group.example.com/project') } + end - context 'mixed case path' do - let(:project_path) { 'Project' } + context 'when pages_unique_domain_enabled is true' do + let(:pages_unique_domain_enabled) { true } - it { is_expected.to eq("http://group.example.com:3000/Project") } - end + it { is_expected.to eq('http://unique-domain.example.com') } end end + end - context 'when in dev mode' do - before do - stub_rails_env('development') - end + context 'with nested group' do + let(:project) { create(:project, namespace: nested_group, name: project_name) } + let(:expected_url) { "http://group.example.com/#{nested_group.path}/#{project.path}" } - context 'group page' do - let(:group_name) { 'Group' } - let(:project_name) { 'group.example.com' } + context 'group page' do + let(:project_name) { 'group.example.com' } - it { is_expected.to eq('http://group.example.com:3000') } + it { is_expected.to eq(expected_url) } + end + + context 'project page' do + let(:project_name) { 'Project' } - context 'mixed case path' do - let(:project_path) { 'Group.example.com' } + it { is_expected.to eq(expected_url) } + end + end - it { is_expected.to eq('http://group.example.com:3000') } - end - end + context 'when the project matches its namespace url' do + let(:project_name) { 'group.example.com' } + + it { is_expected.to eq('http://group.example.com') } - context 'project page' do - let(:group_name) { 'Group' } - let(:project_name) { 'Project' } + context 'with different group name capitalization' do + let(:group_name) { 'Group' } - it { is_expected.to eq("http://group.example.com:3000/project") } + it { is_expected.to eq("http://group.example.com") } + end + + context 'with different project path capitalization' do + let(:project_path) { 'Group.example.com' } + + it { is_expected.to eq("http://group.example.com") } + end + + context 'with different project name capitalization' do + let(:project_name) { 'Project' } - context 'mixed case path' do - let(:project_path) { 'Project' } + it { is_expected.to eq("http://group.example.com/project") } + end + + context 'when there is an explicit port' do + let(:port) { 3000 } - it { is_expected.to eq("http://group.example.com:3000/Project") } + context 'when not in dev mode' do + before do + stub_rails_env('production') end + + it { is_expected.to eq('http://group.example.com:3000/group.example.com') } + end + + context 'when in dev mode' do + before do + stub_rails_env('development') + end + + it { is_expected.to eq('http://group.example.com:3000') } end end end end + describe '#pages_unique_url', feature_category: :pages do + let(:project_settings) { create(:project_setting, pages_unique_domain: 'unique-domain') } + let(:project) { build(:project, project_setting: project_settings) } + let(:domain) { 'example.com' } + + before do + allow(Settings.pages).to receive(:host).and_return(domain) + allow(Gitlab.config.pages).to receive(:url).and_return("http://#{domain}") + end + + it 'returns the pages unique url' do + expect(project.pages_unique_url).to eq('http://unique-domain.example.com') + end + end + + describe '#pages_unique_host', feature_category: :pages do + let(:project_settings) { create(:project_setting, pages_unique_domain: 'unique-domain') } + let(:project) { build(:project, project_setting: project_settings) } + let(:domain) { 'example.com' } + + before do + allow(Settings.pages).to receive(:host).and_return(domain) + allow(Gitlab.config.pages).to receive(:url).and_return("http://#{domain}") + end + + it 'returns the pages unique url' do + expect(project.pages_unique_host).to eq('unique-domain.example.com') + end + end + describe '#pages_namespace_url', feature_category: :pages do let(:group) { create(:group, name: group_name) } let(:project) { create(:project, namespace: group, name: project_name) } @@ -3030,6 +3135,12 @@ RSpec.describe Project, factory_default: :keep, feature_category: :projects do expect(project.create_repository).to eq(false) expect(project.errors).not_to be_empty end + + it 'passes through default branch' do + expect(project.repository).to receive(:create_repository).with('pineapple') + + expect(project.create_repository(default_branch: 'pineapple')).to eq(true) + end end context 'using a forked repository' do @@ -3565,6 +3676,44 @@ RSpec.describe Project, factory_default: :keep, feature_category: :projects do end end + describe '#beautified_import_status_name' do + context 'when import not finished' do + it 'returns the right beautified import status' do + project = create(:project, :import_started) + + expect(project.beautified_import_status_name).to eq('started') + end + end + + context 'when import is finished' do + context 'when import is partially completed' do + it 'returns partially completed' do + project = create(:project) + + create(:import_state, project: project, status: 'finished', checksums: { + 'fetched' => { 'labels' => 10 }, + 'imported' => { 'labels' => 9 } + }) + + expect(project.beautified_import_status_name).to eq('partially completed') + end + end + + context 'when import is fully completed' do + it 'returns completed' do + project = create(:project) + + create(:import_state, project: project, status: 'finished', checksums: { + 'fetched' => { 'labels' => 10 }, + 'imported' => { 'labels' => 10 } + }) + + expect(project.beautified_import_status_name).to eq('completed') + end + end + end + end + describe '#add_import_job' do let(:import_jid) { '123' } @@ -3843,21 +3992,7 @@ RSpec.describe Project, factory_default: :keep, feature_category: :projects do end describe '#ancestors' do - context 'with linear_project_ancestors feature flag enabled' do - before do - stub_feature_flags(linear_project_ancestors: true) - end - - include_examples '#ancestors' - end - - context 'with linear_project_ancestors feature flag disabled' do - before do - stub_feature_flags(linear_project_ancestors: false) - end - - include_examples '#ancestors' - end + include_examples '#ancestors' end describe '#ancestors_upto' do @@ -4643,52 +4778,6 @@ RSpec.describe Project, factory_default: :keep, feature_category: :projects do end end - describe '#pages_url' do - let(:group) { create(:group, name: 'Group') } - let(:nested_group) { create(:group, parent: group) } - let(:domain) { 'Example.com' } - - subject { project.pages_url } - - before do - allow(Settings.pages).to receive(:host).and_return(domain) - allow(Gitlab.config.pages).to receive(:url).and_return('http://example.com') - end - - context 'top-level group' do - let(:project) { create(:project, namespace: group, name: project_name) } - - context 'group page' do - let(:project_name) { 'group.example.com' } - - it { is_expected.to eq("http://group.example.com") } - end - - context 'project page' do - let(:project_name) { 'Project' } - - it { is_expected.to eq("http://group.example.com/project") } - end - end - - context 'nested group' do - let(:project) { create(:project, namespace: nested_group, name: project_name) } - let(:expected_url) { "http://group.example.com/#{nested_group.path}/#{project.path}" } - - context 'group page' do - let(:project_name) { 'group.example.com' } - - it { is_expected.to eq(expected_url) } - end - - context 'project page' do - let(:project_name) { 'Project' } - - it { is_expected.to eq(expected_url) } - end - end - end - describe '#lfs_http_url_to_repo' do let(:project) { create(:project) } @@ -5740,8 +5829,19 @@ RSpec.describe Project, factory_default: :keep, feature_category: :projects do let_it_be(:project) { create(:project) } it 'exposes API v4 URL' do - expect(project.api_variables.first[:key]).to eq 'CI_API_V4_URL' - expect(project.api_variables.first[:value]).to include '/api/v4' + v4_variable = project.api_variables.find { |variable| variable[:key] == "CI_API_V4_URL" } + + expect(v4_variable).not_to be_nil + expect(v4_variable[:key]).to eq 'CI_API_V4_URL' + expect(v4_variable[:value]).to end_with '/api/v4' + end + + it 'exposes API GraphQL URL' do + graphql_variable = project.api_variables.find { |variable| variable[:key] == "CI_API_GRAPHQL_URL" } + + expect(graphql_variable).not_to be_nil + expect(graphql_variable[:key]).to eq 'CI_API_GRAPHQL_URL' + expect(graphql_variable[:value]).to end_with '/api/graphql' end it 'contains a URL variable for every supported API version' do @@ -5756,7 +5856,7 @@ RSpec.describe Project, factory_default: :keep, feature_category: :projects do end expect(project.api_variables.map { |variable| variable[:key] }) - .to contain_exactly(*required_variables) + .to include(*required_variables) end end @@ -5854,7 +5954,7 @@ RSpec.describe Project, factory_default: :keep, feature_category: :projects do expect(project).to receive(:after_create_default_branch) expect(project).to receive(:refresh_markdown_cache!) expect(InternalId).to receive(:flush_records!).with(project: project) - expect(ProjectCacheWorker).to receive(:perform_async).with(project.id, [], [:repository_size]) + expect(ProjectCacheWorker).to receive(:perform_async).with(project.id, [], [:repository_size, :wiki_size]) expect(DetectRepositoryLanguagesWorker).to receive(:perform_async).with(project.id) expect(AuthorizedProjectUpdate::ProjectRecalculateWorker).to receive(:perform_async).with(project.id) expect(project).to receive(:set_full_path) @@ -6049,7 +6149,7 @@ RSpec.describe Project, factory_default: :keep, feature_category: :projects do it 'executes hooks which were backed off and are no longer backed off' do project = create(:project) hook = create(:project_hook, project: project, push_events: true) - WebHook::FAILURE_THRESHOLD.succ.times { hook.backoff! } + WebHooks::AutoDisabling::FAILURE_THRESHOLD.succ.times { hook.backoff! } expect_any_instance_of(ProjectHook).to receive(:async_execute).once @@ -6701,6 +6801,19 @@ RSpec.describe Project, factory_default: :keep, feature_category: :projects do end end + describe '.pending_data_repair_analysis' do + it 'returns projects that are not in ContainerRegistry::DataRepairDetail' do + project_1 = create(:project) + project_2 = create(:project) + + expect(described_class.pending_data_repair_analysis).to match_array([project_1, project_2]) + + create(:container_registry_data_repair_detail, project: project_1) + + expect(described_class.pending_data_repair_analysis).to match_array([project_2]) + end + end + describe '.deployments' do subject { project.deployments } @@ -7305,20 +7418,6 @@ RSpec.describe Project, factory_default: :keep, feature_category: :projects do end end - describe 'with integrations and chat names' do - subject { create(:project) } - - let(:integration) { create(:integration, project: subject) } - - before do - create_list(:chat_name, 5, integration: integration) - end - - it 'does not remove chat names on removal' do - expect { subject.destroy! }.not_to change { ChatName.count } - end - end - describe 'with_issues_or_mrs_available_for_user' do before do Project.delete_all @@ -7380,6 +7479,7 @@ RSpec.describe Project, factory_default: :keep, feature_category: :projects do context 'when feature flag `group_protected_branches` enabled' do before do stub_feature_flags(group_protected_branches: true) + stub_feature_flags(allow_protected_branches_for_group: true) end it 'return all protected branches' do @@ -7390,6 +7490,7 @@ RSpec.describe Project, factory_default: :keep, feature_category: :projects do context 'when feature flag `group_protected_branches` disabled' do before do stub_feature_flags(group_protected_branches: false) + stub_feature_flags(allow_protected_branches_for_group: false) end it 'return only project-level protected branches' do @@ -7463,24 +7564,6 @@ RSpec.describe Project, factory_default: :keep, feature_category: :projects do end end - describe '#self_monitoring?' do - let_it_be(:project) { create(:project) } - - subject { project.self_monitoring? } - - context 'when the project is instance self-monitoring' do - before do - stub_application_setting(self_monitoring_project_id: project.id) - end - - it { is_expected.to be true } - end - - context 'when the project is not self-monitoring' do - it { is_expected.to be false } - end - end - describe '#add_export_job' do let_it_be(:user) { create(:user) } let_it_be(:project) { create(:project) } @@ -7578,48 +7661,6 @@ RSpec.describe Project, factory_default: :keep, feature_category: :projects do end end - describe '#has_packages?' do - let(:project) { create(:project, :public) } - - subject { project.has_packages?(package_type) } - - shared_examples 'has_package' do - context 'package of package_type exists' do - let!(:package) { create("#{package_type}_package", project: project) } - - it { is_expected.to be true } - end - - context 'package of package_type does not exist' do - it { is_expected.to be false } - end - end - - context 'with maven packages' do - it_behaves_like 'has_package' do - let(:package_type) { :maven } - end - end - - context 'with npm packages' do - it_behaves_like 'has_package' do - let(:package_type) { :npm } - end - end - - context 'with conan packages' do - it_behaves_like 'has_package' do - let(:package_type) { :conan } - end - end - - context 'calling has_package? with nil' do - let(:package_type) { nil } - - it { is_expected.to be false } - end - end - describe 'with Debian Distributions' do subject { create(:project) } @@ -7740,6 +7781,29 @@ RSpec.describe Project, factory_default: :keep, feature_category: :projects do end end + describe '#has_namespaced_npm_packages?' do + let_it_be(:namespace) { create(:namespace, path: 'test') } + let_it_be(:project) { create(:project, :public, namespace: namespace) } + + subject { project.has_namespaced_npm_packages? } + + context 'with scope of the namespace path' do + let_it_be(:package) { create(:npm_package, project: project, name: "@#{namespace.path}/foo") } + + it { is_expected.to be true } + end + + context 'without scope of the namespace path' do + let_it_be(:package) { create(:npm_package, project: project, name: "@someotherscope/foo") } + + it { is_expected.to be false } + end + + context 'without packages' do + it { is_expected.to be false } + end + end + describe '#package_already_taken?' do let_it_be(:namespace) { create(:namespace, path: 'test') } let_it_be(:project) { create(:project, :public, namespace: namespace) } @@ -7913,7 +7977,7 @@ RSpec.describe Project, factory_default: :keep, feature_category: :projects do describe '#activity_path' do it 'returns the project activity_path' do - expected_path = "/#{project.namespace.path}/#{project.name}/activity" + expected_path = "/#{project.full_path}/activity" expect(project.activity_path).to eq(expected_path) end @@ -7952,8 +8016,8 @@ RSpec.describe Project, factory_default: :keep, feature_category: :projects do using RSpec::Parameterized::TableSyntax where(:topic_list, :expected_result) do - ['topicA', 'topicB'] | %w[topicA topicB] # rubocop:disable Style/WordArray, Lint/BinaryOperatorWithIdenticalOperands - ['topicB', 'topicA'] | %w[topicB topicA] # rubocop:disable Style/WordArray, Lint/BinaryOperatorWithIdenticalOperands + ['topicA', 'topicB'] | %w[topicA topicB] # rubocop:disable Style/WordArray + ['topicB', 'topicA'] | %w[topicB topicA] # rubocop:disable Style/WordArray [' topicC ', ' topicD '] | %w[topicC topicD] ['topicE', 'topicF', 'topicE'] | %w[topicE topicF] # rubocop:disable Style/WordArray ['topicE ', 'topicF', ' topicE'] | %w[topicE topicF] @@ -8028,7 +8092,6 @@ RSpec.describe Project, factory_default: :keep, feature_category: :projects do using RSpec::Parameterized::TableSyntax - # rubocop:disable Lint/BinaryOperatorWithIdenticalOperands where(:initial_visibility, :new_visibility, :new_topic_list, :expected_count_changes) do ref(:private) | nil | 't2, t3' | [0, 0, 0] ref(:internal) | nil | 't2, t3' | [-1, 0, 1] @@ -8052,7 +8115,6 @@ RSpec.describe Project, factory_default: :keep, feature_category: :projects do ref(:public) | ref(:internal) | 't2, t3' | [-1, 0, 1] ref(:public) | ref(:private) | 't2, t3' | [-1, -1, 0] end - # rubocop:enable Lint/BinaryOperatorWithIdenticalOperands with_them do it 'increments or decrements counters of topics' do @@ -8519,6 +8581,16 @@ RSpec.describe Project, factory_default: :keep, feature_category: :projects do end end + describe '#content_editor_on_issues_feature_flag_enabled?' do + let_it_be(:group_project) { create(:project, :in_subgroup) } + + it_behaves_like 'checks parent group feature flag' do + let(:feature_flag_method) { :content_editor_on_issues_feature_flag_enabled? } + let(:feature_flag) { :content_editor_on_issues } + let(:subject_project) { group_project } + end + end + describe '#work_items_mvc_feature_flag_enabled?' do let_it_be(:group_project) { create(:project, :in_subgroup) } @@ -8842,6 +8914,32 @@ RSpec.describe Project, factory_default: :keep, feature_category: :projects do end end + describe 'deprecated project attributes' do + where(:project_attr, :project_method, :project_feature_attr) do + :wiki_enabled | :wiki_enabled? | :wiki_access_level + :builds_enabled | :builds_enabled? | :builds_access_level + :merge_requests_enabled | :merge_requests_enabled? | :merge_requests_access_level + :issues_enabled | :issues_enabled? | :issues_access_level + :snippets_enabled | :snippets_enabled? | :snippets_access_level + end + + with_them do + it 'delegates the attributes to project feature' do + project = Project.new(project_attr => false) + + expect(project.public_send(project_method)).to eq(false) + expect(project.project_feature.public_send(project_feature_attr)).to eq(ProjectFeature::DISABLED) + end + + it 'sets the default value' do + project = Project.new + + expect(project.public_send(project_method)).to eq(true) + expect(project.project_feature.public_send(project_feature_attr)).to eq(ProjectFeature::ENABLED) + end + end + end + private def finish_job(export_job) diff --git a/spec/models/project_wiki_spec.rb b/spec/models/project_wiki_spec.rb index c04fc70deca..efa5403dad9 100644 --- a/spec/models/project_wiki_spec.rb +++ b/spec/models/project_wiki_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe ProjectWiki do +RSpec.describe ProjectWiki, feature_category: :wiki do it_behaves_like 'wiki model' do let(:wiki_container) { create(:project, :wiki_repo, namespace: user.namespace) } let(:wiki_container_without_repo) { create(:project, namespace: user.namespace) } @@ -18,6 +18,33 @@ RSpec.describe ProjectWiki do end end + describe '#create_wiki_repository' do + context 'when a project_wiki_repositories record does not exist' do + let_it_be(:wiki_container) { create(:project) } + + it 'creates a new record' do + expect { subject.create_wiki_repository }.to change { wiki_container.wiki_repository } + .from(nil).to(kind_of(Projects::WikiRepository)) + end + + context 'on a read-only instance' do + before do + allow(Gitlab::Database).to receive(:read_only?).and_return(true) + end + + it 'does not attempt to create a new record' do + expect { subject.create_wiki_repository }.not_to change { wiki_container.wiki_repository } + end + end + end + + context 'when a project_wiki_repositories record exists' do + it 'does not create a new record in the database' do + expect { subject.create_wiki_repository }.not_to change { wiki_container.wiki_repository } + end + end + end + describe '#after_wiki_activity' do it 'updates project activity' do wiki_container.update!( diff --git a/spec/models/projects/data_transfer_spec.rb b/spec/models/projects/data_transfer_spec.rb index 6d3ddbdd74e..49be35662c8 100644 --- a/spec/models/projects/data_transfer_spec.rb +++ b/spec/models/projects/data_transfer_spec.rb @@ -7,12 +7,24 @@ RSpec.describe Projects::DataTransfer, feature_category: :source_code_management it { expect(subject).to be_valid } + # tests DataTransferCounterAttribute with the appropiate attributes + it_behaves_like CounterAttribute, + %i[repository_egress artifacts_egress packages_egress registry_egress] do + let(:model) { create(:project_data_transfer, project: project) } + end + describe 'associations' do it { is_expected.to belong_to(:project) } it { is_expected.to belong_to(:namespace) } end describe 'scopes' do + let(:dates) { %w[2023-01-01 2023-02-01 2023-03-01] } + + before do + dates.each { |date| create(:project_data_transfer, project: project, date: date) } + end + describe '.current_month' do subject { described_class.current_month } @@ -25,6 +37,26 @@ RSpec.describe Projects::DataTransfer, feature_category: :source_code_management end end end + + describe '.with_project_between_dates' do + subject do + described_class.with_project_between_dates(project, Date.new(2023, 2, 1), Date.new(2023, 3, 1)) + end + + it 'returns the correct number of results' do + expect(subject.size).to eq(2) + end + end + + describe '.with_namespace_between_dates' do + subject do + described_class.with_namespace_between_dates(project.namespace, Date.new(2023, 2, 1), Date.new(2023, 3, 1)) + end + + it 'returns the correct number of results' do + expect(subject.select(:namespace_id).to_a.size).to eq(2) + end + end end describe '.beginning_of_month' do diff --git a/spec/models/projects/forks/divergence_counts_spec.rb b/spec/models/projects/forks/details_spec.rb index fd69cc0f3e7..4c0a2e3453a 100644 --- a/spec/models/projects/forks/divergence_counts_spec.rb +++ b/spec/models/projects/forks/details_spec.rb @@ -2,24 +2,29 @@ require 'spec_helper' -RSpec.describe Projects::Forks::DivergenceCounts, feature_category: :source_code_management do +RSpec.describe Projects::Forks::Details, feature_category: :source_code_management do + include ExclusiveLeaseHelpers include ProjectForksHelper let_it_be(:user) { create(:user) } + let_it_be(:source_repo) { create(:project, :repository, :public).repository } + let_it_be(:fork_repo) { fork_project(source_repo.project, user, { repository: true }).repository } - describe '#counts', :use_clean_rails_redis_caching do - let(:source_repo) { create(:project, :repository, :public).repository } - let(:fork_repo) { fork_project(source_repo.project, user, { repository: true }).repository } - let(:fork_branch) { 'fork-branch' } - let(:cache_key) { ['project_forks', fork_repo.project.id, fork_branch, 'divergence_counts'] } + let(:fork_branch) { 'fork-branch' } + let(:cache_key) { ['project_fork_details', fork_repo.project.id, fork_branch].join(':') } + describe '#counts', :use_clean_rails_redis_caching do def expect_cached_counts(value) counts = described_class.new(fork_repo.project, fork_branch).counts ahead, behind = value expect(counts).to eq({ ahead: ahead, behind: behind }) - cached_value = [source_repo.commit.sha, fork_repo.commit(fork_branch).sha, value] + cached_value = { + source_sha: source_repo.commit.sha, + sha: fork_repo.commit(fork_branch).sha, + counts: value + } expect(Rails.cache.read(cache_key)).to eq(cached_value) end @@ -72,6 +77,9 @@ RSpec.describe Projects::Forks::DivergenceCounts, feature_category: :source_code end context 'when counts calculated from a branch that exists upstream' do + let_it_be(:source_repo) { create(:project, :repository, :public).repository } + let_it_be(:fork_repo) { fork_project(source_repo.project, user, { repository: true }).repository } + let(:fork_branch) { 'feature' } it 'compares the fork branch to upstream default branch' do @@ -94,5 +102,61 @@ RSpec.describe Projects::Forks::DivergenceCounts, feature_category: :source_code expect_cached_counts([2, 30]) end end + + context 'when specified branch does not exist' do + it 'returns nils as counts' do + counts = described_class.new(fork_repo.project, 'non-existent-branch').counts + expect(counts).to eq({ ahead: nil, behind: nil }) + end + end + end + + describe '#update!', :use_clean_rails_redis_caching do + it 'updates the cache with the specified value' do + value = { source_sha: source_repo.commit.sha, sha: fork_repo.commit.sha, counts: [0, 0], has_conflicts: true } + + described_class.new(fork_repo.project, fork_branch).update!(value) + + expect(Rails.cache.read(cache_key)).to eq(value) + end + end + + describe '#has_conflicts', :use_clean_rails_redis_caching do + it 'returns whether merge for the stored commits failed due to conflicts' do + details = described_class.new(fork_repo.project, fork_branch) + + expect do + value = { source_sha: source_repo.commit.sha, sha: fork_repo.commit.sha, counts: [0, 0], has_conflicts: true } + + details.update!(value) + end.to change { details.has_conflicts? }.from(false).to(true) + end + end + + describe '#exclusive_lease' do + it 'returns exclusive lease to the details' do + key = ['project_details', fork_repo.project.id, fork_branch].join(':') + uuid = SecureRandom.uuid + details = described_class.new(fork_repo.project, fork_branch) + + expect(Gitlab::ExclusiveLease).to receive(:get_uuid).with(key).and_return(uuid) + expect(Gitlab::ExclusiveLease).to receive(:new).with( + key, uuid: uuid, timeout: described_class::LEASE_TIMEOUT + ).and_call_original + + expect(details.exclusive_lease).to be_a(Gitlab::ExclusiveLease) + end + end + + describe 'syncing?', :use_clean_rails_redis_caching do + it 'returns whether there is a sync in progress' do + details = described_class.new(fork_repo.project, fork_branch) + + expect(details.exclusive_lease.try_obtain).to be_present + expect(details.syncing?).to eq(true) + + details.exclusive_lease.cancel + expect(details.syncing?).to eq(false) + end end end diff --git a/spec/models/projects/import_export/relation_export_spec.rb b/spec/models/projects/import_export/relation_export_spec.rb index 8643fbc7b46..c724f30250d 100644 --- a/spec/models/projects/import_export/relation_export_spec.rb +++ b/spec/models/projects/import_export/relation_export_spec.rb @@ -2,9 +2,7 @@ require 'spec_helper' -RSpec.describe Projects::ImportExport::RelationExport, type: :model do - subject { create(:project_relation_export) } - +RSpec.describe Projects::ImportExport::RelationExport, type: :model, feature_category: :importers do describe 'associations' do it { is_expected.to belong_to(:project_export_job) } it { is_expected.to have_one(:upload) } @@ -13,12 +11,16 @@ RSpec.describe Projects::ImportExport::RelationExport, type: :model do describe 'validations' do it { is_expected.to validate_presence_of(:project_export_job) } it { is_expected.to validate_presence_of(:relation) } - it { is_expected.to validate_uniqueness_of(:relation).scoped_to(:project_export_job_id) } it { is_expected.to validate_presence_of(:status) } it { is_expected.to validate_numericality_of(:status).only_integer } it { is_expected.to validate_length_of(:relation).is_at_most(255) } it { is_expected.to validate_length_of(:jid).is_at_most(255) } it { is_expected.to validate_length_of(:export_error).is_at_most(300) } + + it 'validates uniquness of the relation attribute' do + create(:project_relation_export) + expect(subject).to validate_uniqueness_of(:relation).scoped_to(:project_export_job_id) + end end describe '.by_relation' do @@ -52,4 +54,16 @@ RSpec.describe Projects::ImportExport::RelationExport, type: :model do expect(described_class.relation_names_list).not_to include('events', 'notes') end end + + describe '#mark_as_failed' do + it 'sets status to failed and sets the export error', :aggregate_failures do + relation_export = create(:project_relation_export) + + relation_export.mark_as_failed("Error message") + relation_export.reload + + expect(relation_export.failed?).to eq(true) + expect(relation_export.export_error).to eq("Error message") + end + end end diff --git a/spec/models/protected_branch/merge_access_level_spec.rb b/spec/models/protected_branch/merge_access_level_spec.rb index b6c2d527d1b..92aa5fa3eee 100644 --- a/spec/models/protected_branch/merge_access_level_spec.rb +++ b/spec/models/protected_branch/merge_access_level_spec.rb @@ -2,6 +2,7 @@ require 'spec_helper' -RSpec.describe ProtectedBranch::MergeAccessLevel do - it { is_expected.to validate_inclusion_of(:access_level).in_array([Gitlab::Access::MAINTAINER, Gitlab::Access::DEVELOPER, Gitlab::Access::NO_ACCESS]) } +RSpec.describe ProtectedBranch::MergeAccessLevel, feature_category: :source_code_management do + include_examples 'protected branch access' + include_examples 'protected ref access allowed_access_levels' end diff --git a/spec/models/protected_branch/push_access_level_spec.rb b/spec/models/protected_branch/push_access_level_spec.rb index 008ae6275f0..e56ff2241b1 100644 --- a/spec/models/protected_branch/push_access_level_spec.rb +++ b/spec/models/protected_branch/push_access_level_spec.rb @@ -2,8 +2,9 @@ require 'spec_helper' -RSpec.describe ProtectedBranch::PushAccessLevel do - it { is_expected.to validate_inclusion_of(:access_level).in_array([Gitlab::Access::MAINTAINER, Gitlab::Access::DEVELOPER, Gitlab::Access::NO_ACCESS]) } +RSpec.describe ProtectedBranch::PushAccessLevel, feature_category: :source_code_management do + include_examples 'protected branch access' + include_examples 'protected ref access allowed_access_levels' describe 'associations' do it { is_expected.to belong_to(:deploy_key) } diff --git a/spec/models/protected_branch_spec.rb b/spec/models/protected_branch_spec.rb index 71e22f848cc..d14a7dd1a7e 100644 --- a/spec/models/protected_branch_spec.rb +++ b/spec/models/protected_branch_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe ProtectedBranch do +RSpec.describe ProtectedBranch, feature_category: :source_code_management do subject { build_stubbed(:protected_branch) } describe 'Associations' do @@ -13,6 +13,30 @@ RSpec.describe ProtectedBranch do describe 'Validation' do it { is_expected.to validate_presence_of(:name) } + context 'uniqueness' do + let(:protected_branch) { build(:protected_branch) } + + subject { protected_branch } + + it { is_expected.to validate_uniqueness_of(:name).scoped_to([:project_id, :namespace_id]) } + + context 'when the protected_branch was saved previously' do + before do + protected_branch.save! + end + + it { is_expected.not_to validate_uniqueness_of(:name) } + + context 'and name is changed' do + before do + protected_branch.name = "#{protected_branch.name} + something else" + end + + it { is_expected.to validate_uniqueness_of(:name).scoped_to([:project_id, :namespace_id]) } + end + end + end + describe '#validate_either_project_or_top_group' do context 'when protected branch does not have project or group association' do it 'validate failed' do @@ -214,85 +238,52 @@ RSpec.describe ProtectedBranch do let_it_be(:project) { create(:project, :repository) } let_it_be(:protected_branch) { create(:protected_branch, project: project, name: "“jawn”") } - let(:rely_on_new_cache) { true } - - shared_examples_for 'hash based cache implementation' do - it 'calls only hash based cache implementation' do - expect_next_instance_of(ProtectedBranches::CacheService) do |instance| - expect(instance).to receive(:fetch).with('missing-branch', anything).and_call_original - end - - expect(Rails.cache).not_to receive(:fetch) - - described_class.protected?(project, 'missing-branch') - end - end - before do - stub_feature_flags(rely_on_protected_branches_cache: rely_on_new_cache) allow(described_class).to receive(:matching).and_call_original # the original call works and warms the cache described_class.protected?(project, protected_branch.name) end - context 'Dry-run: true (rely_on_protected_branches_cache is off, new hash-based is used)' do - let(:rely_on_new_cache) { false } + it 'correctly invalidates a cache' do + expect(described_class).to receive(:matching).with(protected_branch.name, protected_refs: anything).exactly(3).times.and_call_original - it 'recalculates a fresh value every time in order to check the cache is not returning stale data' do - expect(described_class).to receive(:matching).with(protected_branch.name, protected_refs: anything).twice + create_params = { name: 'bar', merge_access_levels_attributes: [{ access_level: Gitlab::Access::DEVELOPER }] } + branch = ProtectedBranches::CreateService.new(project, project.owner, create_params).execute + expect(described_class.protected?(project, protected_branch.name)).to eq(true) - 2.times { described_class.protected?(project, protected_branch.name) } - end + ProtectedBranches::UpdateService.new(project, project.owner, name: 'ber').execute(branch) + expect(described_class.protected?(project, protected_branch.name)).to eq(true) - it_behaves_like 'hash based cache implementation' + ProtectedBranches::DestroyService.new(project, project.owner).execute(branch) + expect(described_class.protected?(project, protected_branch.name)).to eq(true) end - context 'Dry-run: false (rely_on_protected_branches_cache is enabled, new hash-based cache is used)' do - let(:rely_on_new_cache) { true } - - it 'correctly invalidates a cache' do - expect(described_class).to receive(:matching).with(protected_branch.name, protected_refs: anything).exactly(3).times.and_call_original - - create_params = { name: 'bar', merge_access_levels_attributes: [{ access_level: Gitlab::Access::DEVELOPER }] } - branch = ProtectedBranches::CreateService.new(project, project.owner, create_params).execute - expect(described_class.protected?(project, protected_branch.name)).to eq(true) - - ProtectedBranches::UpdateService.new(project, project.owner, name: 'ber').execute(branch) - expect(described_class.protected?(project, protected_branch.name)).to eq(true) - - ProtectedBranches::DestroyService.new(project, project.owner).execute(branch) - expect(described_class.protected?(project, protected_branch.name)).to eq(true) - end - - it_behaves_like 'hash based cache implementation' - - context 'when project is updated' do - it 'does not invalidate a cache' do - expect(described_class).not_to receive(:matching).with(protected_branch.name, protected_refs: anything) + context 'when project is updated' do + it 'does not invalidate a cache' do + expect(described_class).not_to receive(:matching).with(protected_branch.name, protected_refs: anything) - project.touch + project.touch - described_class.protected?(project, protected_branch.name) - end + described_class.protected?(project, protected_branch.name) end + end - context 'when other project protected branch is updated' do - it 'does not invalidate the current project cache' do - expect(described_class).not_to receive(:matching).with(protected_branch.name, protected_refs: anything) + context 'when other project protected branch is updated' do + it 'does not invalidate the current project cache' do + expect(described_class).not_to receive(:matching).with(protected_branch.name, protected_refs: anything) - another_project = create(:project) - ProtectedBranches::CreateService.new(another_project, another_project.owner, name: 'bar').execute + another_project = create(:project) + ProtectedBranches::CreateService.new(another_project, another_project.owner, name: 'bar').execute - described_class.protected?(project, protected_branch.name) - end + described_class.protected?(project, protected_branch.name) end + end - it 'correctly uses the cached version' do - expect(described_class).not_to receive(:matching) + it 'correctly uses the cached version' do + expect(described_class).not_to receive(:matching) - expect(described_class.protected?(project, protected_branch.name)).to eq(true) - end + expect(described_class.protected?(project, protected_branch.name)).to eq(true) end end end @@ -344,6 +335,7 @@ RSpec.describe ProtectedBranch do context "when feature flag disabled" do before do stub_feature_flags(group_protected_branches: false) + stub_feature_flags(allow_protected_branches_for_group: false) end let(:subject_branch) { create(:protected_branch, allow_force_push: allow_force_push, name: "foo") } @@ -383,6 +375,7 @@ RSpec.describe ProtectedBranch do with_them do before do stub_feature_flags(group_protected_branches: true) + stub_feature_flags(allow_protected_branches_for_group: true) unless group_level_value.nil? create(:protected_branch, allow_force_push: group_level_value, name: "foo", project: nil, group: group) @@ -436,6 +429,7 @@ RSpec.describe ProtectedBranch do context 'when feature flag enabled' do before do stub_feature_flags(group_protected_branches: true) + stub_feature_flags(allow_protected_branches_for_group: true) end it 'call `all_protected_branches`' do @@ -448,6 +442,7 @@ RSpec.describe ProtectedBranch do context 'when feature flag disabled' do before do stub_feature_flags(group_protected_branches: false) + stub_feature_flags(allow_protected_branches_for_group: false) end it 'call `protected_branches`' do @@ -458,6 +453,62 @@ RSpec.describe ProtectedBranch do end end + describe '.protected_ref_accessible_to?' do + let_it_be(:project) { create(:project) } + let_it_be(:guest) { create(:user) } + let_it_be(:reporter) { create(:user) } + let_it_be(:developer) { create(:user) } + let_it_be(:maintainer) { create(:user) } + let_it_be(:owner) { create(:user) } + let_it_be(:admin) { create(:user, :admin) } + + before do + project.add_guest(guest) + project.add_reporter(reporter) + project.add_developer(developer) + project.add_maintainer(maintainer) + project.add_owner(owner) + end + + subject { described_class.protected_ref_accessible_to?(anything, current_user, project: project, action: :push) } + + context 'with guest' do + let(:current_user) { guest } + + it { is_expected.to eq(false) } + end + + context 'with reporter' do + let(:current_user) { reporter } + + it { is_expected.to eq(false) } + end + + context 'with developer' do + let(:current_user) { developer } + + it { is_expected.to eq(false) } + end + + context 'with maintainer' do + let(:current_user) { maintainer } + + it { is_expected.to eq(true) } + end + + context 'with owner' do + let(:current_user) { owner } + + it { is_expected.to eq(true) } + end + + context 'with admin' do + let(:current_user) { admin } + + it { is_expected.to eq(true) } + end + end + describe '.by_name' do let!(:protected_branch) { create(:protected_branch, name: 'master') } let!(:another_protected_branch) { create(:protected_branch, name: 'stable') } diff --git a/spec/models/protected_tag/create_access_level_spec.rb b/spec/models/protected_tag/create_access_level_spec.rb index 566f8695388..8eeccdc9b34 100644 --- a/spec/models/protected_tag/create_access_level_spec.rb +++ b/spec/models/protected_tag/create_access_level_spec.rb @@ -3,6 +3,9 @@ require 'spec_helper' RSpec.describe ProtectedTag::CreateAccessLevel, feature_category: :source_code_management do + include_examples 'protected tag access' + include_examples 'protected ref access allowed_access_levels' + describe 'associations' do it { is_expected.to belong_to(:deploy_key) } end @@ -10,16 +13,6 @@ RSpec.describe ProtectedTag::CreateAccessLevel, feature_category: :source_code_m describe 'validations', :aggregate_failures do let_it_be(:protected_tag) { create(:protected_tag) } - it 'verifies access levels' do - is_expected.to validate_inclusion_of(:access_level).in_array( - [ - Gitlab::Access::MAINTAINER, - Gitlab::Access::DEVELOPER, - Gitlab::Access::NO_ACCESS - ] - ) - end - context 'when deploy key enabled for the project' do let(:deploy_key) { create(:deploy_key, projects: [protected_tag.project]) } diff --git a/spec/models/releases/link_spec.rb b/spec/models/releases/link_spec.rb index 4910de61c22..c4c9fba32d9 100644 --- a/spec/models/releases/link_spec.rb +++ b/spec/models/releases/link_spec.rb @@ -80,15 +80,6 @@ RSpec.describe Releases::Link do end end - describe '#external?' do - subject { link.external? } - - let(:link) { build(:release_link, release: release, url: url) } - let(:url) { 'https://google.com/-/jobs/140463678/artifacts/download' } - - it { is_expected.to be_truthy } - end - describe 'supported protocols' do where(:protocol) do %w(http https ftp) diff --git a/spec/models/releases/source_spec.rb b/spec/models/releases/source_spec.rb index 227085951c0..fadc2fd6f53 100644 --- a/spec/models/releases/source_spec.rb +++ b/spec/models/releases/source_spec.rb @@ -3,7 +3,7 @@ require 'spec_helper' RSpec.describe Releases::Source do - let_it_be(:project) { create(:project, :repository, name: 'finance-cal') } + let_it_be(:project) { create(:project, :repository) } let(:tag_name) { 'v1.0' } @@ -27,7 +27,7 @@ RSpec.describe Releases::Source do it 'returns zip archived source url' do is_expected - .to eq("#{project.web_url}/-/archive/v1.0/finance-cal-v1.0.zip") + .to eq("#{project.web_url}/-/archive/v1.0/#{project.path}-v1.0.zip") end context 'when ref is directory structure' do @@ -35,7 +35,7 @@ RSpec.describe Releases::Source do it 'converts slash to dash' do is_expected - .to eq("#{project.web_url}/-/archive/beta/v1.0/finance-cal-beta-v1.0.zip") + .to eq("#{project.web_url}/-/archive/beta/v1.0/#{project.path}-beta-v1.0.zip") end end end diff --git a/spec/models/repository_spec.rb b/spec/models/repository_spec.rb index 44fcf87526a..aa2ac52a9ab 100644 --- a/spec/models/repository_spec.rb +++ b/spec/models/repository_spec.rb @@ -20,10 +20,12 @@ RSpec.describe Repository, feature_category: :source_code_management do let(:merge_commit) do merge_request = create(:merge_request, source_branch: 'feature', target_branch: 'master', source_project: project) - merge_commit_id = repository.merge(user, - merge_request.diff_head_sha, - merge_request, - message) + merge_commit_id = repository.merge( + user, + merge_request.diff_head_sha, + merge_request, + message + ) repository.commit(merge_commit_id) end @@ -413,6 +415,27 @@ RSpec.describe Repository, feature_category: :source_code_management do repository.commits('master', limit: 1) end end + + context 'when include_referenced_by is passed' do + context 'when commit has references' do + let(:ref) { '5937ac0a7beb003549fc5fd26fc247adbce4a52e' } + let(:include_referenced_by) { ['refs/tags'] } + + subject { repository.commits(ref, limit: 1, include_referenced_by: include_referenced_by).first } + + it 'returns commits with referenced_by excluding that match the patterns' do + expect(subject.referenced_by).to match_array(['refs/tags/v1.1.0']) + end + + context 'when matching multiple references' do + let(:include_referenced_by) { ['refs/tags', 'refs/heads'] } + + it 'returns commits with referenced_by that match the patterns' do + expect(subject.referenced_by).to match_array(['refs/tags/v1.1.0', 'refs/heads/improve/awesome', 'refs/heads/merge-test']) + end + end + end + end end context "when 'author' is set" do @@ -576,6 +599,15 @@ RSpec.describe Repository, feature_category: :source_code_management do end describe '#list_commits_by' do + it 'returns commits when no filter is applied' do + commit_ids = repository.list_commits_by(nil, 'master', limit: 2).map(&:id) + + expect(commit_ids).to include( + 'b83d6e391c22777fca1ed3012fce84f633d7fed0', + '498214de67004b1da3d820901307bed2a68a8ef6' + ) + end + it 'returns commits with messages containing a given string' do commit_ids = repository.list_commits_by('test text', 'master').map(&:id) @@ -682,6 +714,14 @@ RSpec.describe Repository, feature_category: :source_code_management do it { is_expected.to be_nil } end + + context 'when root reference is empty' do + subject { empty_repo.merged_to_root_ref?('master') } + + let(:empty_repo) { build(:project, :empty_repo).repository } + + it { is_expected.to be_nil } + end end describe "#root_ref_sha" do @@ -690,7 +730,7 @@ RSpec.describe Repository, feature_category: :source_code_management do subject { repository.root_ref_sha } before do - allow(repository).to receive(:commit).with(repository.root_ref) { commit } + allow(repository).to receive(:head_commit) { commit } end it { is_expected.to eq(commit.sha) } @@ -925,9 +965,11 @@ RSpec.describe Repository, feature_category: :source_code_management do describe "#create_file" do it 'commits new file successfully' do expect do - repository.create_file(user, 'NEWCHANGELOG', 'Changelog!', - message: 'Create changelog', - branch_name: 'master') + repository.create_file( + user, 'NEWCHANGELOG', 'Changelog!', + message: 'Create changelog', + branch_name: 'master' + ) end.to change { repository.count_commits(ref: 'master') }.by(1) blob = repository.blob_at('master', 'NEWCHANGELOG') @@ -937,9 +979,11 @@ RSpec.describe Repository, feature_category: :source_code_management do it 'creates new file and dir when file_path has a forward slash' do expect do - repository.create_file(user, 'new_dir/new_file.txt', 'File!', - message: 'Create new_file with new_dir', - branch_name: 'master') + repository.create_file( + user, 'new_dir/new_file.txt', 'File!', + message: 'Create new_file with new_dir', + branch_name: 'master' + ) end.to change { repository.count_commits(ref: 'master') }.by(1) expect(repository.tree('master', 'new_dir').path).to eq('new_dir') @@ -947,9 +991,11 @@ RSpec.describe Repository, feature_category: :source_code_management do end it 'respects the autocrlf setting' do - repository.create_file(user, 'hello.txt', "Hello,\r\nWorld", - message: 'Add hello world', - branch_name: 'master') + repository.create_file( + user, 'hello.txt', "Hello,\r\nWorld", + message: 'Add hello world', + branch_name: 'master' + ) blob = repository.blob_at('master', 'hello.txt') @@ -959,11 +1005,13 @@ RSpec.describe Repository, feature_category: :source_code_management do context "when an author is specified" do it "uses the given email/name to set the commit's author" do expect do - repository.create_file(user, 'NEWREADME', 'README!', - message: 'Add README', - branch_name: 'master', - author_email: author_email, - author_name: author_name) + repository.create_file( + user, 'NEWREADME', 'README!', + message: 'Add README', + branch_name: 'master', + author_email: author_email, + author_name: author_name + ) end.to change { repository.count_commits(ref: 'master') }.by(1) last_commit = repository.commit @@ -977,9 +1025,11 @@ RSpec.describe Repository, feature_category: :source_code_management do describe "#update_file" do it 'updates file successfully' do expect do - repository.update_file(user, 'CHANGELOG', 'Changelog!', - message: 'Update changelog', - branch_name: 'master') + repository.update_file( + user, 'CHANGELOG', 'Changelog!', + message: 'Update changelog', + branch_name: 'master' + ) end.to change { repository.count_commits(ref: 'master') }.by(1) blob = repository.blob_at('master', 'CHANGELOG') @@ -989,10 +1039,12 @@ RSpec.describe Repository, feature_category: :source_code_management do it 'updates filename successfully' do expect do - repository.update_file(user, 'NEWLICENSE', 'Copyright!', - branch_name: 'master', - previous_path: 'LICENSE', - message: 'Changes filename') + repository.update_file( + user, 'NEWLICENSE', 'Copyright!', + branch_name: 'master', + previous_path: 'LICENSE', + message: 'Changes filename' + ) end.to change { repository.count_commits(ref: 'master') }.by(1) files = repository.ls_files('master') @@ -1004,12 +1056,14 @@ RSpec.describe Repository, feature_category: :source_code_management do context "when an author is specified" do it "uses the given email/name to set the commit's author" do expect do - repository.update_file(user, 'README', 'Updated README!', - branch_name: 'master', - previous_path: 'README', - message: 'Update README', - author_email: author_email, - author_name: author_name) + repository.update_file( + user, 'README', 'Updated README!', + branch_name: 'master', + previous_path: 'README', + message: 'Update README', + author_email: author_email, + author_name: author_name + ) end.to change { repository.count_commits(ref: 'master') }.by(1) last_commit = repository.commit @@ -1020,13 +1074,45 @@ RSpec.describe Repository, feature_category: :source_code_management do end end + describe "#move_dir_files" do + it 'move directory files successfully' do + expect do + repository.move_dir_files( + user, 'files/new_js', 'files/js', + branch_name: 'master', + message: 'move directory images to new_images', + author_email: author_email, + author_name: author_name + ) + end.to change { repository.count_commits(ref: 'master') }.by(1) + files = repository.ls_files('master') + + expect(files).not_to include('files/js/application.js') + expect(files).to include('files/new_js/application.js') + end + + it 'skips commit with same path' do + expect do + repository.move_dir_files( + user, 'files/js', 'files/js', + branch_name: 'master', + message: 'no commit', + author_email: author_email, + author_name: author_name + ) + end.to change { repository.count_commits(ref: 'master') }.by(0) + end + end + describe "#delete_file" do - let(:project) { create(:project, :repository) } + let_it_be(:project) { create(:project, :repository) } it 'removes file successfully' do expect do - repository.delete_file(user, 'README', - message: 'Remove README', branch_name: 'master') + repository.delete_file( + user, 'README', + message: 'Remove README', branch_name: 'master' + ) end.to change { repository.count_commits(ref: 'master') }.by(1) expect(repository.blob_at('master', 'README')).to be_nil @@ -1035,9 +1121,11 @@ RSpec.describe Repository, feature_category: :source_code_management do context "when an author is specified" do it "uses the given email/name to set the commit's author" do expect do - repository.delete_file(user, 'README', + repository.delete_file( + user, 'README', message: 'Remove README', branch_name: 'master', - author_email: author_email, author_name: author_name) + author_email: author_email, author_name: author_name + ) end.to change { repository.count_commits(ref: 'master') }.by(1) last_commit = repository.commit @@ -1155,10 +1243,12 @@ RSpec.describe Repository, feature_category: :source_code_management do let(:path) { '*.md' } it 'returns files matching the path in the root folder' do - expect(result).to contain_exactly('CONTRIBUTING.md', - 'MAINTENANCE.md', - 'PROCESS.md', - 'README.md') + expect(result).to contain_exactly( + 'CONTRIBUTING.md', + 'MAINTENANCE.md', + 'PROCESS.md', + 'README.md' + ) end end @@ -1166,12 +1256,14 @@ RSpec.describe Repository, feature_category: :source_code_management do let(:path) { '**.md' } it 'returns all matching files in all folders' do - expect(result).to contain_exactly('CONTRIBUTING.md', - 'MAINTENANCE.md', - 'PROCESS.md', - 'README.md', - 'files/markdown/ruby-style-guide.md', - 'with space/README.md') + expect(result).to contain_exactly( + 'CONTRIBUTING.md', + 'MAINTENANCE.md', + 'PROCESS.md', + 'README.md', + 'files/markdown/ruby-style-guide.md', + 'with space/README.md' + ) end end @@ -1203,10 +1295,12 @@ RSpec.describe Repository, feature_category: :source_code_management do let(:path) { '**/*.rb' } it 'returns all matched files in all subfolders' do - expect(result).to contain_exactly('encoding/russian.rb', - 'files/ruby/popen.rb', - 'files/ruby/regex.rb', - 'files/ruby/version_info.rb') + expect(result).to contain_exactly( + 'encoding/russian.rb', + 'files/ruby/popen.rb', + 'files/ruby/regex.rb', + 'files/ruby/version_info.rb' + ) end end @@ -1422,46 +1516,47 @@ RSpec.describe Repository, feature_category: :source_code_management do end end - [true, false].each do |ff| - context "with feature flag license_from_gitaly=#{ff}" do - before do - stub_feature_flags(license_from_gitaly: ff) - end - - describe '#license', :use_clean_rails_memory_store_caching, :clean_gitlab_redis_cache do - let(:project) { create(:project, :repository) } + describe '#license', :use_clean_rails_memory_store_caching, :clean_gitlab_redis_cache do + let(:project) { create(:project, :repository) } - before do - repository.delete_file(user, 'LICENSE', - message: 'Remove LICENSE', branch_name: 'master') - end + before do + repository.delete_file( + user, 'LICENSE', + message: 'Remove LICENSE', + branch_name: 'master' + ) + end - it 'returns nil when no license is detected' do - expect(repository.license).to be_nil - end + it 'returns nil when no license is detected' do + expect(repository.license).to be_nil + end - it 'returns nil when the repository does not exist' do - expect(repository).to receive(:exists?).and_return(false) + it 'returns nil when the repository does not exist' do + expect(repository).to receive(:exists?).and_return(false) - expect(repository.license).to be_nil - end + expect(repository.license).to be_nil + end - it 'returns other when the content is not recognizable' do - repository.create_file(user, 'LICENSE', 'Gitlab B.V.', - message: 'Add LICENSE', branch_name: 'master') + it 'returns other when the content is not recognizable' do + repository.create_file( + user, 'LICENSE', 'Gitlab B.V.', + message: 'Add LICENSE', + branch_name: 'master' + ) - expect(repository.license_key).to eq('other') - end + expect(repository.license_key).to eq('other') + end - it 'returns the license' do - license = Licensee::License.new('mit') - repository.create_file(user, 'LICENSE', - license.content, - message: 'Add LICENSE', branch_name: 'master') + it 'returns the license' do + license = Licensee::License.new('mit') + repository.create_file( + user, 'LICENSE', + license.content, + message: 'Add LICENSE', + branch_name: 'master' + ) - expect(repository.license_key).to eq(license.key) - end - end + expect(repository.license_key).to eq(license.key) end end @@ -1987,19 +2082,23 @@ RSpec.describe Repository, feature_category: :source_code_management do describe '#merge_to_ref' do let(:merge_request) do - create(:merge_request, source_branch: 'feature', - target_branch: 'master', - source_project: project) + create( + :merge_request, + source_branch: 'feature', + target_branch: 'master', + source_project: project + ) end it 'writes merge of source SHA and first parent ref to MR merge_ref_path' do - merge_commit_id = - repository.merge_to_ref(user, - source_sha: merge_request.diff_head_sha, - branch: merge_request.target_branch, - target_ref: merge_request.merge_ref_path, - message: 'Custom message', - first_parent_ref: merge_request.target_branch_ref) + merge_commit_id = repository.merge_to_ref( + user, + source_sha: merge_request.diff_head_sha, + branch: merge_request.target_branch, + target_ref: merge_request.merge_ref_path, + message: 'Custom message', + first_parent_ref: merge_request.target_branch_ref + ) merge_commit = repository.commit(merge_commit_id) @@ -2021,11 +2120,13 @@ RSpec.describe Repository, feature_category: :source_code_management do end it 'merges the code and return the commit id' do - merge_commit_id = repository.ff_merge(user, - merge_request.diff_head_sha, - merge_request.target_branch, - target_sha: repository.commit(merge_request.target_branch).sha, - merge_request: merge_request) + merge_commit_id = repository.ff_merge( + user, + merge_request.diff_head_sha, + merge_request.target_branch, + target_sha: repository.commit(merge_request.target_branch).sha, + merge_request: merge_request + ) merge_commit = repository.commit(merge_commit_id) expect(merge_commit).to be_present @@ -2033,11 +2134,13 @@ RSpec.describe Repository, feature_category: :source_code_management do end it 'sets the `in_progress_merge_commit_sha` flag for the given merge request' do - merge_commit_id = repository.ff_merge(user, - merge_request.diff_head_sha, - merge_request.target_branch, - target_sha: repository.commit(merge_request.target_branch).sha, - merge_request: merge_request) + merge_commit_id = repository.ff_merge( + user, + merge_request.diff_head_sha, + merge_request.target_branch, + target_sha: repository.commit(merge_request.target_branch).sha, + merge_request: merge_request + ) expect(merge_request.in_progress_merge_commit_sha).to eq(merge_commit_id) end @@ -2302,7 +2405,6 @@ RSpec.describe Repository, feature_category: :source_code_management do :contribution_guide, :changelog, :license_blob, - :license_licensee, :license_gitaly, :gitignore, :gitlab_ci_yml, @@ -2957,11 +3059,10 @@ RSpec.describe Repository, feature_category: :source_code_management do describe '#refresh_method_caches' do it 'refreshes the caches of the given types' do expect(repository).to receive(:expire_method_caches) - .with(%i(readme_path license_blob license_licensee license_gitaly)) + .with(%i(readme_path license_blob license_gitaly)) expect(repository).to receive(:readme_path) expect(repository).to receive(:license_blob) - expect(repository).to receive(:license_licensee) expect(repository).to receive(:license_gitaly) repository.refresh_method_caches(%i(readme license)) diff --git a/spec/models/resource_events/abuse_report_event_spec.rb b/spec/models/resource_events/abuse_report_event_spec.rb new file mode 100644 index 00000000000..1c709ae4f21 --- /dev/null +++ b/spec/models/resource_events/abuse_report_event_spec.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe ResourceEvents::AbuseReportEvent, feature_category: :instance_resiliency, type: :model do + subject(:event) { build(:abuse_report_event) } + + describe 'associations' do + it { is_expected.to belong_to(:abuse_report).required } + it { is_expected.to belong_to(:user).optional } + end + + describe 'validations' do + it { is_expected.to be_valid } + it { is_expected.to validate_presence_of(:action) } + end +end diff --git a/spec/models/resource_events/issue_assignment_event_spec.rb b/spec/models/resource_events/issue_assignment_event_spec.rb new file mode 100644 index 00000000000..bc217da2812 --- /dev/null +++ b/spec/models/resource_events/issue_assignment_event_spec.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe ResourceEvents::IssueAssignmentEvent, feature_category: :value_stream_management, type: :model do + subject(:event) { build(:issue_assignment_event) } + + describe 'associations' do + it { is_expected.to belong_to(:user) } + it { is_expected.to belong_to(:issue) } + end + + describe 'validations' do + it { is_expected.to be_valid } + it { is_expected.to validate_presence_of(:issue) } + end +end diff --git a/spec/models/resource_events/merge_request_assignment_event_spec.rb b/spec/models/resource_events/merge_request_assignment_event_spec.rb new file mode 100644 index 00000000000..15f4c088333 --- /dev/null +++ b/spec/models/resource_events/merge_request_assignment_event_spec.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe ResourceEvents::MergeRequestAssignmentEvent, feature_category: :value_stream_management, type: :model do + subject(:event) { build(:merge_request_assignment_event) } + + describe 'associations' do + it { is_expected.to belong_to(:user) } + it { is_expected.to belong_to(:merge_request) } + end + + describe 'validations' do + it { is_expected.to be_valid } + it { is_expected.to validate_presence_of(:merge_request) } + end +end diff --git a/spec/models/resource_state_event_spec.rb b/spec/models/resource_state_event_spec.rb index a6d6b507b69..de101107268 100644 --- a/spec/models/resource_state_event_spec.rb +++ b/spec/models/resource_state_event_spec.rb @@ -45,8 +45,11 @@ RSpec.describe ResourceStateEvent, feature_category: :team_planning, type: :mode describe '#issue_usage_metrics' do describe 'when an issue is closed' do subject(:close_issue) do - create(described_class.name.underscore.to_sym, issue: issue, - state: described_class.states[:closed]) + create( + described_class.name.underscore.to_sym, + issue: issue, + state: described_class.states[:closed] + ) end it 'tracks closed issues' do @@ -65,8 +68,11 @@ RSpec.describe ResourceStateEvent, feature_category: :team_planning, type: :mode describe 'when an issue is reopened' do subject(:reopen_issue) do - create(described_class.name.underscore.to_sym, issue: issue, - state: described_class.states[:reopened]) + create( + described_class.name.underscore.to_sym, + issue: issue, + state: described_class.states[:reopened] + ) end it 'tracks reopened issues' do diff --git a/spec/models/serverless/domain_cluster_spec.rb b/spec/models/serverless/domain_cluster_spec.rb deleted file mode 100644 index 487385c62c1..00000000000 --- a/spec/models/serverless/domain_cluster_spec.rb +++ /dev/null @@ -1,75 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe ::Serverless::DomainCluster do - subject { create(:serverless_domain_cluster) } - - describe 'default values' do - subject(:domain_cluster) { build(:serverless_domain_cluster) } - - before do - allow(::Serverless::Domain).to receive(:generate_uuid).and_return('randomtoken') - end - - it { expect(domain_cluster.uuid).to eq('randomtoken') } - end - - describe 'validations' do - it { is_expected.to validate_presence_of(:pages_domain) } - it { is_expected.to validate_presence_of(:knative) } - - it { is_expected.to validate_presence_of(:uuid) } - it { is_expected.to validate_length_of(:uuid).is_equal_to(::Serverless::Domain::UUID_LENGTH) } - it { is_expected.to validate_uniqueness_of(:uuid) } - - it 'validates that uuid has only hex characters' do - subject = build(:serverless_domain_cluster, uuid: 'z1234567890123') - subject.valid? - - expect(subject.errors[:uuid]).to include('only allows hex characters') - end - end - - describe 'associations' do - it { is_expected.to belong_to(:pages_domain) } - it { is_expected.to belong_to(:knative) } - it { is_expected.to belong_to(:creator).optional } - end - - describe 'uuid' do - context 'when nil' do - it 'generates a value by default' do - attributes = build(:serverless_domain_cluster).attributes.merge(uuid: nil) - expect(::Serverless::Domain).to receive(:generate_uuid).and_call_original - - subject = Serverless::DomainCluster.new(attributes) - - expect(subject.uuid).not_to be_blank - end - end - - context 'when not nil' do - it 'does not override the existing value' do - uuid = 'abcd1234567890' - expect(build(:serverless_domain_cluster, uuid: uuid).uuid).to eq(uuid) - end - end - end - - describe 'cluster' do - it { is_expected.to respond_to(:cluster) } - end - - describe 'domain' do - it { is_expected.to respond_to(:domain) } - end - - describe 'certificate' do - it { is_expected.to respond_to(:certificate) } - end - - describe 'key' do - it { is_expected.to respond_to(:key) } - end -end diff --git a/spec/models/serverless/domain_spec.rb b/spec/models/serverless/domain_spec.rb deleted file mode 100644 index f997b28b149..00000000000 --- a/spec/models/serverless/domain_spec.rb +++ /dev/null @@ -1,97 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe ::Serverless::Domain do - let(:function_name) { 'test-function' } - let(:pages_domain_name) { 'serverless.gitlab.io' } - let(:pages_domain) { create(:pages_domain, :instance_serverless, domain: pages_domain_name) } - let!(:serverless_domain_cluster) { create(:serverless_domain_cluster, uuid: 'abcdef12345678', pages_domain: pages_domain) } - let(:valid_cluster_uuid) { 'aba1cdef123456f278' } - let(:invalid_cluster_uuid) { 'aba1cdef123456f178' } - let!(:environment) { create(:environment, name: 'test') } - - let(:valid_uri) { "https://#{function_name}-#{valid_cluster_uuid}#{"%x" % environment.id}-#{environment.slug}.#{pages_domain_name}" } - let(:valid_fqdn) { "#{function_name}-#{valid_cluster_uuid}#{"%x" % environment.id}-#{environment.slug}.#{pages_domain_name}" } - let(:invalid_uri) { "https://#{function_name}-#{invalid_cluster_uuid}#{"%x" % environment.id}-#{environment.slug}.#{pages_domain_name}" } - - shared_examples 'a valid Domain' do - describe '#uri' do - it 'matches valid URI' do - expect(subject.uri.to_s).to eq valid_uri - end - end - - describe '#function_name' do - it 'returns function_name' do - expect(subject.function_name).to eq function_name - end - end - - describe '#serverless_domain_cluster' do - it 'returns serverless_domain_cluster' do - expect(subject.serverless_domain_cluster).to eq serverless_domain_cluster - end - end - - describe '#environment' do - it 'returns environment' do - expect(subject.environment).to eq environment - end - end - end - - describe '.new' do - context 'with valid arguments' do - subject do - described_class.new( - function_name: function_name, - serverless_domain_cluster: serverless_domain_cluster, - environment: environment - ) - end - - it_behaves_like 'a valid Domain' - end - - context 'with invalid arguments' do - subject do - described_class.new( - function_name: function_name, - environment: environment - ) - end - - it { is_expected.not_to be_valid } - end - - context 'with nil cluster argument' do - subject do - described_class.new( - function_name: function_name, - serverless_domain_cluster: nil, - environment: environment - ) - end - - it { is_expected.not_to be_valid } - end - end - - describe '.generate_uuid' do - it 'has 14 characters' do - expect(described_class.generate_uuid.length).to eq(described_class::UUID_LENGTH) - end - - it 'consists of only hexadecimal characters' do - expect(described_class.generate_uuid).to match(/\A\h+\z/) - end - - it 'uses random characters' do - uuid = 'abcd1234567890' - - expect(SecureRandom).to receive(:hex).with(described_class::UUID_LENGTH / 2).and_return(uuid) - expect(described_class.generate_uuid).to eq(uuid) - end - end -end diff --git a/spec/models/serverless/function_spec.rb b/spec/models/serverless/function_spec.rb deleted file mode 100644 index 632f5eba5c3..00000000000 --- a/spec/models/serverless/function_spec.rb +++ /dev/null @@ -1,21 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe ::Serverless::Function do - let(:project) { create(:project) } - let(:func) { described_class.new(project, 'test', 'test-ns') } - - it 'has a proper id' do - expect(func.id).to eql("#{project.id}/test/test-ns") - expect(func.name).to eql("test") - expect(func.namespace).to eql("test-ns") - end - - it 'can decode an identifier' do - f = described_class.find_by_id("#{project.id}/testfunc/dummy-ns") - - expect(f.name).to eql("testfunc") - expect(f.namespace).to eql("dummy-ns") - end -end diff --git a/spec/models/service_desk/custom_email_credential_spec.rb b/spec/models/service_desk/custom_email_credential_spec.rb new file mode 100644 index 00000000000..a990b77128e --- /dev/null +++ b/spec/models/service_desk/custom_email_credential_spec.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe ServiceDesk::CustomEmailCredential, feature_category: :service_desk do + let(:project) { build_stubbed(:project) } + let(:credential) { build_stubbed(:service_desk_custom_email_credential, project: project) } + let(:smtp_username) { "user@example.com" } + let(:smtp_password) { "supersecret" } + + describe 'validations' do + it { is_expected.to validate_presence_of(:project) } + + it { is_expected.to validate_presence_of(:smtp_address) } + it { is_expected.to validate_length_of(:smtp_address).is_at_most(255) } + it { is_expected.to allow_value('smtp.gmail.com').for(:smtp_address) } + it { is_expected.to allow_value('1.1.1.1').for(:smtp_address) } + it { is_expected.to allow_value('199.1.1.1').for(:smtp_address) } + it { is_expected.not_to allow_value('https://example.com').for(:smtp_address) } + it { is_expected.not_to allow_value('file://example').for(:smtp_address) } + it { is_expected.not_to allow_value('/example').for(:smtp_address) } + it { is_expected.not_to allow_value('localhost').for(:smtp_address) } + it { is_expected.not_to allow_value('127.0.0.1').for(:smtp_address) } + it { is_expected.not_to allow_value('192.168.12.12').for(:smtp_address) } # disallow local network + + it { is_expected.to validate_presence_of(:smtp_port) } + it { is_expected.to validate_numericality_of(:smtp_port).only_integer.is_greater_than(0) } + + it { is_expected.to validate_presence_of(:smtp_username) } + it { is_expected.to validate_length_of(:smtp_username).is_at_most(255) } + + it { is_expected.to validate_presence_of(:smtp_password) } + it { is_expected.to validate_length_of(:smtp_password).is_at_least(8).is_at_most(128) } + end + + describe 'encrypted #smtp_username' do + subject { build_stubbed(:service_desk_custom_email_credential, smtp_username: smtp_username) } + + it 'saves and retrieves the encrypted smtp username and iv correctly' do + expect(subject.encrypted_smtp_username).not_to be_nil + expect(subject.encrypted_smtp_username_iv).not_to be_nil + + expect(subject.smtp_username).to eq(smtp_username) + end + end + + describe 'encrypted #smtp_password' do + subject { build_stubbed(:service_desk_custom_email_credential, smtp_password: smtp_password) } + + it 'saves and retrieves the encrypted smtp password and iv correctly' do + expect(subject.encrypted_smtp_password).not_to be_nil + expect(subject.encrypted_smtp_password_iv).not_to be_nil + + expect(subject.smtp_password).to eq(smtp_password) + end + end + + describe 'associations' do + it { is_expected.to belong_to(:project) } + + it 'can access service desk setting from project' do + setting = build_stubbed(:service_desk_setting, project: project) + + expect(credential.service_desk_setting).to eq(setting) + end + end +end diff --git a/spec/models/service_desk/custom_email_verification_spec.rb b/spec/models/service_desk/custom_email_verification_spec.rb new file mode 100644 index 00000000000..f114367cfbf --- /dev/null +++ b/spec/models/service_desk/custom_email_verification_spec.rb @@ -0,0 +1,170 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe ServiceDesk::CustomEmailVerification, feature_category: :service_desk do + let_it_be(:project) { create(:project) } + let_it_be(:user) { create(:user) } + + let(:generate_token_pattern) { /\A\p{Alnum}{12}\z/ } + + describe '.generate_token' do + it 'matches expected output' do + expect(described_class.generate_token).to match(generate_token_pattern) + end + end + + describe 'validations' do + subject { build(:service_desk_custom_email_verification, project: project) } + + it { is_expected.to validate_presence_of(:project) } + it { is_expected.to validate_presence_of(:state) } + + context 'when status is :started' do + before do + subject.mark_as_started!(user) + end + + it { is_expected.to validate_presence_of(:token) } + it { is_expected.to validate_length_of(:token).is_equal_to(12) } + + it 'matches .generate_token pattern' do + expect(subject.token).to match(generate_token_pattern) + end + + it { is_expected.to validate_presence_of(:triggerer) } + it { is_expected.to validate_presence_of(:triggered_at) } + it { is_expected.to validate_absence_of(:error) } + end + + context 'when status is :finished' do + before do + subject.mark_as_started!(user) + subject.mark_as_finished! + end + + it { is_expected.to validate_absence_of(:token) } + it { is_expected.to validate_absence_of(:error) } + end + + context 'when status is :failed' do + before do + subject.mark_as_started!(user) + subject.mark_as_failed!(:smtp_host_issue) + end + + it { is_expected.to validate_presence_of(:error) } + it { is_expected.to validate_absence_of(:token) } + end + end + + describe 'status state machine' do + subject { build(:service_desk_custom_email_verification, project: project) } + + describe 'transitioning to started' do + it 'records the started at time and generates token' do + subject.mark_as_started!(user) + + is_expected.to be_started + expect(subject.token).to be_present + expect(subject.triggered_at).to be_present + expect(subject.triggerer).to eq(user) + end + end + + describe 'transitioning to finished' do + it 'removes the generated token' do + subject.mark_as_started!(user) + subject.mark_as_finished! + + is_expected.to be_finished + expect(subject.token).not_to be_present + end + end + + describe 'transitioning to failed' do + let(:error) { :smtp_host_issue } + + it 'removes the generated token' do + subject.mark_as_started!(user) + subject.mark_as_failed!(error) + + is_expected.to be_failed + expect(subject.token).not_to be_present + expect(subject.error).to eq(error.to_s) + end + end + end + + describe '#accepted_until' do + it 'returns nil' do + expect(subject.accepted_until).to be_nil + end + + context 'when state is :started and successfully transitioned' do + let(:triggered_at) { 2.minutes.ago } + + before do + subject.project = project + subject.mark_as_started!(user) + end + + it 'returns correct timeframe end time' do + expect(subject.accepted_until).to eq(described_class::TIMEFRAME.since(subject.triggered_at)) + end + + context 'when triggered_at is not set' do + it 'returns nil' do + subject.triggered_at = nil + expect(subject.accepted_until).to be nil + end + end + end + end + + describe '#in_timeframe?' do + it { is_expected.not_to be_in_timeframe } + + context 'when state is :started and successfully transitioned' do + before do + subject.project = project + subject.mark_as_started!(user) + end + + it { is_expected.to be_in_timeframe } + + context 'and timeframe was missed' do + before do + subject.triggered_at = (described_class::TIMEFRAME + 1).ago + end + + it { is_expected.not_to be_in_timeframe } + end + end + end + + describe 'encrypted #token' do + let(:token) { 'XXXXXXXXXXXX' } + + subject { build_stubbed(:service_desk_custom_email_verification, token: token) } + + it 'saves and retrieves the encrypted token and iv correctly' do + expect(subject.encrypted_token).not_to be_nil + expect(subject.encrypted_token_iv).not_to be_nil + + expect(subject.token).to eq(token) + end + end + + describe 'associations' do + it { is_expected.to belong_to(:project) } + it { is_expected.to belong_to(:triggerer) } + + it 'can access service desk setting from project' do + subject.project = project + setting = build_stubbed(:service_desk_setting, project: subject.project) + + expect(subject.service_desk_setting).to eq(setting) + end + end +end diff --git a/spec/models/service_desk_setting_spec.rb b/spec/models/service_desk_setting_spec.rb index 32c36375a3d..b9679b82bd0 100644 --- a/spec/models/service_desk_setting_spec.rb +++ b/spec/models/service_desk_setting_spec.rb @@ -3,6 +3,9 @@ require 'spec_helper' RSpec.describe ServiceDeskSetting, feature_category: :service_desk do + let(:verification) { build(:service_desk_custom_email_verification) } + let(:project) { build(:project) } + describe 'validations' do subject(:service_desk_setting) { create(:service_desk_setting) } @@ -13,8 +16,6 @@ RSpec.describe ServiceDeskSetting, feature_category: :service_desk do it { is_expected.not_to allow_value('abc 12').for(:project_key).with_message("can contain only lowercase letters, digits, and '_'.") } it { is_expected.not_to allow_value('Big val').for(:project_key) } it { is_expected.to validate_length_of(:custom_email).is_at_most(255) } - it { is_expected.to validate_length_of(:custom_email_smtp_address).is_at_most(255) } - it { is_expected.to validate_length_of(:custom_email_smtp_username).is_at_most(255) } describe '#custom_email_enabled' do it { expect(subject.custom_email_enabled).to be_falsey } @@ -23,6 +24,7 @@ RSpec.describe ServiceDeskSetting, feature_category: :service_desk do context 'when custom_email_enabled is true' do before do + # Test without ServiceDesk::CustomEmailVerification for simplicity subject.custom_email_enabled = true end @@ -42,20 +44,9 @@ RSpec.describe ServiceDeskSetting, feature_category: :service_desk do it { is_expected.not_to allow_value('"><script>alert(1);</script>"@example.org').for(:custom_email) } it { is_expected.not_to allow_value('file://example').for(:custom_email) } it { is_expected.not_to allow_value('no email at all').for(:custom_email) } - - it { is_expected.to validate_presence_of(:custom_email_smtp_username) } - - it { is_expected.to validate_presence_of(:custom_email_smtp_port) } - it { is_expected.to validate_numericality_of(:custom_email_smtp_port).only_integer.is_greater_than(0) } - - it { is_expected.to validate_presence_of(:custom_email_smtp_address) } - it { is_expected.to allow_value('smtp.gmail.com').for(:custom_email_smtp_address) } - it { is_expected.not_to allow_value('https://example.com').for(:custom_email_smtp_address) } - it { is_expected.not_to allow_value('file://example').for(:custom_email_smtp_address) } - it { is_expected.not_to allow_value('/example').for(:custom_email_smtp_address) } end - describe '.valid_issue_template' do + describe '#valid_issue_template' do let_it_be(:project) { create(:project, :custom_repo, files: { '.gitlab/issue_templates/service_desk.md' => 'template' }) } it 'is not valid if template does not exist' do @@ -73,13 +64,26 @@ RSpec.describe ServiceDeskSetting, feature_category: :service_desk do end end - describe '.valid_project_key' do + describe '#custom_email_address_for_verification' do + it 'returns nil' do + expect(subject.custom_email_address_for_verification).to be_nil + end + + context 'when custom_email exists' do + it 'returns correct verification address' do + subject.custom_email = 'support@example.com' + expect(subject.custom_email_address_for_verification).to eq('support+verify@example.com') + end + end + end + + describe '#valid_project_key' do # Creates two projects with same full path slug # group1/test/one and group1/test-one will both have 'group-test-one' slug let_it_be(:group) { create(:group) } let_it_be(:subgroup) { create(:group, parent: group, name: 'test') } - let_it_be(:project1) { create(:project, name: 'test-one', group: group) } - let_it_be(:project2) { create(:project, name: 'one', group: subgroup) } + let_it_be(:project1) { create(:project, path: 'test-one', group: group) } + let_it_be(:project2) { create(:project, path: 'one', group: subgroup) } let_it_be(:project_key) { 'key' } let!(:setting) do create(:service_desk_setting, project: project1, project_key: project_key) @@ -109,28 +113,21 @@ RSpec.describe ServiceDeskSetting, feature_category: :service_desk do end end - describe 'encrypted password' do - let_it_be(:settings) do - create( + describe 'associations' do + let(:custom_email_settings) do + build_stubbed( :service_desk_setting, - custom_email_enabled: true, - custom_email: 'supersupport@example.com', - custom_email_smtp_address: 'smtp.example.com', - custom_email_smtp_port: 587, - custom_email_smtp_username: 'supersupport@example.com', - custom_email_smtp_password: 'supersecret' + custom_email: 'support@example.com' ) end - it 'saves and retrieves the encrypted custom email smtp password and iv correctly' do - expect(settings.encrypted_custom_email_smtp_password).not_to be_nil - expect(settings.encrypted_custom_email_smtp_password_iv).not_to be_nil + it { is_expected.to belong_to(:project) } - expect(settings.custom_email_smtp_password).to eq('supersecret') - end - end + it 'can access custom email verification from project' do + project.service_desk_custom_email_verification = verification + custom_email_settings.project = project - describe 'associations' do - it { is_expected.to belong_to(:project) } + expect(custom_email_settings.custom_email_verification).to eq(verification) + end end end diff --git a/spec/models/slack_integration_spec.rb b/spec/models/slack_integration_spec.rb new file mode 100644 index 00000000000..41beeee598c --- /dev/null +++ b/spec/models/slack_integration_spec.rb @@ -0,0 +1,147 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe SlackIntegration, feature_category: :integrations do + describe "Associations" do + it { is_expected.to belong_to(:integration) } + end + + describe 'authorized_scope_names' do + subject(:slack_integration) { create(:slack_integration) } + + it 'accepts assignment to nil' do + slack_integration.update!(authorized_scope_names: nil) + + expect(slack_integration.authorized_scope_names).to be_empty + end + + it 'accepts assignment to a string' do + slack_integration.update!(authorized_scope_names: 'foo') + + expect(slack_integration.authorized_scope_names).to contain_exactly('foo') + end + + it 'accepts assignment to an array of strings' do + slack_integration.update!(authorized_scope_names: %w[foo bar]) + + expect(slack_integration.authorized_scope_names).to contain_exactly('foo', 'bar') + end + + it 'accepts assignment to a comma-separated string' do + slack_integration.update!(authorized_scope_names: 'foo,bar') + + expect(slack_integration.authorized_scope_names).to contain_exactly('foo', 'bar') + end + + it 'strips white-space' do + slack_integration.update!(authorized_scope_names: 'foo , bar,baz') + + expect(slack_integration.authorized_scope_names).to contain_exactly('foo', 'bar', 'baz') + end + end + + describe 'all_features_supported?/upgrade_needed?' do + subject(:slack_integration) { create(:slack_integration) } + + context 'with enough scopes' do + before do + slack_integration.update!(authorized_scope_names: %w[chat:write.public chat:write commands]) + end + + it { is_expected.to be_all_features_supported } + it { is_expected.not_to be_upgrade_needed } + end + + %w[chat:write.public chat:write].each do |scope_name| + context "without #{scope_name}" do + before do + scopes = %w[chat:write.public chat:write] - [scope_name] + slack_integration.update!(authorized_scope_names: scopes) + end + + it { is_expected.not_to be_all_features_supported } + it { is_expected.to be_upgrade_needed } + end + end + end + + describe 'feature_available?' do + subject(:slack_integration) { create(:slack_integration) } + + context 'without any scopes' do + it 'is always true for :commands' do + expect(slack_integration).to be_feature_available(:commands) + end + + it 'is always false for others' do + expect(slack_integration).not_to be_feature_available(:notifications) + expect(slack_integration).not_to be_feature_available(:foo) + end + end + + context 'with enough scopes for notifications' do + before do + slack_integration.update!(authorized_scope_names: %w[chat:write.public chat:write foo]) + end + + it 'only has the correct features' do + expect(slack_integration).to be_feature_available(:commands) + expect(slack_integration).to be_feature_available(:notifications) + expect(slack_integration).not_to be_feature_available(:foo) + end + end + + context 'with enough scopes for commands' do + before do + slack_integration.update!(authorized_scope_names: %w[commands foo]) + end + + it 'only has the correct features' do + expect(slack_integration).to be_feature_available(:commands) + expect(slack_integration).not_to be_feature_available(:notifications) + expect(slack_integration).not_to be_feature_available(:foo) + end + end + + context 'with all scopes' do + before do + slack_integration.update!(authorized_scope_names: %w[commands chat:write chat:write.public]) + end + + it 'only has the correct features' do + expect(slack_integration).to be_feature_available(:commands) + expect(slack_integration).to be_feature_available(:notifications) + expect(slack_integration).not_to be_feature_available(:foo) + end + end + end + + describe 'Scopes' do + let_it_be(:slack_integration) { create(:slack_integration) } + let_it_be(:legacy_slack_integration) { create(:slack_integration, :legacy) } + + describe '#with_bot' do + it 'returns records with bot data' do + expect(described_class.with_bot).to contain_exactly(slack_integration) + end + end + + describe '#by_team' do + it 'returns records with shared team_id' do + team_id = slack_integration.team_id + team_slack_integration = create(:slack_integration, team_id: team_id) + + expect(described_class.by_team(team_id)).to contain_exactly(slack_integration, team_slack_integration) + end + end + end + + describe 'Validations' do + it { is_expected.to validate_presence_of(:team_id) } + it { is_expected.to validate_presence_of(:team_name) } + it { is_expected.to validate_presence_of(:alias) } + it { is_expected.to validate_presence_of(:user_id) } + it { is_expected.to validate_presence_of(:integration) } + end +end diff --git a/spec/models/snippet_spec.rb b/spec/models/snippet_spec.rb index da1f2653676..6a5456fce3f 100644 --- a/spec/models/snippet_spec.rb +++ b/spec/models/snippet_spec.rb @@ -41,8 +41,8 @@ RSpec.describe Snippet do is_expected .to validate_length_of(:content) - .is_at_most(Gitlab::CurrentSettings.snippet_size_limit) - .with_message("is too long (2 Bytes). The maximum size is 1 Byte.") + .is_at_most(Gitlab::CurrentSettings.snippet_size_limit) + .with_message("is too long (2 Bytes). The maximum size is 1 Byte.") end context 'content validations' do @@ -145,7 +145,7 @@ RSpec.describe Snippet do describe '#to_reference' do context 'when snippet belongs to a project' do - let(:project) { build(:project, name: 'sample-project') } + let(:project) { build(:project) } let(:snippet) { build(:snippet, id: 1, project: project) } it 'returns a String reference to the object' do @@ -153,8 +153,8 @@ RSpec.describe Snippet do end it 'supports a cross-project reference' do - another_project = build(:project, name: 'another-project', namespace: project.namespace) - expect(snippet.to_reference(another_project)).to eq "sample-project$1" + another_project = build(:project, namespace: project.namespace) + expect(snippet.to_reference(another_project)).to eq "#{project.path}$1" end end @@ -166,7 +166,7 @@ RSpec.describe Snippet do end it 'still returns shortest reference when project arg present' do - another_project = build(:project, name: 'another-project') + another_project = build(:project) expect(snippet.to_reference(another_project)).to eq "$1" end end @@ -492,17 +492,21 @@ RSpec.describe Snippet do let_it_be(:snippet) { create(:snippet, content: 'foo', project: project) } let_it_be(:note1) do - create(:note_on_project_snippet, - noteable: snippet, - project: project, - note: 'a') + create( + :note_on_project_snippet, + noteable: snippet, + project: project, + note: 'a' + ) end let_it_be(:note2) do - create(:note_on_project_snippet, - noteable: snippet, - project: project, - note: 'b') + create( + :note_on_project_snippet, + noteable: snippet, + project: project, + note: 'b' + ) end it 'includes the snippet author and note authors' do diff --git a/spec/models/spam_log_spec.rb b/spec/models/spam_log_spec.rb index 564710b31d0..549a4e92ce0 100644 --- a/spec/models/spam_log_spec.rb +++ b/spec/models/spam_log_spec.rb @@ -30,8 +30,7 @@ RSpec.describe SpamLog do end expect( - Users::GhostUserMigration.where(user: user, - initiator_user: admin) + Users::GhostUserMigration.where(user: user, initiator_user: admin) ).to be_exists end end diff --git a/spec/models/terraform/state_spec.rb b/spec/models/terraform/state_spec.rb index 533e6e4bd7b..fc0a6432149 100644 --- a/spec/models/terraform/state_spec.rb +++ b/spec/models/terraform/state_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe Terraform::State do +RSpec.describe Terraform::State, feature_category: :infrastructure_as_code do subject { create(:terraform_state, :with_version) } it { is_expected.to belong_to(:project) } diff --git a/spec/models/terraform/state_version_spec.rb b/spec/models/terraform/state_version_spec.rb index 477041117cb..a476b9e79ae 100644 --- a/spec/models/terraform/state_version_spec.rb +++ b/spec/models/terraform/state_version_spec.rb @@ -2,11 +2,11 @@ require 'spec_helper' -RSpec.describe Terraform::StateVersion do +RSpec.describe Terraform::StateVersion, feature_category: :infrastructure_as_code do it { is_expected.to be_a FileStoreMounter } it { is_expected.to be_a EachBatch } - it { is_expected.to belong_to(:terraform_state).required } + it { is_expected.to belong_to(:terraform_state).required.touch } it { is_expected.to belong_to(:created_by_user).class_name('User').optional } it { is_expected.to belong_to(:build).class_name('Ci::Build').optional } diff --git a/spec/models/u2f_registration_spec.rb b/spec/models/u2f_registration_spec.rb deleted file mode 100644 index 1fab3882c2a..00000000000 --- a/spec/models/u2f_registration_spec.rb +++ /dev/null @@ -1,141 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe U2fRegistration do - let_it_be(:user) { create(:user) } - - let(:u2f_registration_name) { 'u2f_device' } - let(:app_id) { FFaker::BaconIpsum.characters(5) } - let(:device) { U2F::FakeU2F.new(app_id) } - - describe '.authenticate' do - context 'when registration is found' do - it 'returns true' do - create_u2f_registration - device_challenge = U2F.urlsafe_encode64(SecureRandom.random_bytes(32)) - sign_response_json = device.sign_response(device_challenge) - - response = U2fRegistration.authenticate( - user, - app_id, - sign_response_json, - device_challenge - ) - - expect(response).to eq true - end - end - - context 'when registration not found' do - it 'returns nil' do - device_challenge = U2F.urlsafe_encode64(SecureRandom.random_bytes(32)) - sign_response_json = device.sign_response(device_challenge) - - # data is valid but user does not have any u2f_registrations - response = U2fRegistration.authenticate( - user, - app_id, - sign_response_json, - device_challenge - ) - - expect(response).to eq nil - end - end - - context 'when args passed in are invalid' do - it 'returns false' do - some_app_id = 123 - invalid_json = 'invalid JSON' - challenges = 'whatever' - - response = U2fRegistration.authenticate( - user, - some_app_id, - invalid_json, - challenges - ) - - expect(response).to eq false - end - end - end - - describe 'callbacks' do - describe 'after create' do - shared_examples_for 'creates webauthn registration' do - it 'creates webauthn registration' do - u2f_registration = create_u2f_registration - webauthn_registration = WebauthnRegistration.where(u2f_registration_id: u2f_registration.id) - expect(webauthn_registration).to exist - end - end - - it_behaves_like 'creates webauthn registration' - - context 'when the u2f_registration has a blank name' do - let(:u2f_registration_name) { '' } - - it_behaves_like 'creates webauthn registration' - end - - context 'when the u2f_registration has the name as `nil`' do - let(:u2f_registration_name) { nil } - - it_behaves_like 'creates webauthn registration' - end - - it 'logs error' do - allow(Gitlab::Auth::U2fWebauthnConverter).to receive(:new).and_raise('boom!') - - allow_next_instance_of(U2fRegistration) do |u2f_registration| - allow(u2f_registration).to receive(:id).and_return(123) - end - - expect(Gitlab::ErrorTracking).to( - receive(:track_exception).with(kind_of(StandardError), - u2f_registration_id: 123)) - - create_u2f_registration - end - end - - describe 'after update' do - context 'when counter is updated' do - it 'updates the webauthn registration counter to be the same value' do - u2f_registration = create_u2f_registration - new_counter = u2f_registration.counter + 1 - webauthn_registration = WebauthnRegistration.find_by(u2f_registration_id: u2f_registration.id) - - u2f_registration.update!(counter: new_counter) - - expect(u2f_registration.reload.counter).to eq(new_counter) - expect(webauthn_registration.reload.counter).to eq(new_counter) - end - end - - context 'when sign count of registration is not updated' do - it 'does not update the counter' do - u2f_registration = create_u2f_registration - webauthn_registration = WebauthnRegistration.find_by(u2f_registration_id: u2f_registration.id) - - expect do - u2f_registration.update!(name: 'a new name') - end.not_to change { webauthn_registration.counter } - end - end - end - end - - def create_u2f_registration - create( - :u2f_registration, - name: u2f_registration_name, - user: user, - certificate: Base64.strict_encode64(device.cert_raw), - key_handle: U2F.urlsafe_encode64(device.key_handle_raw), - public_key: Base64.strict_encode64(device.origin_public_key_raw) - ) - end -end diff --git a/spec/models/upload_spec.rb b/spec/models/upload_spec.rb index cdf73b203af..27e2060a94b 100644 --- a/spec/models/upload_spec.rb +++ b/spec/models/upload_spec.rb @@ -96,9 +96,11 @@ RSpec.describe Upload do describe '#calculate_checksum!' do let(:upload) do - described_class.new(path: __FILE__, - size: described_class::CHECKSUM_THRESHOLD - 1.megabyte, - store: ObjectStorage::Store::LOCAL) + described_class.new( + path: __FILE__, + size: described_class::CHECKSUM_THRESHOLD - 1.megabyte, + store: ObjectStorage::Store::LOCAL + ) end it 'sets `checksum` to SHA256 sum of the file' do diff --git a/spec/models/user_detail_spec.rb b/spec/models/user_detail_spec.rb index 7d433896cf8..428fd5470c3 100644 --- a/spec/models/user_detail_spec.rb +++ b/spec/models/user_detail_spec.rb @@ -91,15 +91,17 @@ RSpec.describe UserDetail do describe '#save' do let(:user_detail) do - create(:user_detail, - bio: 'bio', - discord: '1234567890123456789', - linkedin: 'linkedin', - location: 'location', - organization: 'organization', - skype: 'skype', - twitter: 'twitter', - website_url: 'https://example.com') + create( + :user_detail, + bio: 'bio', + discord: '1234567890123456789', + linkedin: 'linkedin', + location: 'location', + organization: 'organization', + skype: 'skype', + twitter: 'twitter', + website_url: 'https://example.com' + ) end shared_examples 'prevents `nil` value' do |attr| diff --git a/spec/models/user_preference_spec.rb b/spec/models/user_preference_spec.rb index a6f64c90657..1d7ecb724bf 100644 --- a/spec/models/user_preference_spec.rb +++ b/spec/models/user_preference_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe UserPreference do +RSpec.describe UserPreference, feature_category: :user_profile do let_it_be(:user) { create(:user) } let(:user_preference) { create(:user_preference, user: user) } @@ -48,11 +48,21 @@ RSpec.describe UserPreference do end end - describe 'use_legacy_web_ide' do - it { is_expected.to allow_value(true).for(:use_legacy_web_ide) } - it { is_expected.to allow_value(false).for(:use_legacy_web_ide) } - it { is_expected.not_to allow_value(nil).for(:use_legacy_web_ide) } - it { is_expected.not_to allow_value("").for(:use_legacy_web_ide) } + describe 'pass_user_identities_to_ci_jwt' do + it { is_expected.to allow_value(true).for(:pass_user_identities_to_ci_jwt) } + it { is_expected.to allow_value(false).for(:pass_user_identities_to_ci_jwt) } + it { is_expected.not_to allow_value(nil).for(:pass_user_identities_to_ci_jwt) } + it { is_expected.not_to allow_value("").for(:pass_user_identities_to_ci_jwt) } + end + + describe 'visibility_pipeline_id_type' do + it 'is set to 0 by default' do + pref = described_class.new + + expect(pref.visibility_pipeline_id_type).to eq('id') + end + + it { is_expected.to define_enum_for(:visibility_pipeline_id_type).with_values(id: 0, iid: 1) } end end @@ -215,47 +225,6 @@ RSpec.describe UserPreference do end end - describe '#time_format_in_24h' do - it 'is set to false by default' do - pref = described_class.new - - expect(pref.time_format_in_24h).to eq(false) - end - - it 'returns default value when assigning nil' do - pref = described_class.new(time_format_in_24h: nil) - - expect(pref.time_format_in_24h).to eq(false) - end - - it 'returns default value when the value is NULL' do - pref = create(:user_preference, user: user) - pref.update_column(:time_format_in_24h, nil) - - expect(pref.reload.time_format_in_24h).to eq(false) - end - - it 'returns assigned value' do - pref = described_class.new(time_format_in_24h: true) - - expect(pref.time_format_in_24h).to eq(true) - end - end - - describe '#time_format_in_24h=' do - it 'sets to default value when nil' do - pref = described_class.new(time_format_in_24h: nil) - - expect(pref.read_attribute(:time_format_in_24h)).to eq(false) - end - - it 'sets user values' do - pref = described_class.new(time_format_in_24h: true) - - expect(pref.read_attribute(:time_format_in_24h)).to eq(true) - end - end - describe '#render_whitespace_in_code' do it 'is set to false by default' do pref = described_class.new diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index e87667d9604..c73dac7251e 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -42,9 +42,6 @@ RSpec.describe User, feature_category: :user_profile do it { is_expected.to delegate_method(:time_display_relative).to(:user_preference) } it { is_expected.to delegate_method(:time_display_relative=).to(:user_preference).with_arguments(:args) } - it { is_expected.to delegate_method(:time_format_in_24h).to(:user_preference) } - it { is_expected.to delegate_method(:time_format_in_24h=).to(:user_preference).with_arguments(:args) } - it { is_expected.to delegate_method(:show_whitespace_in_diffs).to(:user_preference) } it { is_expected.to delegate_method(:show_whitespace_in_diffs=).to(:user_preference).with_arguments(:args) } @@ -78,12 +75,15 @@ RSpec.describe User, feature_category: :user_profile do it { is_expected.to delegate_method(:diffs_addition_color).to(:user_preference) } it { is_expected.to delegate_method(:diffs_addition_color=).to(:user_preference).with_arguments(:args) } - it { is_expected.to delegate_method(:use_legacy_web_ide).to(:user_preference) } - it { is_expected.to delegate_method(:use_legacy_web_ide=).to(:user_preference).with_arguments(:args) } - it { is_expected.to delegate_method(:use_new_navigation).to(:user_preference) } it { is_expected.to delegate_method(:use_new_navigation=).to(:user_preference).with_arguments(:args) } + it { is_expected.to delegate_method(:pinned_nav_items).to(:user_preference) } + it { is_expected.to delegate_method(:pinned_nav_items=).to(:user_preference).with_arguments(:args) } + + it { is_expected.to delegate_method(:achievements_enabled).to(:user_preference) } + it { is_expected.to delegate_method(:achievements_enabled=).to(:user_preference).with_arguments(:args) } + it { is_expected.to delegate_method(:job_title).to(:user_detail).allow_nil } it { is_expected.to delegate_method(:job_title=).to(:user_detail).with_arguments(:args).allow_nil } @@ -99,9 +99,6 @@ RSpec.describe User, feature_category: :user_profile do it { is_expected.to delegate_method(:registration_objective).to(:user_detail).allow_nil } it { is_expected.to delegate_method(:registration_objective=).to(:user_detail).with_arguments(:args).allow_nil } - it { is_expected.to delegate_method(:requires_credit_card_verification).to(:user_detail).allow_nil } - it { is_expected.to delegate_method(:requires_credit_card_verification=).to(:user_detail).with_arguments(:args).allow_nil } - it { is_expected.to delegate_method(:discord).to(:user_detail).allow_nil } it { is_expected.to delegate_method(:discord=).to(:user_detail).with_arguments(:args).allow_nil } @@ -174,6 +171,14 @@ RSpec.describe User, feature_category: :user_profile do it { is_expected.to have_many(:revoked_user_achievements).class_name('Achievements::UserAchievement').with_foreign_key('revoked_by_user_id').inverse_of(:revoked_by_user) } it { is_expected.to have_many(:achievements).through(:user_achievements).class_name('Achievements::Achievement').inverse_of(:users) } it { is_expected.to have_many(:namespace_commit_emails).class_name('Users::NamespaceCommitEmail') } + it { is_expected.to have_many(:audit_events).with_foreign_key(:author_id).inverse_of(:user) } + it { is_expected.to have_many(:abuse_trust_scores).class_name('Abuse::TrustScore') } + it { is_expected.to have_many(:issue_assignment_events).class_name('ResourceEvents::IssueAssignmentEvent') } + it { is_expected.to have_many(:merge_request_assignment_events).class_name('ResourceEvents::MergeRequestAssignmentEvent') } + + it do + is_expected.to have_many(:alert_assignees).class_name('::AlertManagement::AlertAssignee').inverse_of(:assignee) + end describe 'default values' do let(:user) { described_class.new } @@ -188,6 +193,7 @@ RSpec.describe User, feature_category: :user_profile do it { expect(user.notified_of_own_activity).to be_falsey } it { expect(user.preferred_language).to eq(Gitlab::CurrentSettings.default_preferred_language) } it { expect(user.theme_id).to eq(described_class.gitlab_config.default_theme) } + it { expect(user.color_scheme_id).to eq(Gitlab::CurrentSettings.default_syntax_highlighting_theme) } end describe '#user_detail' do @@ -282,6 +288,31 @@ RSpec.describe User, feature_category: :user_profile do end end + describe '#abuse_metadata' do + let_it_be(:user) { create(:user) } + let_it_be(:contribution_calendar) { Gitlab::ContributionsCalendar.new(user) } + + before do + allow(Gitlab::ContributionsCalendar).to receive(:new).and_return(contribution_calendar) + allow(contribution_calendar).to receive(:activity_dates).and_return({ first: 3, second: 5, third: 4 }) + + allow(user).to receive_messages( + account_age_in_days: 10, + two_factor_enabled?: true + ) + end + + it 'returns the expected hash' do + abuse_metadata = user.abuse_metadata + + expect(abuse_metadata.length).to eq 2 + expect(abuse_metadata).to include( + account_age: 10, + two_factor_enabled: 1 + ) + end + end + describe '#group_members' do it 'does not include group memberships for which user is a requester' do user = create(:user) @@ -474,6 +505,37 @@ RSpec.describe User, feature_category: :user_profile do expect(user).to be_valid end end + + context 'namespace_move_dir_allowed' do + context 'when the user is not a new record' do + before do + expect(user.new_record?).to eq(false) + end + + it 'checks when username changes' do + expect(user).to receive(:namespace_move_dir_allowed) + + user.username = 'newuser' + user.validate + end + + it 'does not check if the username did not change' do + expect(user).not_to receive(:namespace_move_dir_allowed) + expect(user.username_changed?).to eq(false) + + user.validate + end + end + + it 'does not check if the user is a new record' do + user = User.new(username: 'newuser') + + expect(user.new_record?).to eq(true) + expect(user).not_to receive(:namespace_move_dir_allowed) + + user.validate + end + end end describe 'name' do @@ -978,10 +1040,6 @@ RSpec.describe User, feature_category: :user_profile do end end - describe 'and U2F' do - it_behaves_like "returns the right users", :two_factor_via_u2f - end - describe 'and WebAuthn' do it_behaves_like "returns the right users", :two_factor_via_webauthn end @@ -997,26 +1055,6 @@ RSpec.describe User, feature_category: :user_profile do expect(users_without_two_factor).not_to include(user_with_2fa.id) end - describe 'and u2f' do - it 'excludes users with 2fa enabled via U2F' do - user_with_2fa = create(:user, :two_factor_via_u2f) - user_without_2fa = create(:user) - users_without_two_factor = described_class.without_two_factor.pluck(:id) - - expect(users_without_two_factor).to include(user_without_2fa.id) - expect(users_without_two_factor).not_to include(user_with_2fa.id) - end - - it 'excludes users with 2fa enabled via OTP and U2F' do - user_with_2fa = create(:user, :two_factor_via_otp, :two_factor_via_u2f) - user_without_2fa = create(:user) - users_without_two_factor = described_class.without_two_factor.pluck(:id) - - expect(users_without_two_factor).to include(user_without_2fa.id) - expect(users_without_two_factor).not_to include(user_with_2fa.id) - end - end - describe 'and webauthn' do it 'excludes users with 2fa enabled via WebAuthn' do user_with_2fa = create(:user, :two_factor_via_webauthn) @@ -2076,7 +2114,7 @@ RSpec.describe User, feature_category: :user_profile do let_it_be(:incoming_email_token) { 'ilqx6jm1u945macft4eff0nw' } it 'returns incoming email token when supported' do - allow(Gitlab::IncomingEmail).to receive(:supports_issue_creation?).and_return(true) + allow(Gitlab::Email::IncomingEmail).to receive(:supports_issue_creation?).and_return(true) user = create(:user, incoming_email_token: incoming_email_token) @@ -2084,7 +2122,7 @@ RSpec.describe User, feature_category: :user_profile do end it 'returns `nil` when not supported' do - allow(Gitlab::IncomingEmail).to receive(:supports_issue_creation?).and_return(false) + allow(Gitlab::Email::IncomingEmail).to receive(:supports_issue_creation?).and_return(false) user = create(:user, incoming_email_token: incoming_email_token) @@ -2112,6 +2150,59 @@ RSpec.describe User, feature_category: :user_profile do end end + describe '#remember_me!' do + let(:user) { create(:user) } + + context 'when remember me application setting is enabled' do + before do + stub_application_setting(remember_me_enabled: true) + end + + it 'sets rememberable attributes' do + expect(user.remember_created_at).to be_nil + + user.remember_me! + + expect(user.remember_created_at).not_to be_nil + end + end + + context 'when remember me application setting is not enabled' do + before do + stub_application_setting(remember_me_enabled: false) + end + + it 'sets rememberable attributes' do + expect(user.remember_created_at).to be_nil + + user.remember_me! + + expect(user.remember_created_at).to be_nil + end + end + end + + describe '#forget_me!' do + let(:user) { create(:user) } + + context 'when remember me application setting is disabled' do + before do + stub_application_setting(remember_me_enabled: true) + end + + it 'allows user to be forgotten when previously remembered' do + user.remember_me! + + expect(user.remember_created_at).not_to be_nil + + stub_application_setting(remember_me_enabled: false) + user.forget_me! + + expect(user.remember_created_at).to be_nil + end + end + end + describe '#disable_two_factor!' do it 'clears all 2FA-related fields' do user = create(:user, :two_factor) @@ -2174,6 +2265,24 @@ RSpec.describe User, feature_category: :user_profile do end end + context 'Duo Auth' do + context 'when enabled via GitLab settings' do + before do + allow(::Gitlab.config.duo_auth).to receive(:enabled).and_return(true) + end + + it { expect(user.two_factor_otp_enabled?).to eq(true) } + end + + context 'when disabled via GitLab settings' do + before do + allow(::Gitlab.config.duo_auth).to receive(:enabled).and_return(false) + end + + it { expect(user.two_factor_otp_enabled?).to eq(false) } + end + end + context 'FortiTokenCloud' do context 'when enabled via GitLab settings' do before do @@ -2207,54 +2316,6 @@ RSpec.describe User, feature_category: :user_profile do end end - context 'two_factor_u2f_enabled?' do - let_it_be(:user) { create(:user, :two_factor) } - - context 'when webauthn feature flag is enabled' do - context 'user has no U2F registration' do - it { expect(user.two_factor_u2f_enabled?).to eq(false) } - end - - context 'user has existing U2F registration' do - it 'returns false' do - device = U2F::FakeU2F.new(FFaker::BaconIpsum.characters(5)) - create(:u2f_registration, - name: 'my u2f device', - user: user, - certificate: Base64.strict_encode64(device.cert_raw), - key_handle: U2F.urlsafe_encode64(device.key_handle_raw), - public_key: Base64.strict_encode64(device.origin_public_key_raw)) - - expect(user.two_factor_u2f_enabled?).to eq(false) - end - end - end - - context 'when webauthn feature flag is disabled' do - before do - stub_feature_flags(webauthn: false) - end - - context 'user has no U2F registration' do - it { expect(user.two_factor_u2f_enabled?).to eq(false) } - end - - context 'user has existing U2F registration' do - it 'returns true' do - device = U2F::FakeU2F.new(FFaker::BaconIpsum.characters(5)) - create(:u2f_registration, - name: 'my u2f device', - user: user, - certificate: Base64.strict_encode64(device.cert_raw), - key_handle: U2F.urlsafe_encode64(device.key_handle_raw), - public_key: Base64.strict_encode64(device.origin_public_key_raw)) - - expect(user.two_factor_u2f_enabled?).to eq(true) - end - end - end - end - describe 'needs_new_otp_secret?', :freeze_time do let(:user) { create(:user) } @@ -2380,6 +2441,20 @@ RSpec.describe User, feature_category: :user_profile do expect(user.forkable_namespaces).to contain_exactly(user.namespace, group, subgroup, developer_group) end + + it 'includes groups where the user has access via group shares to create projects' do + shared_group = create(:group) + create( + :group_group_link, + :maintainer, + shared_with_group: group, + shared_group: shared_group + ) + + expect(user.forkable_namespaces).to contain_exactly( + user.namespace, group, subgroup, shared_group + ) + end end describe '#manageable_groups' do @@ -2396,14 +2471,6 @@ RSpec.describe User, feature_category: :user_profile do end it_behaves_like 'manageable groups examples' - - context 'when feature flag :linear_user_manageable_groups is disabled' do - before do - stub_feature_flags(linear_user_manageable_groups: false) - end - - it_behaves_like 'manageable groups examples' - end end end end @@ -3847,6 +3914,54 @@ RSpec.describe User, feature_category: :user_profile do expect(user.following?(followee)).to be_falsey end + + it 'does not follow if user disabled following' do + user = create(:user) + user.enabled_following = false + + followee = create(:user) + + expect(user.follow(followee)).to eq(false) + + expect(user.following?(followee)).to be_falsey + end + + it 'does not follow if followee user disabled following' do + user = create(:user) + + followee = create(:user) + followee.enabled_following = false + + expect(user.follow(followee)).to eq(false) + + expect(user.following?(followee)).to be_falsey + end + + context 'when disable_follow_users feature flag is off' do + before do + stub_feature_flags(disable_follow_users: false) + end + + it 'follows user even if user disabled following' do + user = create(:user) + user.enabled_following = false + + followee = create(:user) + + expect(user.follow(followee)).to be_truthy + expect(user.following?(followee)).to be_truthy + end + + it 'follows user even if followee user disabled following' do + user = create(:user) + + followee = create(:user) + followee.enabled_following = false + + expect(user.follow(followee)).to be_truthy + expect(user.following?(followee)).to be_truthy + end + end end describe '#unfollow' do @@ -3889,6 +4004,39 @@ RSpec.describe User, feature_category: :user_profile do end end + describe '#following_users_allowed?' do + using RSpec::Parameterized::TableSyntax + + let_it_be(:user) { create(:user) } + let_it_be(:followee) { create(:user) } + + where(:user_enabled_following, :followee_enabled_following, :feature_flag_status, :result) do + true | true | false | true + true | false | false | true + true | true | true | true + true | false | true | false + false | true | false | true + false | true | true | false + false | false | false | true + false | false | true | false + end + + with_them do + before do + user.enabled_following = user_enabled_following + followee.enabled_following = followee_enabled_following + followee.save! + stub_feature_flags(disable_follow_users: feature_flag_status) + end + + it { expect(user.following_users_allowed?(followee)).to eq result } + end + + it 'is false when user and followee is the same user' do + expect(user.following_users_allowed?(user)).to eq(false) + end + end + describe '#notification_email_or_default' do let(:email) { 'gonzo@muppets.com' } @@ -5225,13 +5373,13 @@ RSpec.describe User, feature_category: :user_profile do end describe '#source_groups_of_two_factor_authentication_requirement' do - let_it_be(:group_not_requiring_2FA) { create :group } + let_it_be(:group_not_requiring_2fa) { create :group } let(:user) { create :user } before do group.add_member(user, GroupMember::OWNER) - group_not_requiring_2FA.add_member(user, GroupMember::OWNER) + group_not_requiring_2fa.add_member(user, GroupMember::OWNER) end context 'when user is direct member of group requiring 2FA' do @@ -5713,6 +5861,34 @@ RSpec.describe User, feature_category: :user_profile do expect(user).not_to be_blocked end + + context 'when target user is the same as deleted_by' do + let(:deleted_by) { user } + + it 'blocks the user and schedules the record for deletion with the correct delay' do + freeze_time do + expect(DeleteUserWorker).to receive(:perform_in).with(7.days, user.id, user.id, {}) + + user.delete_async(deleted_by: deleted_by) + + expect(user).to be_blocked + end + end + + context 'when delay_delete_own_user feature flag is disabled' do + before do + stub_feature_flags(delay_delete_own_user: false) + end + + it 'schedules user for deletion without blocking them' do + expect(DeleteUserWorker).to receive(:perform_async).with(user.id, user.id, {}) + + user.delete_async(deleted_by: deleted_by) + + expect(user).not_to be_blocked + end + end + end end describe '#max_member_access_for_project_ids' do @@ -5777,8 +5953,10 @@ RSpec.describe User, feature_category: :user_profile do all_projects = projects + [second_maintainer_project.id, second_developer_project.id] - expected_all = expected.merge(second_maintainer_project.id => Gitlab::Access::MAINTAINER, - second_developer_project.id => Gitlab::Access::DEVELOPER) + expected_all = expected.merge( + second_maintainer_project.id => Gitlab::Access::MAINTAINER, + second_developer_project.id => Gitlab::Access::DEVELOPER + ) access_levels(projects) @@ -5856,8 +6034,10 @@ RSpec.describe User, feature_category: :user_profile do all_groups = groups + [second_maintainer_group.id, second_developer_group.id] - expected_all = expected.merge(second_maintainer_group.id => Gitlab::Access::MAINTAINER, - second_developer_group.id => Gitlab::Access::DEVELOPER) + expected_all = expected.merge( + second_maintainer_group.id => Gitlab::Access::MAINTAINER, + second_developer_group.id => Gitlab::Access::DEVELOPER + ) access_levels(groups) @@ -6566,11 +6746,13 @@ RSpec.describe User, feature_category: :user_profile do context 'when dismissed callout exists' do before_all do - create(:group_callout, - user: user, - group_id: group.id, - feature_name: feature_name, - dismissed_at: 4.months.ago) + create( + :group_callout, + user: user, + group_id: group.id, + feature_name: feature_name, + dismissed_at: 4.months.ago + ) end it 'returns true when no ignore_dismissal_earlier_than provided' do @@ -6600,11 +6782,13 @@ RSpec.describe User, feature_category: :user_profile do context 'when dismissed callout exists' do before_all do - create(:project_callout, - user: user, - project_id: project.id, - feature_name: feature_name, - dismissed_at: 4.months.ago) + create( + :project_callout, + user: user, + project_id: project.id, + feature_name: feature_name, + dismissed_at: 4.months.ago + ) end it 'returns true when no ignore_dismissal_earlier_than provided' do @@ -6840,7 +7024,8 @@ RSpec.describe User, feature_category: :user_profile do { user_type: :support_bot }, { user_type: :security_bot }, { user_type: :automation_bot }, - { user_type: :admin_bot } + { user_type: :admin_bot }, + { user_type: :llm_bot } ] end @@ -7002,6 +7187,14 @@ RSpec.describe User, feature_category: :user_profile do it_behaves_like 'does not require password to be present' end + + context 'when user is a security_policy bot user' do + before do + user.update!(user_type: 'security_policy_bot') + end + + it_behaves_like 'does not require password to be present' + end end describe 'can_trigger_notifications?' do @@ -7104,42 +7297,103 @@ RSpec.describe User, feature_category: :user_profile do context 'when user is confirmed' do let(:user) { create(:user) } - it 'is falsey' do - expect(user.confirmed?).to be_truthy - expect(subject).to be_falsey + it 'is false' do + expect(user.confirmed?).to be(true) + expect(subject).to be(false) end end context 'when user is not confirmed' do let_it_be(:user) { build_stubbed(:user, :unconfirmed, confirmation_sent_at: Time.current) } - it 'is truthy when soft_email_confirmation feature is disabled' do - stub_feature_flags(soft_email_confirmation: false) - expect(subject).to be_truthy + context 'when email confirmation setting is set to `off`' do + before do + stub_application_setting_enum('email_confirmation_setting', 'off') + end + + it { is_expected.to be(false) } end - context 'when soft_email_confirmation feature is enabled' do + context 'when email confirmation setting is set to `soft`' do before do - stub_feature_flags(soft_email_confirmation: true) + stub_application_setting_enum('email_confirmation_setting', 'soft') end - it 'is falsey when confirmation period is valid' do - expect(subject).to be_falsey + context 'when confirmation period is valid' do + it { is_expected.to be(false) } end - it 'is truthy when confirmation period is expired' do - travel_to(User.allow_unconfirmed_access_for.from_now + 1.day) do - expect(subject).to be_truthy + context 'when confirmation period is expired' do + before do + travel_to(User.allow_unconfirmed_access_for.from_now + 1.day) end + + it { is_expected.to be(true) } end context 'when user has no confirmation email sent' do let(:user) { build(:user, :unconfirmed, confirmation_sent_at: nil) } - it 'is truthy' do - expect(subject).to be_truthy - end + it { is_expected.to be(true) } + end + end + + context 'when email confirmation setting is set to `hard`' do + before do + stub_application_setting_enum('email_confirmation_setting', 'hard') + end + + it { is_expected.to be(true) } + end + end + end + + describe '#confirmation_period_valid?' do + subject { user.send(:confirmation_period_valid?) } + + let_it_be(:user) { create(:user) } + + context 'when email confirmation setting is set to `off`' do + before do + stub_application_setting_enum('email_confirmation_setting', 'off') + end + + it { is_expected.to be(true) } + end + + context 'when email confirmation setting is set to `soft`' do + before do + stub_application_setting_enum('email_confirmation_setting', 'soft') + end + + context 'when within confirmation window' do + before do + user.update!(confirmation_sent_at: Date.today) end + + it { is_expected.to be(true) } + end + + context 'when outside confirmation window' do + before do + user.update!(confirmation_sent_at: Date.today - described_class.confirm_within - 7.days) + end + + it { is_expected.to be(false) } + end + end + + context 'when email confirmation setting is set to `hard`' do + before do + stub_application_setting_enum('email_confirmation_setting', 'hard') + end + + it { is_expected.to be(true) } + end + + describe '#in_confirmation_period?' do + it 'is expected to be an alias' do + expect(user.method(:in_confirmation_period?).original_name).to eq(:confirmation_period_valid?) end end end @@ -7433,9 +7687,11 @@ RSpec.describe User, feature_category: :user_profile do context 'with a defined project namespace_commit_email' do it 'returns the defined namespace_commit_email' do - project_commit_email = create(:namespace_commit_email, - user: user, - namespace: project.project_namespace) + project_commit_email = create( + :namespace_commit_email, + user: user, + namespace: project.project_namespace + ) expect(emails).to eq(project_commit_email) end @@ -7455,9 +7711,11 @@ RSpec.describe User, feature_category: :user_profile do context 'with a defined project namespace_commit_email' do it 'returns the defined namespace_commit_email' do - project_commit_email = create(:namespace_commit_email, - user: user, - namespace: project.project_namespace) + project_commit_email = create( + :namespace_commit_email, + user: user, + namespace: project.project_namespace + ) expect(emails).to eq(project_commit_email) end @@ -7476,9 +7734,11 @@ RSpec.describe User, feature_category: :user_profile do context 'with a defined project namespace_commit_email' do it 'returns the defined namespace_commit_email' do - project_commit_email = create(:namespace_commit_email, - user: user, - namespace: project.project_namespace) + project_commit_email = create( + :namespace_commit_email, + user: user, + namespace: project.project_namespace + ) expect(emails).to eq(project_commit_email) end diff --git a/spec/models/users/credit_card_validation_spec.rb b/spec/models/users/credit_card_validation_spec.rb index 58b529ff18a..4db3683c057 100644 --- a/spec/models/users/credit_card_validation_spec.rb +++ b/spec/models/users/credit_card_validation_spec.rb @@ -51,4 +51,107 @@ RSpec.describe Users::CreditCardValidation do end end end + + describe 'scopes' do + describe '.by_banned_user' do + let(:banned_user) { create(:banned_user) } + let!(:credit_card) { create(:credit_card_validation) } + let!(:banned_user_credit_card) { create(:credit_card_validation, user: banned_user.user) } + + it 'returns only records associated to banned users' do + expect(described_class.by_banned_user).to match_array([banned_user_credit_card]) + end + end + + describe '.similar_by_holder_name' do + let!(:credit_card) { create(:credit_card_validation, holder_name: 'CARD MCHODLER') } + let!(:credit_card2) { create(:credit_card_validation, holder_name: 'RICHIE RICH') } + + it 'returns only records that case-insensitive match the given holder name' do + expect(described_class.similar_by_holder_name('card mchodler')).to match_array([credit_card]) + end + + context 'when given holder name is falsey' do + it 'returns [] when given holder name is ""' do + expect(described_class.similar_by_holder_name('')).to match_array([]) + end + + it 'returns [] when given holder name is nil' do + expect(described_class.similar_by_holder_name(nil)).to match_array([]) + end + end + end + + describe '.similar_to' do + let(:credit_card) { create(:credit_card_validation) } + + let!(:credit_card2) do + create(:credit_card_validation, + expiration_date: credit_card.expiration_date, + last_digits: credit_card.last_digits, + network: credit_card.network + ) + end + + let!(:credit_card3) do + create(:credit_card_validation, + expiration_date: credit_card.expiration_date, + last_digits: credit_card.last_digits, + network: 'UnknownCCNetwork' + ) + end + + it 'returns only records with similar expiration_date, last_digits, and network attribute values' do + expect(described_class.similar_to(credit_card)).to match_array([credit_card, credit_card2]) + end + end + end + + describe '#used_by_banned_user?' do + let(:credit_card_details) do + { + holder_name: 'Christ McLovin', + expiration_date: 2.years.from_now.end_of_month, + last_digits: 4242, + network: 'Visa' + } + end + + let!(:credit_card) { create(:credit_card_validation, credit_card_details) } + + subject { credit_card } + + context 'when there is a similar credit card associated to a banned user' do + let_it_be(:banned_user) { create(:banned_user) } + + let(:attrs) { credit_card_details.merge({ user: banned_user.user }) } + let!(:similar_credit_card) { create(:credit_card_validation, attrs) } + + it { is_expected.to be_used_by_banned_user } + + context 'when holder names do not match' do + let!(:similar_credit_card) do + create(:credit_card_validation, attrs.merge({ holder_name: 'Mary Goody' })) + end + + it { is_expected.not_to be_used_by_banned_user } + end + + context 'when .similar_to returns nothing' do + let!(:similar_credit_card) do + create(:credit_card_validation, attrs.merge({ network: 'DifferentNetwork' })) + end + + it { is_expected.not_to be_used_by_banned_user } + end + end + + context 'when there is a similar credit card not associated to a banned user' do + let!(:similar_credit_card) do + create(:credit_card_validation, credit_card_details) + end + + it { is_expected.not_to be_used_by_banned_user } + end + end end diff --git a/spec/models/wiki_directory_spec.rb b/spec/models/wiki_directory_spec.rb index 1b177934ace..c30e79f79ce 100644 --- a/spec/models/wiki_directory_spec.rb +++ b/spec/models/wiki_directory_spec.rb @@ -13,6 +13,8 @@ RSpec.describe WikiDirectory do let_it_be(:toplevel1) { build(:wiki_page, title: 'aaa-toplevel1') } let_it_be(:toplevel2) { build(:wiki_page, title: 'zzz-toplevel2') } let_it_be(:toplevel3) { build(:wiki_page, title: 'zzz-toplevel3') } + let_it_be(:home) { build(:wiki_page, title: 'home') } + let_it_be(:homechild) { build(:wiki_page, title: 'Home/homechild') } let_it_be(:parent1) { build(:wiki_page, title: 'parent1') } let_it_be(:parent2) { build(:wiki_page, title: 'parent2') } let_it_be(:child1) { build(:wiki_page, title: 'parent1/child1') } @@ -24,13 +26,18 @@ RSpec.describe WikiDirectory do it 'returns a nested array of entries' do entries = described_class.group_pages( - [toplevel1, toplevel2, toplevel3, + [toplevel1, toplevel2, toplevel3, home, homechild, parent1, parent2, child1, child2, child3, subparent, grandchild1, grandchild2].sort_by(&:title) ) expect(entries).to match( [ + a_kind_of(WikiDirectory).and( + having_attributes( + slug: 'Home', entries: [homechild] + ) + ), toplevel1, a_kind_of(WikiDirectory).and( having_attributes( diff --git a/spec/models/wiki_page/meta_spec.rb b/spec/models/wiki_page/meta_spec.rb index 4d1a2dc1c98..5808be128e0 100644 --- a/spec/models/wiki_page/meta_spec.rb +++ b/spec/models/wiki_page/meta_spec.rb @@ -167,10 +167,12 @@ RSpec.describe WikiPage::Meta do end def create_previous_version(title: old_title, slug: last_known_slug, date: wiki_page.version.commit.committed_date) - create(:wiki_page_meta, - title: title, project: project, - created_at: date, updated_at: date, - canonical_slug: slug) + create( + :wiki_page_meta, + title: title, project: project, + created_at: date, updated_at: date, + canonical_slug: slug + ) end def create_context diff --git a/spec/models/wiki_page_spec.rb b/spec/models/wiki_page_spec.rb index 21da06a222f..efade74688a 100644 --- a/spec/models/wiki_page_spec.rb +++ b/spec/models/wiki_page_spec.rb @@ -588,6 +588,20 @@ RSpec.describe WikiPage do expect(page.content).to eq new_content end + context 'when page combine with directory' do + it 'moving the file and directory' do + wiki.create_page('testpage/testtitle', 'content') + wiki.create_page('testpage', 'content') + + page = wiki.find_page('testpage') + page.update(title: 'testfolder/testpage') + + page = wiki.find_page('testfolder/testpage/testtitle') + + expect(page.slug).to eq 'testfolder/testpage/testtitle' + end + end + describe 'in subdir' do it 'moves the page to the root folder if the title is preceded by /' do page = create_wiki_page(container, title: 'foo/Existing Page') diff --git a/spec/models/work_item_spec.rb b/spec/models/work_item_spec.rb index 6aacaa3c119..5a525d83c3b 100644 --- a/spec/models/work_item_spec.rb +++ b/spec/models/work_item_spec.rb @@ -3,6 +3,8 @@ require 'spec_helper' RSpec.describe WorkItem, feature_category: :portfolio_management do + using RSpec::Parameterized::TableSyntax + let_it_be(:reusable_project) { create(:project) } describe 'associations' do @@ -99,15 +101,28 @@ RSpec.describe WorkItem, feature_category: :portfolio_management do end end - describe '#supports_assignee?' do - let(:work_item) { build(:work_item, :task) } + describe '#get_widget' do + let(:work_item) { build(:work_item, description: 'foo') } - before do - allow(work_item.work_item_type).to receive(:supports_assignee?).and_return(false) + it 'returns widget object' do + expect(work_item.get_widget(:description)).to be_an_instance_of(WorkItems::Widgets::Description) end - it 'delegates the call to its work item type' do - expect(work_item.supports_assignee?).to be(false) + context 'when widget does not exist' do + it 'returns nil' do + expect(work_item.get_widget(:nop)).to be_nil + end + end + end + + describe '#supports_assignee?' do + Gitlab::DatabaseImporters::WorkItems::BaseTypeImporter::WIDGETS_FOR_TYPE.each_pair do |base_type, widgets| + specify do + work_item = build(:work_item, base_type) + supports_assignee = widgets.include?(:assignees) + + expect(work_item.supports_assignee?).to eq(supports_assignee) + end end end @@ -117,7 +132,7 @@ RSpec.describe WorkItem, feature_category: :portfolio_management do subject { work_item.supported_quick_action_commands } it 'returns quick action commands supported for all work items' do - is_expected.to include(:title, :reopen, :close, :cc, :tableflip, :shrug) + is_expected.to include(:title, :reopen, :close, :cc, :tableflip, :shrug, :type) end context 'when work item supports the assignee widget' do @@ -127,7 +142,7 @@ RSpec.describe WorkItem, feature_category: :portfolio_management do end context 'when work item does not the assignee widget' do - let(:work_item) { build(:work_item, :incident) } + let(:work_item) { build(:work_item, :test_case) } it 'omits assignee related quick action commands' do is_expected.not_to include(:assign, :unassign, :reassign) @@ -163,6 +178,30 @@ RSpec.describe WorkItem, feature_category: :portfolio_management do end end + describe 'transform_quick_action_params' do + let(:work_item) { build(:work_item, :task) } + + subject(:transformed_params) do + work_item.transform_quick_action_params({ + title: 'bar', + assignee_ids: ['foo'] + }) + end + + it 'correctly separates widget params from regular params' do + expect(transformed_params).to eq({ + common: { + title: 'bar' + }, + widgets: { + assignees_widget: { + assignee_ids: ['foo'] + } + } + }) + end + end + describe 'callbacks' do describe 'record_create_action' do it 'records the creation action after saving' do @@ -290,6 +329,20 @@ RSpec.describe WorkItem, feature_category: :portfolio_management do end end + describe '#link_reference_pattern' do + let(:match_data) { described_class.link_reference_pattern.match(link_reference_url) } + + context 'with work item url' do + let(:link_reference_url) { 'http://localhost/namespace/project/-/work_items/1' } + + it 'matches with expected attributes' do + expect(match_data['namespace']).to eq('namespace') + expect(match_data['project']).to eq('project') + expect(match_data['work_item']).to eq('1') + end + end + end + context 'with hierarchy' do let_it_be(:type1) { create(:work_item_type, namespace: reusable_project.namespace) } let_it_be(:type2) { create(:work_item_type, namespace: reusable_project.namespace) } @@ -344,4 +397,179 @@ RSpec.describe WorkItem, feature_category: :portfolio_management do end end end + + describe '#allowed_work_item_type_change' do + let_it_be(:all_types) { WorkItems::Type::BASE_TYPES.keys } + + it 'is possible to change between all types', :aggregate_failures do + all_types.each do |type| + work_item = build(:work_item, type, project: reusable_project) + + (all_types - [type]).each do |new_type| + work_item.work_item_type_id = WorkItems::Type.default_by_type(new_type).id + + expect(work_item).to be_valid, "#{type} to #{new_type}" + end + end + end + + context 'with ParentLink relation' do + let_it_be(:old_type) { create(:work_item_type) } + let_it_be(:new_type) { create(:work_item_type) } + + context 'with hierarchy restrictions' do + let_it_be(:child_type) { create(:work_item_type) } + + let_it_be_with_reload(:parent) { create(:work_item, work_item_type: old_type, project: reusable_project) } + let_it_be_with_reload(:child) { create(:work_item, work_item_type: child_type, project: reusable_project) } + + let_it_be(:hierarchy_restriction) do + create(:hierarchy_restriction, parent_type: old_type, child_type: child_type) + end + + let_it_be(:link) { create(:parent_link, work_item_parent: parent, work_item: child) } + + context 'when child items restrict the type change' do + before do + parent.work_item_type = new_type + end + + context 'when child items are compatible with the new type' do + let_it_be(:hierarchy_restriction_new_type) do + create(:hierarchy_restriction, parent_type: new_type, child_type: child_type) + end + + it 'allows to change types' do + expect(parent).to be_valid + expect(parent.errors).to be_empty + end + end + + context 'when child items are not compatible with the new type' do + it 'does not allow to change types' do + expect(parent).not_to be_valid + expect(parent.errors[:work_item_type_id]) + .to include("cannot be changed to #{new_type.name} with these child item types.") + end + end + end + + context 'when the parent restricts the type change' do + before do + child.work_item_type = new_type + end + + it 'does not allow to change types' do + expect(child.valid?).to eq(false) + expect(child.errors[:work_item_type_id]) + .to include("cannot be changed to #{new_type.name} with #{parent.work_item_type.name} as parent type.") + end + end + end + + context 'with hierarchy depth restriction' do + let_it_be_with_reload(:item1) { create(:work_item, work_item_type: new_type, project: reusable_project) } + let_it_be_with_reload(:item2) { create(:work_item, work_item_type: new_type, project: reusable_project) } + let_it_be_with_reload(:item3) { create(:work_item, work_item_type: new_type, project: reusable_project) } + let_it_be_with_reload(:item4) { create(:work_item, work_item_type: new_type, project: reusable_project) } + + let_it_be(:hierarchy_restriction1) do + create(:hierarchy_restriction, parent_type: old_type, child_type: new_type) + end + + let_it_be(:hierarchy_restriction2) do + create(:hierarchy_restriction, parent_type: new_type, child_type: old_type) + end + + let_it_be_with_reload(:hierarchy_restriction3) do + create(:hierarchy_restriction, parent_type: new_type, child_type: new_type, maximum_depth: 4) + end + + let_it_be(:link1) { create(:parent_link, work_item_parent: item1, work_item: item2) } + let_it_be(:link2) { create(:parent_link, work_item_parent: item2, work_item: item3) } + let_it_be(:link3) { create(:parent_link, work_item_parent: item3, work_item: item4) } + + before do + hierarchy_restriction3.update!(maximum_depth: maximum_depth) + end + + shared_examples 'validates the depth correctly' do + before do + work_item.update!(work_item_type: old_type) + end + + context 'when it is valid' do + let(:maximum_depth) { 4 } + + it 'allows to change types' do + work_item.work_item_type = new_type + + expect(work_item).to be_valid + end + end + + context 'when it is not valid' do + let(:maximum_depth) { 3 } + + it 'does not allow to change types' do + work_item.work_item_type = new_type + + expect(work_item).not_to be_valid + expect(work_item.errors[:work_item_type_id]).to include("reached maximum depth") + end + end + end + + context 'with the highest ancestor' do + let_it_be_with_reload(:work_item) { item1 } + + it_behaves_like 'validates the depth correctly' + end + + context 'with a child item' do + let_it_be_with_reload(:work_item) { item2 } + + it_behaves_like 'validates the depth correctly' + end + + context 'with the last child item' do + let_it_be_with_reload(:work_item) { item4 } + + it_behaves_like 'validates the depth correctly' + end + + context 'when ancestor is still the old type' do + let_it_be(:hierarchy_restriction4) do + create(:hierarchy_restriction, parent_type: old_type, child_type: old_type) + end + + before do + item1.update!(work_item_type: old_type) + item2.update!(work_item_type: old_type) + end + + context 'when it exceeds maximum depth' do + let(:maximum_depth) { 2 } + + it 'does not allow to change types' do + item2.work_item_type = new_type + + expect(item2).not_to be_valid + expect(item2.errors[:work_item_type_id]).to include("reached maximum depth") + end + end + + context 'when it does not exceed maximum depth' do + let(:maximum_depth) { 3 } + + it 'does allow to change types' do + item2.work_item_type = new_type + + expect(item2).to be_valid + end + end + end + end + end + end end diff --git a/spec/models/work_items/resource_link_event_spec.rb b/spec/models/work_items/resource_link_event_spec.rb new file mode 100644 index 00000000000..67ca9e72bbc --- /dev/null +++ b/spec/models/work_items/resource_link_event_spec.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe WorkItems::ResourceLinkEvent, type: :model, feature_category: :team_planning do + it_behaves_like 'a resource event' + + describe 'associations' do + it { is_expected.to belong_to(:work_item) } + it { is_expected.to belong_to(:child_work_item) } + end + + describe 'validation' do + it { is_expected.to validate_presence_of(:child_work_item) } + end +end diff --git a/spec/models/work_items/widget_definition_spec.rb b/spec/models/work_items/widget_definition_spec.rb index 08f8f4d9663..a33e08a1bf2 100644 --- a/spec/models/work_items/widget_definition_spec.rb +++ b/spec/models/work_items/widget_definition_spec.rb @@ -11,7 +11,10 @@ RSpec.describe WorkItems::WidgetDefinition, feature_category: :team_planning do ::WorkItems::Widgets::Assignees, ::WorkItems::Widgets::StartAndDueDate, ::WorkItems::Widgets::Milestone, - ::WorkItems::Widgets::Notes + ::WorkItems::Widgets::Notes, + ::WorkItems::Widgets::Notifications, + ::WorkItems::Widgets::CurrentUserTodos, + ::WorkItems::Widgets::AwardEmoji ] if Gitlab.ee? diff --git a/spec/models/work_items/widgets/award_emoji_spec.rb b/spec/models/work_items/widgets/award_emoji_spec.rb new file mode 100644 index 00000000000..bb61aa41669 --- /dev/null +++ b/spec/models/work_items/widgets/award_emoji_spec.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe WorkItems::Widgets::AwardEmoji, feature_category: :team_planning do + let_it_be(:work_item) { create(:work_item) } + let_it_be(:emoji1) { create(:award_emoji, name: 'star', awardable: work_item) } + let_it_be(:emoji2) { create(:award_emoji, :upvote, awardable: work_item) } + let_it_be(:emoji3) { create(:award_emoji, :downvote, awardable: work_item) } + + describe '.type' do + it { expect(described_class.type).to eq(:award_emoji) } + end + + describe '#type' do + it { expect(described_class.new(work_item).type).to eq(:award_emoji) } + end + + describe '#downvotes' do + it { expect(described_class.new(work_item).downvotes).to eq(1) } + end + + describe '#upvotes' do + it { expect(described_class.new(work_item).upvotes).to eq(1) } + end + + describe '#award_emoji' do + it { expect(described_class.new(work_item).award_emoji).to match_array([emoji1, emoji2, emoji3]) } + end +end diff --git a/spec/models/work_items/widgets/notifications_spec.rb b/spec/models/work_items/widgets/notifications_spec.rb new file mode 100644 index 00000000000..2942c149660 --- /dev/null +++ b/spec/models/work_items/widgets/notifications_spec.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe WorkItems::Widgets::Notifications, feature_category: :team_planning do + let_it_be(:work_item) { create(:work_item) } + + describe '.type' do + it { expect(described_class.type).to eq(:notifications) } + end + + describe '#type' do + it { expect(described_class.new(work_item).type).to eq(:notifications) } + end + + describe '#subscribed?' do + it { expect(described_class.new(work_item).subscribed?(work_item.author, work_item.project)).to eq(true) } + end +end |
