diff options
Diffstat (limited to 'spec/models')
111 files changed, 6216 insertions, 2444 deletions
diff --git a/spec/models/ability_spec.rb b/spec/models/ability_spec.rb index 2bf971f553f..9ef77da6f43 100644 --- a/spec/models/ability_spec.rb +++ b/spec/models/ability_spec.rb @@ -74,13 +74,20 @@ describe Ability do context 'using a private project' do let(:project) { create(:project, :private) } - it 'returns users that are administrators' do + it 'returns users that are administrators when admin mode is enabled', :enable_admin_mode do user = build(:user, admin: true) expect(described_class.users_that_can_read_project([user], project)) .to eq([user]) end + it 'does not return users that are administrators when admin mode is disabled' do + user = build(:user, admin: true) + + expect(described_class.users_that_can_read_project([user], project)) + .to eq([]) + end + it 'returns external users if they are the project owner' do user1 = build(:user, external: true) user2 = build(:user, external: true) @@ -145,7 +152,7 @@ describe Ability do end describe '.merge_requests_readable_by_user' do - context 'with an admin' do + context 'with an admin when admin mode is enabled', :enable_admin_mode do it 'returns all merge requests' do user = build(:user, admin: true) merge_request = build(:merge_request) @@ -155,6 +162,19 @@ describe Ability do end end + context 'with an admin when admin mode is disabled' do + it 'returns merge_requests that are publicly visible' do + user = build(:user, admin: true) + hidden_merge_request = build(:merge_request) + visible_merge_request = build(:merge_request, source_project: build(:project, :public)) + + merge_requests = described_class + .merge_requests_readable_by_user([hidden_merge_request, visible_merge_request], user) + + expect(merge_requests).to eq([visible_merge_request]) + end + end + context 'without a user' do it 'returns merge_requests that are publicly visible' do hidden_merge_request = build(:merge_request) @@ -217,7 +237,7 @@ describe Ability do end describe '.issues_readable_by_user' do - context 'with an admin user' do + context 'with an admin when admin mode is enabled', :enable_admin_mode do it 'returns all given issues' do user = build(:user, admin: true) issue = build(:issue) @@ -227,6 +247,26 @@ describe Ability do end end + context 'with an admin when admin mode is disabled' do + it 'returns the issues readable by the admin' do + user = build(:user, admin: true) + issue = build(:issue) + + expect(issue).to receive(:readable_by?).with(user).and_return(true) + + expect(described_class.issues_readable_by_user([issue], user)) + .to eq([issue]) + end + + it 'returns no issues when not given access' do + user = build(:user, admin: true) + issue = build(:issue) + + expect(described_class.issues_readable_by_user([issue], user)) + .to be_empty + end + end + context 'with a regular user' do it 'returns the issues readable by the user' do user = build(:user) diff --git a/spec/models/alert_management/alert_spec.rb b/spec/models/alert_management/alert_spec.rb new file mode 100644 index 00000000000..1da0c6d4071 --- /dev/null +++ b/spec/models/alert_management/alert_spec.rb @@ -0,0 +1,320 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe AlertManagement::Alert do + describe 'associations' do + it { is_expected.to belong_to(:project) } + it { is_expected.to belong_to(:issue) } + end + + describe 'validations' do + it { is_expected.to validate_presence_of(:title) } + it { is_expected.to validate_presence_of(:events) } + it { is_expected.to validate_presence_of(:severity) } + it { is_expected.to validate_presence_of(:status) } + it { is_expected.to validate_presence_of(:started_at) } + + it { is_expected.to validate_length_of(:title).is_at_most(200) } + it { is_expected.to validate_length_of(:description).is_at_most(1000) } + it { is_expected.to validate_length_of(:service).is_at_most(100) } + it { is_expected.to validate_length_of(:monitoring_tool).is_at_most(100) } + + context 'when status is triggered' do + context 'when ended_at is blank' do + subject { build(:alert_management_alert) } + + it { is_expected.to be_valid } + end + + context 'when ended_at is present' do + subject { build(:alert_management_alert, ended_at: Time.current) } + + it { is_expected.to be_invalid } + end + end + + context 'when status is acknowledged' do + context 'when ended_at is blank' do + subject { build(:alert_management_alert, :acknowledged) } + + it { is_expected.to be_valid } + end + + context 'when ended_at is present' do + subject { build(:alert_management_alert, :acknowledged, ended_at: Time.current) } + + it { is_expected.to be_invalid } + end + end + + context 'when status is resolved' do + context 'when ended_at is blank' do + subject { build(:alert_management_alert, :resolved, ended_at: nil) } + + it { is_expected.to be_invalid } + end + + context 'when ended_at is present' do + subject { build(:alert_management_alert, :resolved, ended_at: Time.current) } + + it { is_expected.to be_valid } + end + end + + context 'when status is ignored' do + context 'when ended_at is blank' do + subject { build(:alert_management_alert, :ignored) } + + it { is_expected.to be_valid } + end + + context 'when ended_at is present' do + subject { build(:alert_management_alert, :ignored, ended_at: Time.current) } + + it { is_expected.to be_invalid } + end + end + + describe 'fingerprint' do + let_it_be(:fingerprint) { 'fingerprint' } + let_it_be(:existing_alert) { create(:alert_management_alert, fingerprint: fingerprint) } + let(:new_alert) { build(:alert_management_alert, fingerprint: fingerprint, project: project) } + + subject { new_alert } + + context 'adding an alert with the same fingerprint' do + context 'same project' do + let(:project) { existing_alert.project } + + it { is_expected.not_to be_valid } + end + + context 'different project' do + let(:project) { create(:project) } + + it { is_expected.to be_valid } + end + end + end + + describe 'hosts' do + subject(:alert) { build(:alert_management_alert, hosts: hosts) } + + context 'over 255 total chars' do + let(:hosts) { ['111.111.111.111'] * 18 } + + it { is_expected.not_to be_valid } + end + + context 'under 255 chars' do + let(:hosts) { ['111.111.111.111'] * 17 } + + it { is_expected.to be_valid } + end + end + end + + describe 'enums' do + let(:severity_values) do + { critical: 0, high: 1, medium: 2, low: 3, info: 4, unknown: 5 } + end + + it { is_expected.to define_enum_for(:severity).with_values(severity_values) } + end + + describe 'scopes' do + let_it_be(:project) { create(:project) } + let_it_be(:triggered_alert) { create(:alert_management_alert, project: project) } + let_it_be(:resolved_alert) { create(:alert_management_alert, :resolved, project: project) } + let_it_be(:ignored_alert) { create(:alert_management_alert, :ignored, project: project) } + + describe '.for_iid' do + subject { AlertManagement::Alert.for_iid(triggered_alert.iid) } + + it { is_expected.to match_array(triggered_alert) } + end + + describe '.for_status' do + let(:status) { AlertManagement::Alert::STATUSES[:resolved] } + + subject { AlertManagement::Alert.for_status(status) } + + it { is_expected.to match_array(resolved_alert) } + + context 'with multiple statuses' do + let(:status) { AlertManagement::Alert::STATUSES.values_at(:resolved, :ignored) } + + it { is_expected.to match_array([resolved_alert, ignored_alert]) } + end + end + + describe '.for_fingerprint' do + let_it_be(:fingerprint) { SecureRandom.hex } + let_it_be(:alert_with_fingerprint) { create(:alert_management_alert, project: project, fingerprint: fingerprint) } + let_it_be(:unrelated_alert_with_finger_print) { create(:alert_management_alert, fingerprint: fingerprint) } + + subject { described_class.for_fingerprint(project, fingerprint) } + + it { is_expected.to contain_exactly(alert_with_fingerprint) } + end + + describe '.counts_by_status' do + subject { described_class.counts_by_status } + + it do + is_expected.to eq( + triggered_alert.status => 1, + resolved_alert.status => 1, + ignored_alert.status => 1 + ) + end + end + end + + describe '.search' do + let_it_be(:alert) do + create(:alert_management_alert, + title: 'Title', + description: 'Desc', + service: 'Service', + monitoring_tool: 'Monitor' + ) + end + + subject { AlertManagement::Alert.search(query) } + + context 'does not contain search string' do + let(:query) { 'something else' } + + it { is_expected.to be_empty } + end + + context 'title includes query' do + let(:query) { alert.title.upcase } + + it { is_expected.to contain_exactly(alert) } + end + + context 'description includes query' do + let(:query) { alert.description.upcase } + + it { is_expected.to contain_exactly(alert) } + end + + context 'service includes query' do + let(:query) { alert.service.upcase } + + it { is_expected.to contain_exactly(alert) } + end + + context 'monitoring tool includes query' do + let(:query) { alert.monitoring_tool.upcase } + + it { is_expected.to contain_exactly(alert) } + end + end + + describe '#details' do + let(:payload) do + { + 'title' => 'Details title', + 'custom' => { + 'alert' => { + 'fields' => %w[one two] + } + }, + 'yet' => { + 'another' => 'field' + } + } + end + let(:alert) { build(:alert_management_alert, title: 'Details title', payload: payload) } + + subject { alert.details } + + it 'renders the payload as inline hash' do + is_expected.to eq( + 'custom.alert.fields' => %w[one two], + 'yet.another' => 'field' + ) + end + end + + describe '#trigger' do + subject { alert.trigger } + + context 'when alert is in triggered state' do + let(:alert) { create(:alert_management_alert) } + + it 'does not change the alert status' do + expect { subject }.not_to change { alert.reload.status } + end + end + + context 'when alert not in triggered state' do + let(:alert) { create(:alert_management_alert, :resolved) } + + it 'changes the alert status to triggered' do + expect { subject }.to change { alert.triggered? }.to(true) + end + + it 'resets ended at' do + expect { subject }.to change { alert.reload.ended_at }.to nil + end + end + end + + describe '#acknowledge' do + subject { alert.acknowledge } + + let(:alert) { create(:alert_management_alert, :resolved) } + + it 'changes the alert status to acknowledged' do + expect { subject }.to change { alert.acknowledged? }.to(true) + end + + it 'resets ended at' do + expect { subject }.to change { alert.reload.ended_at }.to nil + end + end + + describe '#resolve' do + let!(:ended_at) { Time.current } + + subject do + alert.ended_at = ended_at + alert.resolve + end + + context 'when alert already resolved' do + let(:alert) { create(:alert_management_alert, :resolved) } + + it 'does not change the alert status' do + expect { subject }.not_to change { alert.reload.status } + end + end + + context 'when alert is not resolved' do + let(:alert) { create(:alert_management_alert) } + + it 'changes alert status to "resolved"' do + expect { subject }.to change { alert.resolved? }.to(true) + end + end + end + + describe '#ignore' do + subject { alert.ignore } + + let(:alert) { create(:alert_management_alert, :resolved) } + + it 'changes the alert status to ignored' do + expect { subject }.to change { alert.ignored? }.to(true) + end + + it 'resets ended at' do + expect { subject }.to change { alert.reload.ended_at }.to nil + end + end +end diff --git a/spec/models/application_setting_spec.rb b/spec/models/application_setting_spec.rb index 523e17f82c1..64308af38f9 100644 --- a/spec/models/application_setting_spec.rb +++ b/spec/models/application_setting_spec.rb @@ -91,6 +91,20 @@ describe ApplicationSetting do it { is_expected.not_to allow_value(nil).for(:namespace_storage_size_limit) } it { is_expected.not_to allow_value(-1).for(:namespace_storage_size_limit) } + it { is_expected.to allow_value(300).for(:issues_create_limit) } + it { is_expected.not_to allow_value('three').for(:issues_create_limit) } + it { is_expected.not_to allow_value(nil).for(:issues_create_limit) } + it { is_expected.not_to allow_value(10.5).for(:issues_create_limit) } + it { is_expected.not_to allow_value(-1).for(:issues_create_limit) } + + it { is_expected.to allow_value(0).for(:raw_blob_request_limit) } + it { is_expected.not_to allow_value('abc').for(:raw_blob_request_limit) } + it { is_expected.not_to allow_value(nil).for(:raw_blob_request_limit) } + it { is_expected.not_to allow_value(10.5).for(:raw_blob_request_limit) } + it { is_expected.not_to allow_value(-1).for(:raw_blob_request_limit) } + + it { is_expected.not_to allow_value(false).for(:hashed_storage_enabled) } + context 'grafana_url validations' do before do subject.instance_variable_set(:@parsed_grafana_url, nil) diff --git a/spec/models/blob_spec.rb b/spec/models/blob_spec.rb index a0193b29bb3..c2d6406c3fb 100644 --- a/spec/models/blob_spec.rb +++ b/spec/models/blob_spec.rb @@ -5,12 +5,17 @@ require 'spec_helper' describe Blob do include FakeBlobHelpers - let(:project) { build(:project, lfs_enabled: true) } + using RSpec::Parameterized::TableSyntax + + let(:project) { build(:project) } let(:personal_snippet) { build(:personal_snippet) } let(:project_snippet) { build(:project_snippet, project: project) } + let(:repository) { project.repository } + let(:lfs_enabled) { true } + before do - allow(Gitlab.config.lfs).to receive(:enabled).and_return(true) + allow(repository).to receive(:lfs_enabled?) { lfs_enabled } end describe '.decorate' do @@ -27,7 +32,7 @@ describe Blob do it 'does not fetch blobs when none are accessed' do expect(container.repository).not_to receive(:blobs_at) - described_class.lazy(container, commit_id, 'CHANGELOG') + described_class.lazy(container.repository, commit_id, 'CHANGELOG') end it 'fetches all blobs for the same repository when one is accessed' do @@ -36,10 +41,10 @@ describe Blob do .once.and_call_original expect(other_container.repository).not_to receive(:blobs_at) - changelog = described_class.lazy(container, commit_id, 'CHANGELOG') - contributing = described_class.lazy(same_container, commit_id, 'CONTRIBUTING.md') + changelog = described_class.lazy(container.repository, commit_id, 'CHANGELOG') + contributing = described_class.lazy(same_container.repository, commit_id, 'CONTRIBUTING.md') - described_class.lazy(other_container, commit_id, 'CHANGELOG') + described_class.lazy(other_container.repository, commit_id, 'CHANGELOG') # Access property so the values are loaded changelog.id @@ -47,14 +52,14 @@ describe Blob do end it 'does not include blobs from previous requests in later requests' do - changelog = described_class.lazy(container, commit_id, 'CHANGELOG') - contributing = described_class.lazy(same_container, commit_id, 'CONTRIBUTING.md') + changelog = described_class.lazy(container.repository, commit_id, 'CHANGELOG') + contributing = described_class.lazy(same_container.repository, commit_id, 'CONTRIBUTING.md') # Access property so the values are loaded changelog.id contributing.id - readme = described_class.lazy(container, commit_id, 'README.md') + readme = described_class.lazy(container.repository, commit_id, 'README.md') expect(container.repository).to receive(:blobs_at) .with([[commit_id, 'README.md']], blob_size_limit: blob_size_limit).once.and_call_original @@ -128,399 +133,84 @@ describe Blob do end describe '#external_storage_error?' do - shared_examples 'no error' do - it do - expect(blob.external_storage_error?).to be_falsey - end - end - - shared_examples 'returns error' do - it do - expect(blob.external_storage_error?).to be_truthy - end - end + subject { blob.external_storage_error? } context 'if the blob is stored in LFS' do - let(:blob) { fake_blob(path: 'file.pdf', lfs: true, container: container) } - - context 'when the project has LFS enabled' do - context 'with project' do - let(:container) { project } - - it_behaves_like 'no error' - end - - context 'with personal snippet' do - let(:container) { personal_snippet } - - it_behaves_like 'returns error' - end + let(:blob) { fake_blob(path: 'file.pdf', lfs: true) } - context 'with project snippet' do - let(:container) { project_snippet } + context 'when LFS is enabled' do + let(:lfs_enabled) { true } - it_behaves_like 'no error' - end + it { is_expected.to be_falsy } end - context 'when the project does not have LFS enabled' do - before do - project.lfs_enabled = false - end - - context 'with project' do - let(:container) { project } + context 'when LFS is not enabled' do + let(:lfs_enabled) { false } - it_behaves_like 'returns error' - end - - context 'with project snippet' do - let(:container) { project_snippet } - - it_behaves_like 'returns error' - end + it { is_expected.to be_truthy } end end context 'if the blob is not stored in LFS' do - let(:blob) { fake_blob(path: 'file.md', container: container) } - - context 'with project' do - let(:container) { project } - - it_behaves_like 'no error' - end - - context 'with personal snippet' do - let(:container) { personal_snippet } - - it_behaves_like 'no error' - end - - context 'with project snippet' do - let(:container) { project_snippet } + let(:blob) { fake_blob(path: 'file.md') } - it_behaves_like 'no error' - end + it { is_expected.to be_falsy } end end describe '#stored_externally?' do + subject { blob.stored_externally? } + context 'if the blob is stored in LFS' do let(:blob) { fake_blob(path: 'file.pdf', lfs: true) } - shared_examples 'returns true' do - it do - expect(blob.stored_externally?).to be_truthy - end - end - - shared_examples 'returns false' do - it do - expect(blob.stored_externally?).to be_falsey - end - end - - context 'when the project has LFS enabled' do - context 'with project' do - let(:container) { project } - - it_behaves_like 'returns true' - end + context 'when LFS is enabled' do + let(:lfs_enabled) { true } - context 'with personal snippet' do - let(:container) { personal_snippet } - - it_behaves_like 'returns true' - end - - context 'with project snippet' do - let(:container) { project_snippet } - - it_behaves_like 'returns true' - end + it { is_expected.to be_truthy } end - context 'when the project does not have LFS enabled' do - before do - project.lfs_enabled = false - end - - context 'with project' do - let(:container) { project } - - it_behaves_like 'returns false' - end - - context 'with personal snippet' do - let(:container) { personal_snippet } + context 'when LFS is not enabled' do + let(:lfs_enabled) { false } - it_behaves_like 'returns false' - end - - context 'with project snippet' do - let(:container) { project_snippet } - - it_behaves_like 'returns false' - end + it { is_expected.to be_falsy } end end context 'if the blob is not stored in LFS' do let(:blob) { fake_blob(path: 'file.md') } - it 'returns false' do - expect(blob.stored_externally?).to be_falsey - end + it { is_expected.to be_falsy } end end describe '#binary?' do - shared_examples 'returns true' do - it do - expect(blob.binary?).to be_truthy - end - end - - shared_examples 'returns false' do - it do - expect(blob.binary?).to be_falsey - end - end - - context 'if the blob is stored externally' do - let(:blob) { fake_blob(path: file, lfs: true) } - - context 'if the extension has a rich viewer' do - context 'if the viewer is binary' do - let(:file) { 'file.pdf' } - - context 'with project' do - let(:container) { project } - - it_behaves_like 'returns true' - end - - context 'with personal snippet' do - let(:container) { personal_snippet } - - it_behaves_like 'returns true' - end - - context 'with project snippet' do - let(:container) { project_snippet } - - it_behaves_like 'returns true' - end - end - - context 'if the viewer is text-based' do - let(:file) { 'file.md' } - - context 'with project' do - let(:container) { project } - - it_behaves_like 'returns false' - end - - context 'with personal snippet' do - let(:container) { personal_snippet } - - it_behaves_like 'returns false' - end - - context 'with project snippet' do - let(:container) { project_snippet } - - it_behaves_like 'returns false' - end - end + context 'an lfs object' do + where(:filename, :is_binary) do + 'file.pdf' | true + 'file.md' | false + 'file.txt' | false + 'file.ics' | false + 'file.rb' | false + 'file.exe' | true + 'file.ini' | false + 'file.wtf' | true end - context "if the extension doesn't have a rich viewer" do - context 'if the extension has a text mime type' do - context 'if the extension is for a programming language' do - let(:file) { 'file.txt' } - - context 'with project' do - let(:container) { project } - - it_behaves_like 'returns false' - end - - context 'with personal snippet' do - let(:container) { personal_snippet } - - it_behaves_like 'returns false' - end - - context 'with project snippet' do - let(:container) { project_snippet } - - it_behaves_like 'returns false' - end - end - - context 'if the extension is not for a programming language' do - let(:file) { 'file.ics' } - - context 'with project' do - let(:container) { project } - - it_behaves_like 'returns false' - end - - context 'with personal snippet' do - let(:container) { personal_snippet } - - it_behaves_like 'returns false' - end - - context 'with project snippet' do - let(:container) { project_snippet } + with_them do + let(:blob) { fake_blob(path: filename, lfs: true, container: project) } - it_behaves_like 'returns false' - end - end - end - - context 'if the extension has a binary mime type' do - context 'if the extension is for a programming language' do - let(:file) { 'file.rb' } - - context 'with project' do - let(:container) { project } - - it_behaves_like 'returns false' - end - - context 'with personal snippet' do - let(:container) { personal_snippet } - - it_behaves_like 'returns false' - end - - context 'with project snippet' do - let(:container) { project_snippet } - - it_behaves_like 'returns false' - end - end - - context 'if the extension is not for a programming language' do - let(:file) { 'file.exe' } - - context 'with project' do - let(:container) { project } - - it_behaves_like 'returns true' - end - - context 'with personal snippet' do - let(:container) { personal_snippet } - - it_behaves_like 'returns true' - end - - context 'with project snippet' do - let(:container) { project_snippet } - - it_behaves_like 'returns true' - end - end - end - - context 'if the extension has an unknown mime type' do - context 'if the extension is for a programming language' do - let(:file) { 'file.ini' } - - context 'with project' do - let(:container) { project } - - it_behaves_like 'returns false' - end - - context 'with personal snippet' do - let(:container) { personal_snippet } - - it_behaves_like 'returns false' - end - - context 'with project snippet' do - let(:container) { project_snippet } - - it_behaves_like 'returns false' - end - end - - context 'if the extension is not for a programming language' do - let(:file) { 'file.wtf' } - - context 'with project' do - let(:container) { project } - - it_behaves_like 'returns true' - end - - context 'with personal snippet' do - let(:container) { personal_snippet } - - it_behaves_like 'returns true' - end - - context 'with project snippet' do - let(:container) { project_snippet } - - it_behaves_like 'returns true' - end - end - end + it { expect(blob.binary?).to eq(is_binary) } end end - context 'if the blob is not stored externally' do - context 'if the blob is binary' do - let(:blob) { fake_blob(path: 'file.pdf', binary: true, container: container) } - - context 'with project' do - let(:container) { project } - - it_behaves_like 'returns true' - end - - context 'with personal snippet' do - let(:container) { personal_snippet } - - it_behaves_like 'returns true' - end - - context 'with project snippet' do - let(:container) { project_snippet } - - it_behaves_like 'returns true' - end - end - - context 'if the blob is text-based' do - let(:blob) { fake_blob(path: 'file.md', container: container) } - - context 'with project' do - let(:container) { project } - - it_behaves_like 'returns false' - end - - context 'with personal snippet' do - let(:container) { personal_snippet } - - it_behaves_like 'returns false' - end + context 'a non-lfs object' do + let(:blob) { fake_blob(path: 'anything', container: project) } - context 'with project snippet' do - let(:container) { project_snippet } + it 'delegates to binary_in_repo?' do + expect(blob).to receive(:binary_in_repo?) { :result } - it_behaves_like 'returns false' - end + expect(blob.binary?).to eq(:result) end end end @@ -569,9 +259,7 @@ describe Blob do describe '#rich_viewer' do context 'when the blob has an external storage error' do - before do - project.lfs_enabled = false - end + let(:lfs_enabled) { false } it 'returns nil' do blob = fake_blob(path: 'file.pdf', lfs: true) @@ -631,9 +319,7 @@ describe Blob do describe '#auxiliary_viewer' do context 'when the blob has an external storage error' do - before do - project.lfs_enabled = false - end + let(:lfs_enabled) { false } it 'returns nil' do blob = fake_blob(path: 'LICENSE', lfs: true) @@ -676,63 +362,21 @@ describe Blob do end describe '#rendered_as_text?' do - shared_examples 'returns true' do - it do - expect(blob.rendered_as_text?(ignore_errors: ignore_errors)).to be_truthy - end - end - - shared_examples 'returns false' do - it do - expect(blob.rendered_as_text?(ignore_errors: ignore_errors)).to be_falsey - end - end + subject { blob.rendered_as_text?(ignore_errors: ignore_errors) } context 'when ignoring errors' do let(:ignore_errors) { true } context 'when the simple viewer is text-based' do - let(:blob) { fake_blob(path: 'file.md', size: 100.megabytes, container: container) } - - context 'with project' do - let(:container) { project } - - it_behaves_like 'returns true' - end - - context 'with personal snippet' do - let(:container) { personal_snippet } + let(:blob) { fake_blob(path: 'file.md', size: 100.megabytes) } - it_behaves_like 'returns true' - end - - context 'with project snippet' do - let(:container) { project_snippet } - - it_behaves_like 'returns true' - end + it { is_expected.to be_truthy } end context 'when the simple viewer is binary' do - let(:blob) { fake_blob(path: 'file.pdf', binary: true, size: 100.megabytes, container: container) } - - context 'with project' do - let(:container) { project } - - it_behaves_like 'returns false' - end - - context 'with personal snippet' do - let(:container) { personal_snippet } - - it_behaves_like 'returns false' - end - - context 'with project snippet' do - let(:container) { project_snippet } + let(:blob) { fake_blob(path: 'file.pdf', binary: true, size: 100.megabytes) } - it_behaves_like 'returns false' - end + it { is_expected.to be_falsy } end end @@ -740,47 +384,15 @@ describe Blob do let(:ignore_errors) { false } context 'when the viewer has render errors' do - let(:blob) { fake_blob(path: 'file.md', size: 100.megabytes, container: container) } - - context 'with project' do - let(:container) { project } - - it_behaves_like 'returns false' - end - - context 'with personal snippet' do - let(:container) { personal_snippet } - - it_behaves_like 'returns false' - end - - context 'with project snippet' do - let(:container) { project_snippet } + let(:blob) { fake_blob(path: 'file.md', size: 100.megabytes) } - it_behaves_like 'returns false' - end + it { is_expected.to be_falsy } end context "when the viewer doesn't have render errors" do - let(:blob) { fake_blob(path: 'file.md', container: container) } - - context 'with project' do - let(:container) { project } - - it_behaves_like 'returns true' - end + let(:blob) { fake_blob(path: 'file.md') } - context 'with personal snippet' do - let(:container) { personal_snippet } - - it_behaves_like 'returns true' - end - - context 'with project snippet' do - let(:container) { project_snippet } - - it_behaves_like 'returns true' - end + it { is_expected.to be_truthy } end end end diff --git a/spec/models/blob_viewer/readme_spec.rb b/spec/models/blob_viewer/readme_spec.rb index 6586adbc373..89bc5be94fb 100644 --- a/spec/models/blob_viewer/readme_spec.rb +++ b/spec/models/blob_viewer/readme_spec.rb @@ -40,7 +40,7 @@ describe BlobViewer::Readme do context 'when the wiki is not empty' do before do - create(:wiki_page, wiki: project.wiki, attrs: { title: 'home', content: 'Home page' }) + create(:wiki_page, wiki: project.wiki, title: 'home', content: 'Home page') end it 'returns nil' do diff --git a/spec/models/broadcast_message_spec.rb b/spec/models/broadcast_message_spec.rb index 6cef81d6e44..127faa5e8e2 100644 --- a/spec/models/broadcast_message_spec.rb +++ b/spec/models/broadcast_message_spec.rb @@ -143,6 +143,24 @@ describe BroadcastMessage do expect(subject.call('/group/groupname/issues').length).to eq(0) end + + it 'does not return message if target path has no wild card at the end' do + create(:broadcast_message, target_path: "*/issues", broadcast_type: broadcast_type) + + expect(subject.call('/group/issues/test').length).to eq(0) + end + + it 'does not return message if target path has wild card at the end' do + create(:broadcast_message, target_path: "/issues/*", broadcast_type: broadcast_type) + + expect(subject.call('/group/issues/test').length).to eq(0) + end + + it 'does return message if target path has wild card at the beginning and the end' do + create(:broadcast_message, target_path: "*/issues/*", broadcast_type: broadcast_type) + + expect(subject.call('/group/issues/test').length).to eq(1) + end end describe '.current', :use_clean_rails_memory_store_caching do diff --git a/spec/models/ci/build_spec.rb b/spec/models/ci/build_spec.rb index a4f3fa518c6..6605866d9c0 100644 --- a/spec/models/ci/build_spec.rb +++ b/spec/models/ci/build_spec.rb @@ -106,10 +106,14 @@ describe Ci::Build do end end - describe '.with_artifacts_archive' do - subject { described_class.with_artifacts_archive } + describe '.with_downloadable_artifacts' do + subject { described_class.with_downloadable_artifacts } - context 'when job does not have an archive' do + before do + stub_feature_flags(drop_license_management_artifact: false) + end + + context 'when job does not have a downloadable artifact' do let!(:job) { create(:ci_build) } it 'does not return the job' do @@ -117,15 +121,23 @@ describe Ci::Build do end end - context 'when job has a job artifact archive' do - let!(:job) { create(:ci_build, :artifacts) } + ::Ci::JobArtifact::DOWNLOADABLE_TYPES.each do |type| + context "when job has a #{type} artifact" do + it 'returns the job' do + job = create(:ci_build) + create( + :ci_job_artifact, + file_format: ::Ci::JobArtifact::TYPE_AND_FORMAT_PAIRS[type.to_sym], + file_type: type, + job: job + ) - it 'returns the job' do - is_expected.to include(job) + is_expected.to include(job) + end end end - context 'when job has a job artifact trace' do + context 'when job has a non-downloadable artifact' do let!(:job) { create(:ci_build, :trace_artifact) } it 'does not return the job' do @@ -1419,6 +1431,8 @@ describe Ci::Build do subject { build.erase_erasable_artifacts! } before do + stub_feature_flags(drop_license_management_artifact: false) + Ci::JobArtifact.file_types.keys.each do |file_type| create(:ci_job_artifact, job: build, file_type: file_type, file_format: Ci::JobArtifact::TYPE_AND_FORMAT_PAIRS[file_type.to_sym]) end @@ -2367,12 +2381,14 @@ describe Ci::Build do let(:pipeline_pre_var) { { key: 'pipeline', value: 'value', public: true, masked: false } } let(:build_yaml_var) { { key: 'yaml', value: 'value', public: true, masked: false } } let(:job_jwt_var) { { key: 'CI_JOB_JWT', value: 'ci.job.jwt', public: false, masked: true } } + let(:job_dependency_var) { { key: 'job_dependency', value: 'value', public: true, masked: false } } before do allow(build).to receive(:predefined_variables) { [build_pre_var] } allow(build).to receive(:yaml_variables) { [build_yaml_var] } allow(build).to receive(:persisted_variables) { [] } allow(build).to receive(:job_jwt_variables) { [job_jwt_var] } + allow(build).to receive(:dependency_variables) { [job_dependency_var] } allow_any_instance_of(Project) .to receive(:predefined_variables) { [project_pre_var] } @@ -2390,6 +2406,7 @@ describe Ci::Build do project_pre_var, pipeline_pre_var, build_yaml_var, + job_dependency_var, { key: 'secret', value: 'value', public: false, masked: false }]) end end @@ -2884,6 +2901,19 @@ describe Ci::Build do it { is_expected.to include(deployment_variable) } end + context 'when build has a freeze period' do + let(:freeze_variable) { { key: 'CI_DEPLOY_FREEZE', value: 'true', masked: false, public: true } } + + before do + expect_next_instance_of(Ci::FreezePeriodStatus) do |freeze_period| + expect(freeze_period).to receive(:execute) + .and_return(true) + end + end + + it { is_expected.to include(freeze_variable) } + end + context 'when project has default CI config path' do let(:ci_config_path) { { key: 'CI_CONFIG_PATH', value: '.gitlab-ci.yml', public: true, masked: false } } @@ -2987,6 +3017,15 @@ describe Ci::Build do end end end + + context 'when build has dependency which has dotenv variable' do + let!(:prepare) { create(:ci_build, pipeline: pipeline, stage_idx: 0) } + let!(:build) { create(:ci_build, pipeline: pipeline, stage_idx: 1, options: { dependencies: [prepare.name] }) } + + let!(:job_variable) { create(:ci_job_variable, :dotenv_source, job: prepare) } + + it { is_expected.to include(key: job_variable.key, value: job_variable.value, public: false, masked: false) } + end end describe '#scoped_variables' do @@ -3049,71 +3088,36 @@ describe Ci::Build do end end end - end - describe '#secret_group_variables' do - subject { build.secret_group_variables } - - let!(:variable) { create(:ci_group_variable, protected: true, group: group) } + context 'with dependency variables' do + let!(:prepare) { create(:ci_build, name: 'prepare', pipeline: pipeline, stage_idx: 0) } + let!(:build) { create(:ci_build, pipeline: pipeline, stage_idx: 1, options: { dependencies: ['prepare'] }) } - context 'when ref is branch' do - let(:build) { create(:ci_build, ref: 'master', tag: false, project: project) } + let!(:job_variable) { create(:ci_job_variable, :dotenv_source, job: prepare) } - context 'when ref is protected' do + context 'FF ci_dependency_variables is enabled' do before do - create(:protected_branch, :developers_can_merge, name: 'master', project: project) + stub_feature_flags(ci_dependency_variables: true) end - it { is_expected.to include(variable) } - end - - context 'when ref is not protected' do - it { is_expected.not_to include(variable) } - end - end - - context 'when ref is tag' do - let(:build) { create(:ci_build, ref: 'v1.1.0', tag: true, project: project) } - - context 'when ref is protected' do - before do - create(:protected_tag, project: project, name: 'v*') + it 'inherits dependent variables' do + expect(build.scoped_variables.to_hash).to include(job_variable.key => job_variable.value) end - - it { is_expected.to include(variable) } - end - - context 'when ref is not protected' do - it { is_expected.not_to include(variable) } end - end - context 'when ref is merge request' do - let(:merge_request) { create(:merge_request, :with_detached_merge_request_pipeline) } - let(:pipeline) { merge_request.pipelines_for_merge_request.first } - let(:build) { create(:ci_build, ref: merge_request.source_branch, tag: false, pipeline: pipeline, project: project) } - - context 'when ref is protected' do + context 'FF ci_dependency_variables is disabled' do before do - create(:protected_branch, :developers_can_merge, name: merge_request.source_branch, project: project) + stub_feature_flags(ci_dependency_variables: false) end - it 'does not return protected variables as it is not supported for merge request pipelines' do - is_expected.not_to include(variable) + it 'does not inherit dependent variables' do + expect(build.scoped_variables.to_hash).not_to include(job_variable.key => job_variable.value) end end - - context 'when ref is not protected' do - it { is_expected.not_to include(variable) } - end end end - describe '#secret_project_variables' do - subject { build.secret_project_variables } - - let!(:variable) { create(:ci_variable, protected: true, project: project) } - + shared_examples "secret CI variables" do context 'when ref is branch' do let(:build) { create(:ci_build, ref: 'master', tag: false, project: project) } @@ -3167,6 +3171,30 @@ describe Ci::Build do end end + describe '#secret_instance_variables' do + subject { build.secret_instance_variables } + + let_it_be(:variable) { create(:ci_instance_variable, protected: true) } + + include_examples "secret CI variables" + end + + describe '#secret_group_variables' do + subject { build.secret_group_variables } + + let_it_be(:variable) { create(:ci_group_variable, protected: true, group: group) } + + include_examples "secret CI variables" + end + + describe '#secret_project_variables' do + subject { build.secret_project_variables } + + let_it_be(:variable) { create(:ci_variable, protected: true, project: project) } + + include_examples "secret CI variables" + end + describe '#deployment_variables' do let(:build) { create(:ci_build, environment: environment) } let(:environment) { 'production' } @@ -3217,6 +3245,29 @@ describe Ci::Build do expect(build.scoped_variables_hash).not_to include('MY_VAR': 'myvar') end end + + context 'when overriding CI instance variables' do + before do + create(:ci_instance_variable, key: 'MY_VAR', value: 'my value 1') + group.variables.create!(key: 'MY_VAR', value: 'my value 2') + end + + it 'returns a regular hash created using valid ordering' do + expect(build.scoped_variables_hash).to include('MY_VAR': 'my value 2') + expect(build.scoped_variables_hash).not_to include('MY_VAR': 'my value 1') + end + end + + context 'when CI instance variables are disabled' do + before do + create(:ci_instance_variable, key: 'MY_VAR', value: 'my value 1') + stub_feature_flags(ci_instance_level_variables: false) + end + + it 'does not include instance level variables' do + expect(build.scoped_variables_hash).not_to include('MY_VAR': 'my value 1') + end + end end describe '#any_unmet_prerequisites?' do @@ -3293,6 +3344,41 @@ describe Ci::Build do end end + describe '#dependency_variables' do + subject { build.dependency_variables } + + context 'when using dependencies' do + let!(:prepare1) { create(:ci_build, name: 'prepare1', pipeline: pipeline, stage_idx: 0) } + let!(:prepare2) { create(:ci_build, name: 'prepare2', pipeline: pipeline, stage_idx: 0) } + let!(:build) { create(:ci_build, pipeline: pipeline, stage_idx: 1, options: { dependencies: ['prepare1'] }) } + + let!(:job_variable_1) { create(:ci_job_variable, :dotenv_source, job: prepare1) } + let!(:job_variable_2) { create(:ci_job_variable, job: prepare1) } + let!(:job_variable_3) { create(:ci_job_variable, :dotenv_source, job: prepare2) } + + it 'inherits only dependent variables' do + expect(subject.to_hash).to eq(job_variable_1.key => job_variable_1.value) + end + end + + context 'when using needs' do + let!(:prepare1) { create(:ci_build, name: 'prepare1', pipeline: pipeline, stage_idx: 0) } + 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!(:build) { create(:ci_build, pipeline: pipeline, stage_idx: 1, scheduling_type: 'dag') } + let!(:build_needs_prepare1) { create(:ci_build_need, build: build, name: 'prepare1', artifacts: true) } + let!(:build_needs_prepare2) { create(:ci_build_need, build: build, name: 'prepare2', artifacts: false) } + + let!(:job_variable_1) { create(:ci_job_variable, :dotenv_source, job: prepare1) } + let!(:job_variable_2) { create(:ci_job_variable, :dotenv_source, job: prepare2) } + let!(:job_variable_3) { create(:ci_job_variable, :dotenv_source, job: prepare3) } + + it 'inherits only needs with artifacts variables' do + expect(subject.to_hash).to eq(job_variable_1.key => job_variable_1.value) + end + end + end + describe 'state transition: any => [:preparing]' do let(:build) { create(:ci_build, :created) } @@ -3822,8 +3908,68 @@ describe Ci::Build do create(:ci_job_artifact, :junit_with_corrupted_data, job: build, project: build.project) end - it 'raises an error' do - expect { subject }.to raise_error(Gitlab::Ci::Parsers::Test::Junit::JunitParserError) + it 'returns no test data and includes a suite_error message' do + expect { subject }.not_to raise_error + + expect(test_reports.get_suite(build.name).total_count).to eq(0) + expect(test_reports.get_suite(build.name).success_count).to eq(0) + expect(test_reports.get_suite(build.name).failed_count).to eq(0) + expect(test_reports.get_suite(build.name).suite_error).to eq('JUnit XML parsing failed: 1:1: FATAL: Document is empty') + end + end + end + end + + describe '#collect_accessibility_reports!' do + subject { build.collect_accessibility_reports!(accessibility_report) } + + let(:accessibility_report) { Gitlab::Ci::Reports::AccessibilityReports.new } + + it { expect(accessibility_report.urls).to eq({}) } + + context 'when build has an accessibility report' do + context 'when there is an accessibility report with errors' do + before do + create(:ci_job_artifact, :accessibility, job: build, project: build.project) + end + + it 'parses blobs and add the results to the accessibility report' do + expect { subject }.not_to raise_error + + expect(accessibility_report.urls.keys).to match_array(['https://about.gitlab.com/']) + expect(accessibility_report.errors_count).to eq(10) + expect(accessibility_report.scans_count).to eq(1) + expect(accessibility_report.passes_count).to eq(0) + end + end + + context 'when there is an accessibility report without errors' do + before do + create(:ci_job_artifact, :accessibility_without_errors, job: build, project: build.project) + end + + it 'parses blobs and add the results to the accessibility report' do + expect { subject }.not_to raise_error + + expect(accessibility_report.urls.keys).to match_array(['https://pa11y.org/']) + expect(accessibility_report.errors_count).to eq(0) + expect(accessibility_report.scans_count).to eq(1) + expect(accessibility_report.passes_count).to eq(1) + end + end + + context 'when there is an accessibility report with an invalid url' do + before do + create(:ci_job_artifact, :accessibility_with_invalid_url, job: build, project: build.project) + end + + it 'parses blobs and add the results to the accessibility report' do + expect { subject }.not_to raise_error + + expect(accessibility_report.urls).to be_empty + expect(accessibility_report.errors_count).to eq(0) + expect(accessibility_report.scans_count).to eq(0) + expect(accessibility_report.passes_count).to eq(0) end end end @@ -3876,6 +4022,48 @@ describe Ci::Build do end end + describe '#collect_terraform_reports!' do + let(:terraform_reports) { Gitlab::Ci::Reports::TerraformReports.new } + + it 'returns an empty hash' do + expect(build.collect_terraform_reports!(terraform_reports).plans).to eq({}) + end + + context 'when build has a terraform report' do + context 'when there is a valid tfplan.json' do + before do + create(:ci_job_artifact, :terraform, job: build, project: build.project) + end + + it 'parses blobs and add the results to the terraform report' do + expect { build.collect_terraform_reports!(terraform_reports) }.not_to raise_error + + expect(terraform_reports.plans).to match( + a_hash_including( + 'tfplan.json' => a_hash_including( + 'create' => 0, + 'update' => 1, + 'delete' => 0 + ) + ) + ) + end + end + + context 'when there is an invalid tfplan.json' do + before do + create(:ci_job_artifact, :terraform_with_corrupted_data, job: build, project: build.project) + end + + it 'raises an error' do + expect { build.collect_terraform_reports!(terraform_reports) }.to raise_error( + Gitlab::Ci::Parsers::Terraform::Tfplan::TfplanParserError + ) + end + end + end + end + describe '#report_artifacts' do subject { build.report_artifacts } @@ -3986,6 +4174,28 @@ describe Ci::Build do it { is_expected.to include(:upload_multiple_artifacts) } end + + context 'when artifacts exclude is defined and the is feature enabled' do + let(:options) do + { artifacts: { exclude: %w[something] } } + end + + context 'when a feature flag is enabled' do + before do + stub_feature_flags(ci_artifacts_exclude: true) + end + + it { is_expected.to include(:artifacts_exclude) } + end + + context 'when a feature flag is disabled' do + before do + stub_feature_flags(ci_artifacts_exclude: false) + end + + it { is_expected.not_to include(:artifacts_exclude) } + end + end end describe '#supported_runner?' do @@ -4312,4 +4522,31 @@ describe Ci::Build do it { is_expected.to be_nil } end end + + describe '#degradation_threshold' do + subject { build.degradation_threshold } + + context 'when threshold variable is defined' do + before do + build.yaml_variables = [ + { key: 'SOME_VAR_1', value: 'SOME_VAL_1' }, + { key: 'DEGRADATION_THRESHOLD', value: '5' }, + { key: 'SOME_VAR_2', value: 'SOME_VAL_2' } + ] + end + + it { is_expected.to eq(5) } + end + + context 'when threshold variable is not defined' do + before do + build.yaml_variables = [ + { key: 'SOME_VAR_1', value: 'SOME_VAL_1' }, + { key: 'SOME_VAR_2', value: 'SOME_VAL_2' } + ] + end + + it { is_expected.to be_nil } + end + end end diff --git a/spec/models/ci/daily_report_result_spec.rb b/spec/models/ci/daily_build_group_report_result_spec.rb index 61aa58c6692..d4c305c649a 100644 --- a/spec/models/ci/daily_report_result_spec.rb +++ b/spec/models/ci/daily_build_group_report_result_spec.rb @@ -2,14 +2,14 @@ require 'spec_helper' -describe Ci::DailyReportResult do +describe Ci::DailyBuildGroupReportResult do describe '.upsert_reports' do let!(:rspec_coverage) do create( - :ci_daily_report_result, - title: 'rspec', + :ci_daily_build_group_report_result, + group_name: 'rspec', date: '2020-03-09', - value: 71.2 + data: { coverage: 71.2 } ) end let!(:new_pipeline) { create(:ci_pipeline) } @@ -19,20 +19,18 @@ describe Ci::DailyReportResult do { project_id: rspec_coverage.project_id, ref_path: rspec_coverage.ref_path, - param_type: described_class.param_types[rspec_coverage.param_type], last_pipeline_id: new_pipeline.id, date: rspec_coverage.date, - title: 'rspec', - value: 81.0 + group_name: 'rspec', + data: { 'coverage' => 81.0 } }, { project_id: rspec_coverage.project_id, ref_path: rspec_coverage.ref_path, - param_type: described_class.param_types[rspec_coverage.param_type], last_pipeline_id: new_pipeline.id, date: rspec_coverage.date, - title: 'karma', - value: 87.0 + group_name: 'karma', + data: { 'coverage' => 87.0 } } ]) @@ -40,16 +38,15 @@ describe Ci::DailyReportResult do expect(rspec_coverage).to have_attributes( last_pipeline_id: new_pipeline.id, - value: 81.0 + data: { 'coverage' => 81.0 } ) - expect(described_class.find_by_title('karma')).to have_attributes( + expect(described_class.find_by_group_name('karma')).to have_attributes( project_id: rspec_coverage.project_id, ref_path: rspec_coverage.ref_path, - param_type: rspec_coverage.param_type, last_pipeline_id: new_pipeline.id, date: rspec_coverage.date, - value: 87.0 + data: { 'coverage' => 87.0 } ) end diff --git a/spec/models/ci/freeze_period_spec.rb b/spec/models/ci/freeze_period_spec.rb new file mode 100644 index 00000000000..f7f840c6696 --- /dev/null +++ b/spec/models/ci/freeze_period_spec.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Ci::FreezePeriod, type: :model do + subject { build(:ci_freeze_period) } + + let(:invalid_cron) { '0 0 0 * *' } + + it { is_expected.to belong_to(:project) } + + it { is_expected.to respond_to(:freeze_start) } + it { is_expected.to respond_to(:freeze_end) } + it { is_expected.to respond_to(:cron_timezone) } + + describe 'cron validations' do + it 'allows valid cron patterns' do + freeze_period = build(:ci_freeze_period) + + expect(freeze_period).to be_valid + end + + it 'does not allow invalid cron patterns on freeze_start' do + freeze_period = build(:ci_freeze_period, freeze_start: invalid_cron) + + expect(freeze_period).not_to be_valid + end + + it 'does not allow invalid cron patterns on freeze_end' do + freeze_period = build(:ci_freeze_period, freeze_end: invalid_cron) + + expect(freeze_period).not_to be_valid + end + + it 'does not allow an invalid timezone' do + freeze_period = build(:ci_freeze_period, cron_timezone: 'invalid') + + expect(freeze_period).not_to be_valid + end + + context 'when cron contains trailing whitespaces' do + it 'strips the attribute' do + freeze_period = build(:ci_freeze_period, freeze_start: ' 0 0 * * * ') + + expect(freeze_period).to be_valid + expect(freeze_period.freeze_start).to eq('0 0 * * *') + end + end + end +end diff --git a/spec/models/ci/freeze_period_status_spec.rb b/spec/models/ci/freeze_period_status_spec.rb new file mode 100644 index 00000000000..b700ec8c45f --- /dev/null +++ b/spec/models/ci/freeze_period_status_spec.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true +require 'spec_helper' + +describe Ci::FreezePeriodStatus do + let(:project) { create :project } + # '0 23 * * 5' == "At 23:00 on Friday."", '0 7 * * 1' == "At 07:00 on Monday."" + let(:friday_2300) { '0 23 * * 5' } + let(:monday_0700) { '0 7 * * 1' } + + subject { described_class.new(project: project).execute } + + shared_examples 'within freeze period' do |time| + it 'is frozen' do + Timecop.freeze(time) do + expect(subject).to be_truthy + end + end + end + + shared_examples 'outside freeze period' do |time| + it 'is not frozen' do + Timecop.freeze(time) do + expect(subject).to be_falsy + end + end + end + + describe 'single freeze period' do + let!(:freeze_period) { create(:ci_freeze_period, project: project, freeze_start: friday_2300, freeze_end: monday_0700) } + + it_behaves_like 'outside freeze period', Time.utc(2020, 4, 10, 22, 59) + + it_behaves_like 'within freeze period', Time.utc(2020, 4, 10, 23, 1) + + it_behaves_like 'within freeze period', Time.utc(2020, 4, 13, 6, 59) + + it_behaves_like 'outside freeze period', Time.utc(2020, 4, 13, 7, 1) + end + + describe 'multiple freeze periods' do + # '30 23 * * 5' == "At 23:30 on Friday."", '0 8 * * 1' == "At 08:00 on Monday."" + let(:friday_2330) { '30 23 * * 5' } + let(:monday_0800) { '0 8 * * 1' } + + let!(:freeze_period_1) { create(:ci_freeze_period, project: project, freeze_start: friday_2300, freeze_end: monday_0700) } + let!(:freeze_period_2) { create(:ci_freeze_period, project: project, freeze_start: friday_2330, freeze_end: monday_0800) } + + it_behaves_like 'outside freeze period', Time.utc(2020, 4, 10, 22, 59) + + it_behaves_like 'within freeze period', Time.utc(2020, 4, 10, 23, 29) + + it_behaves_like 'within freeze period', Time.utc(2020, 4, 11, 10, 0) + + it_behaves_like 'within freeze period', Time.utc(2020, 4, 10, 23, 1) + + it_behaves_like 'within freeze period', Time.utc(2020, 4, 13, 6, 59) + + it_behaves_like 'within freeze period', Time.utc(2020, 4, 13, 7, 59) + + it_behaves_like 'outside freeze period', Time.utc(2020, 4, 13, 8, 1) + end +end diff --git a/spec/models/ci/instance_variable_spec.rb b/spec/models/ci/instance_variable_spec.rb new file mode 100644 index 00000000000..ff8676e1424 --- /dev/null +++ b/spec/models/ci/instance_variable_spec.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Ci::InstanceVariable do + subject { build(:ci_instance_variable) } + + it_behaves_like "CI variable" + + it { is_expected.to include_module(Ci::Maskable) } + it { is_expected.to validate_uniqueness_of(:key).with_message(/\(\w+\) has already been taken/) } + + describe '.unprotected' do + subject { described_class.unprotected } + + context 'when variable is protected' do + before do + create(:ci_instance_variable, :protected) + end + + it 'returns nothing' do + is_expected.to be_empty + end + end + + context 'when variable is not protected' do + let(:variable) { create(:ci_instance_variable, protected: false) } + + it 'returns the variable' do + is_expected.to contain_exactly(variable) + end + end + end + + describe '.all_cached', :use_clean_rails_memory_store_caching do + let_it_be(:unprotected_variable) { create(:ci_instance_variable, protected: false) } + let_it_be(:protected_variable) { create(:ci_instance_variable, protected: true) } + + it { expect(described_class.all_cached).to contain_exactly(protected_variable, unprotected_variable) } + + it 'memoizes the result' do + expect(described_class).to receive(:store_cache).with(:ci_instance_variable_data).once.and_call_original + + 2.times do + expect(described_class.all_cached).to contain_exactly(protected_variable, unprotected_variable) + end + end + + it 'removes scopes' do + expect(described_class.unprotected.all_cached).to contain_exactly(protected_variable, unprotected_variable) + end + + it 'resets the cache when records are deleted' do + expect(described_class.all_cached).to contain_exactly(protected_variable, unprotected_variable) + + protected_variable.destroy + + expect(described_class.all_cached).to contain_exactly(unprotected_variable) + end + + it 'resets the cache when records are inserted' do + expect(described_class.all_cached).to contain_exactly(protected_variable, unprotected_variable) + + variable = create(:ci_instance_variable, protected: true) + + expect(described_class.all_cached).to contain_exactly(protected_variable, unprotected_variable, variable) + end + + it 'resets the cache when the shared key is missing' do + expect(Rails.cache).to receive(:read).with(:ci_instance_variable_changed_at).twice.and_return(nil) + expect(described_class).to receive(:store_cache).with(:ci_instance_variable_data).thrice.and_call_original + + 3.times do + expect(described_class.all_cached).to contain_exactly(protected_variable, unprotected_variable) + end + end + end + + describe '.unprotected_cached', :use_clean_rails_memory_store_caching do + let_it_be(:unprotected_variable) { create(:ci_instance_variable, protected: false) } + let_it_be(:protected_variable) { create(:ci_instance_variable, protected: true) } + + it { expect(described_class.unprotected_cached).to contain_exactly(unprotected_variable) } + + it 'memoizes the result' do + expect(described_class).to receive(:store_cache).with(:ci_instance_variable_data).once.and_call_original + + 2.times do + expect(described_class.unprotected_cached).to contain_exactly(unprotected_variable) + end + end + end +end diff --git a/spec/models/ci/job_artifact_spec.rb b/spec/models/ci/job_artifact_spec.rb index 6f6ff3704b4..4cdc74d7a41 100644 --- a/spec/models/ci/job_artifact_spec.rb +++ b/spec/models/ci/job_artifact_spec.rb @@ -19,24 +19,8 @@ describe Ci::JobArtifact do it_behaves_like 'having unique enum values' - context 'with update_project_statistics_after_commit enabled' do - before do - stub_feature_flags(update_project_statistics_after_commit: true) - end - - it_behaves_like 'UpdateProjectStatistics' do - subject { build(:ci_job_artifact, :archive, size: 107464) } - end - end - - context 'with update_project_statistics_after_commit disabled' do - before do - stub_feature_flags(update_project_statistics_after_commit: false) - end - - it_behaves_like 'UpdateProjectStatistics' do - subject { build(:ci_job_artifact, :archive, size: 107464) } - end + it_behaves_like 'UpdateProjectStatistics' do + subject { build(:ci_job_artifact, :archive, size: 107464) } end describe '.with_reports' do @@ -70,6 +54,22 @@ describe Ci::JobArtifact do end end + describe '.accessibility_reports' do + subject { described_class.accessibility_reports } + + context 'when there is an accessibility report' do + let(:artifact) { create(:ci_job_artifact, :accessibility) } + + it { is_expected.to eq([artifact]) } + end + + context 'when there are no accessibility report' do + let(:artifact) { create(:ci_job_artifact, :archive) } + + it { is_expected.to be_empty } + end + end + describe '.coverage_reports' do subject { described_class.coverage_reports } @@ -86,6 +86,22 @@ describe Ci::JobArtifact do end end + describe '.terraform_reports' do + context 'when there is a terraform report' do + it 'return the job artifact' do + artifact = create(:ci_job_artifact, :terraform) + + expect(described_class.terraform_reports).to eq([artifact]) + end + end + + context 'when there are no terraform reports' do + it 'return the an empty array' do + expect(described_class.terraform_reports).to eq([]) + end + end + end + describe '.erasable' do subject { described_class.erasable } @@ -128,15 +144,26 @@ describe Ci::JobArtifact do end describe '.for_sha' do + let(:first_pipeline) { create(:ci_pipeline) } + let(:second_pipeline) { create(:ci_pipeline, project: first_pipeline.project, sha: Digest::SHA1.hexdigest(SecureRandom.hex)) } + let!(:first_artifact) { create(:ci_job_artifact, job: create(:ci_build, pipeline: first_pipeline)) } + let!(:second_artifact) { create(:ci_job_artifact, job: create(:ci_build, pipeline: second_pipeline)) } + it 'returns job artifacts for a given pipeline sha' do - project = create(:project) - first_pipeline = create(:ci_pipeline, project: project) - second_pipeline = create(:ci_pipeline, project: project, sha: Digest::SHA1.hexdigest(SecureRandom.hex)) - first_artifact = create(:ci_job_artifact, job: create(:ci_build, pipeline: first_pipeline)) - second_artifact = create(:ci_job_artifact, job: create(:ci_build, pipeline: second_pipeline)) + expect(described_class.for_sha(first_pipeline.sha, first_pipeline.project.id)).to eq([first_artifact]) + expect(described_class.for_sha(second_pipeline.sha, first_pipeline.project.id)).to eq([second_artifact]) + end + end - expect(described_class.for_sha(first_pipeline.sha, project.id)).to eq([first_artifact]) - expect(described_class.for_sha(second_pipeline.sha, project.id)).to eq([second_artifact]) + describe '.for_ref' do + let(:first_pipeline) { create(:ci_pipeline, ref: 'first_ref') } + let(:second_pipeline) { create(:ci_pipeline, ref: 'second_ref', project: first_pipeline.project) } + let!(:first_artifact) { create(:ci_job_artifact, job: create(:ci_build, pipeline: first_pipeline)) } + let!(:second_artifact) { create(:ci_job_artifact, job: create(:ci_build, pipeline: second_pipeline)) } + + it 'returns job artifacts for a given pipeline ref' do + expect(described_class.for_ref(first_pipeline.ref, first_pipeline.project.id)).to eq([first_artifact]) + expect(described_class.for_ref(second_pipeline.ref, first_pipeline.project.id)).to eq([second_artifact]) end end @@ -153,9 +180,9 @@ describe Ci::JobArtifact do end describe 'callbacks' do - subject { create(:ci_job_artifact, :archive) } - describe '#schedule_background_upload' do + subject { create(:ci_job_artifact, :archive) } + context 'when object storage is disabled' do before do stub_artifacts_object_storage(enabled: false) @@ -212,9 +239,35 @@ describe Ci::JobArtifact do end end + describe 'validates if file format is supported' do + subject { artifact } + + let(:artifact) { build(:ci_job_artifact, file_type: :license_management, file_format: :raw) } + + context 'when license_management is supported' do + before do + stub_feature_flags(drop_license_management_artifact: false) + end + + it { is_expected.to be_valid } + end + + context 'when license_management is not supported' do + before do + stub_feature_flags(drop_license_management_artifact: true) + end + + it { is_expected.not_to be_valid } + end + end + describe 'validates file format' do subject { artifact } + before do + stub_feature_flags(drop_license_management_artifact: false) + end + described_class::TYPE_AND_FORMAT_PAIRS.except(:trace).each do |file_type, file_format| context "when #{file_type} type with #{file_format} format" do let(:artifact) { build(:ci_job_artifact, file_type: file_type, file_format: file_format) } @@ -351,19 +404,6 @@ describe Ci::JobArtifact do describe 'file is being stored' do subject { create(:ci_job_artifact, :archive) } - context 'when object has nil store' do - before do - subject.update_column(:file_store, nil) - subject.reload - end - - it 'is stored locally' do - expect(subject.file_store).to be(nil) - expect(subject.file).to be_file_storage - expect(subject.file.object_store).to eq(ObjectStorage::Store::LOCAL) - end - end - context 'when existing object has local store' do it 'is stored locally' do expect(subject.file_store).to be(ObjectStorage::Store::LOCAL) diff --git a/spec/models/ci/persistent_ref_spec.rb b/spec/models/ci/persistent_ref_spec.rb index 4cece0664cf..89dd9b05331 100644 --- a/spec/models/ci/persistent_ref_spec.rb +++ b/spec/models/ci/persistent_ref_spec.rb @@ -45,18 +45,6 @@ describe Ci::PersistentRef do expect(pipeline.persistent_ref).to be_exist end - context 'when depend_on_persistent_pipeline_ref feature flag is disabled' do - before do - stub_feature_flags(depend_on_persistent_pipeline_ref: false) - end - - it 'does not create a persistent ref' do - expect(project.repository).not_to receive(:create_ref) - - subject - end - end - context 'when sha does not exist in the repository' do let(:sha) { 'not-exist' } diff --git a/spec/models/ci/pipeline_schedule_spec.rb b/spec/models/ci/pipeline_schedule_spec.rb index 4ed4b7e38d8..9a10c7629b2 100644 --- a/spec/models/ci/pipeline_schedule_spec.rb +++ b/spec/models/ci/pipeline_schedule_spec.rb @@ -17,14 +17,18 @@ describe Ci::PipelineSchedule do it { is_expected.to respond_to(:description) } it { is_expected.to respond_to(:next_run_at) } + it_behaves_like 'includes Limitable concern' do + subject { build(:ci_pipeline_schedule) } + end + describe 'validations' do - it 'does not allow invalid cron patters' do + it 'does not allow invalid cron patterns' do pipeline_schedule = build(:ci_pipeline_schedule, cron: '0 0 0 * *') expect(pipeline_schedule).not_to be_valid end - it 'does not allow invalid cron patters' do + it 'does not allow invalid cron patterns' do pipeline_schedule = build(:ci_pipeline_schedule, cron_timezone: 'invalid') expect(pipeline_schedule).not_to be_valid diff --git a/spec/models/ci/pipeline_spec.rb b/spec/models/ci/pipeline_spec.rb index 90412136c1d..4f53b6b4418 100644 --- a/spec/models/ci/pipeline_spec.rb +++ b/spec/models/ci/pipeline_spec.rb @@ -53,6 +53,29 @@ describe Ci::Pipeline, :mailer do end end + describe '#set_status' do + where(:from_status, :to_status) do + from_status_names = described_class.state_machines[:status].states.map(&:name) + to_status_names = from_status_names - [:created] # we never want to transition into created + + from_status_names.product(to_status_names) + end + + with_them do + it do + pipeline.status = from_status.to_s + + if from_status != to_status + expect(pipeline.set_status(to_status.to_s)) + .to eq(true) + else + expect(pipeline.set_status(to_status.to_s)) + .to eq(false), "loopback transitions are not allowed" + end + end + end + end + describe '.processables' do before do create(:ci_build, name: 'build', pipeline: pipeline) @@ -364,6 +387,26 @@ describe Ci::Pipeline, :mailer do end end + context 'when pipeline has an accessibility report' do + subject { described_class.with_reports(Ci::JobArtifact.accessibility_reports) } + + let(:pipeline_with_report) { create(:ci_pipeline, :with_accessibility_reports) } + + it 'selects the pipeline' do + is_expected.to eq([pipeline_with_report]) + end + end + + context 'when pipeline has a terraform report' do + it 'selects the pipeline' do + pipeline_with_report = create(:ci_pipeline, :with_terraform_reports) + + expect(described_class.with_reports(Ci::JobArtifact.terraform_reports)).to eq( + [pipeline_with_report] + ) + end + end + context 'when pipeline does not have metrics reports' do subject { described_class.with_reports(Ci::JobArtifact.test_reports) } @@ -699,6 +742,28 @@ describe Ci::Pipeline, :mailer do ) end end + + describe 'variable CI_KUBERNETES_ACTIVE' do + context 'when pipeline.has_kubernetes_active? is true' do + before do + allow(pipeline).to receive(:has_kubernetes_active?).and_return(true) + end + + it "is included with value 'true'" do + expect(subject.to_hash).to include('CI_KUBERNETES_ACTIVE' => 'true') + end + end + + context 'when pipeline.has_kubernetes_active? is false' do + before do + allow(pipeline).to receive(:has_kubernetes_active?).and_return(false) + end + + it 'is not included' do + expect(subject.to_hash).not_to have_key('CI_KUBERNETES_ACTIVE') + end + end + end end describe '#protected_ref?' do @@ -944,7 +1009,10 @@ describe Ci::Pipeline, :mailer do context 'when using legacy stages' do before do - stub_feature_flags(ci_pipeline_persisted_stages: false) + stub_feature_flags( + ci_pipeline_persisted_stages: false, + ci_atomic_processing: false + ) end it 'returns legacy stages in valid order' do @@ -952,9 +1020,40 @@ describe Ci::Pipeline, :mailer do end end + context 'when using atomic processing' do + before do + stub_feature_flags( + ci_atomic_processing: true + ) + end + + context 'when pipelines is not complete' do + it 'returns stages in valid order' do + expect(subject).to all(be_a Ci::Stage) + expect(subject.map(&:name)) + .to eq %w[sanity build test deploy cleanup] + end + end + + context 'when pipeline is complete' do + before do + pipeline.succeed! + end + + it 'returns stages in valid order' do + expect(subject).to all(be_a Ci::Stage) + expect(subject.map(&:name)) + .to eq %w[sanity build test deploy cleanup] + end + end + end + context 'when using persisted stages' do before do - stub_feature_flags(ci_pipeline_persisted_stages: true) + stub_feature_flags( + ci_pipeline_persisted_stages: true, + ci_atomic_processing: false + ) end context 'when pipelines is not complete' do @@ -1119,8 +1218,8 @@ describe Ci::Pipeline, :mailer do context "from #{status}" do let(:from_status) { status } - it 'schedules pipeline success worker' do - expect(Ci::DailyReportResultsWorker).to receive(:perform_in).with(10.minutes, pipeline.id) + it 'schedules daily build group report results worker' do + expect(Ci::DailyBuildGroupReportResultsWorker).to receive(:perform_in).with(10.minutes, pipeline.id) pipeline.succeed end @@ -2307,7 +2406,7 @@ describe Ci::Pipeline, :mailer do def have_requested_pipeline_hook(status) have_requested(:post, stubbed_hostname(hook.url)).with do |req| - json_body = JSON.parse(req.body) + json_body = Gitlab::Json.parse(req.body) json_body['object_attributes']['status'] == status && json_body['builds'].length == 2 end @@ -2755,6 +2854,42 @@ describe Ci::Pipeline, :mailer do end end + describe '#accessibility_reports' do + subject { pipeline.accessibility_reports } + + context 'when pipeline has multiple builds with accessibility reports' do + let(:build_rspec) { create(:ci_build, :success, name: 'rspec', pipeline: pipeline, project: project) } + let(:build_golang) { create(:ci_build, :success, name: 'golang', pipeline: pipeline, project: project) } + + before do + create(:ci_job_artifact, :accessibility, job: build_rspec, project: project) + create(:ci_job_artifact, :accessibility_without_errors, job: build_golang, project: project) + end + + it 'returns accessibility report with collected data' do + expect(subject.urls.keys).to match_array([ + "https://pa11y.org/", + "https://about.gitlab.com/" + ]) + end + + context 'when builds are retried' do + let(:build_rspec) { create(:ci_build, :retried, :success, name: 'rspec', pipeline: pipeline, project: project) } + let(:build_golang) { create(:ci_build, :retried, :success, name: 'golang', pipeline: pipeline, project: project) } + + it 'returns empty urls for accessibility reports' do + expect(subject.urls).to be_empty + end + end + end + + context 'when pipeline does not have any builds with accessibility reports' do + it 'returns empty urls for accessibility reports' do + expect(subject.urls).to be_empty + end + end + end + describe '#coverage_reports' do subject { pipeline.coverage_reports } diff --git a/spec/models/ci/processable_spec.rb b/spec/models/ci/processable_spec.rb index 4490371bde5..e67f740279b 100644 --- a/spec/models/ci/processable_spec.rb +++ b/spec/models/ci/processable_spec.rb @@ -6,16 +6,12 @@ describe Ci::Processable do let_it_be(:project) { create(:project) } let_it_be(:pipeline) { create(:ci_pipeline, project: project) } - let_it_be(:detached_merge_request_pipeline) do - create(:ci_pipeline, :detached_merge_request_pipeline, :with_job, project: project) - end - - let_it_be(:legacy_detached_merge_request_pipeline) do - create(:ci_pipeline, :legacy_detached_merge_request_pipeline, :with_job, project: project) - end + describe 'delegations' do + subject { Ci::Processable.new } - let_it_be(:merged_result_pipeline) do - create(:ci_pipeline, :merged_result_pipeline, :with_job, project: project) + it { is_expected.to delegate_method(:merge_request?).to(:pipeline) } + it { is_expected.to delegate_method(:merge_request_ref?).to(:pipeline) } + it { is_expected.to delegate_method(:legacy_detached_merge_request_pipeline?).to(:pipeline) } end describe '#aggregated_needs_names' do @@ -52,69 +48,28 @@ describe Ci::Processable do end describe 'validate presence of scheduling_type' do - context 'on create' do - let(:processable) do - build( - :ci_build, :created, project: project, pipeline: pipeline, - importing: importing, scheduling_type: nil - ) - end - - context 'when importing' do - let(:importing) { true } - - context 'when validate_scheduling_type_of_processables is true' do - before do - stub_feature_flags(validate_scheduling_type_of_processables: true) - end + using RSpec::Parameterized::TableSyntax - it 'does not validate' do - expect(processable).to be_valid - end - end - - context 'when validate_scheduling_type_of_processables is false' do - before do - stub_feature_flags(validate_scheduling_type_of_processables: false) - end - - it 'does not validate' do - expect(processable).to be_valid - end - end - end + subject { build(:ci_build, project: project, pipeline: pipeline, importing: importing) } - context 'when not importing' do - let(:importing) { false } - - context 'when validate_scheduling_type_of_processables is true' do - before do - stub_feature_flags(validate_scheduling_type_of_processables: true) - end - - it 'validates' do - expect(processable).not_to be_valid - end - end - - context 'when validate_scheduling_type_of_processables is false' do - before do - stub_feature_flags(validate_scheduling_type_of_processables: false) - end + where(:importing, :should_validate) do + false | true + true | false + end - it 'does not validate' do - expect(processable).to be_valid + with_them do + context 'on create' do + it 'validates presence' do + if should_validate + is_expected.to validate_presence_of(:scheduling_type).on(:create) + else + is_expected.not_to validate_presence_of(:scheduling_type).on(:create) end end end - end - - context 'on update' do - let(:processable) { create(:ci_build, :created, project: project, pipeline: pipeline) } - it 'does not validate' do - processable.scheduling_type = nil - expect(processable).to be_valid + context 'on update' do + it { is_expected.not_to validate_presence_of(:scheduling_type).on(:update) } end end end @@ -147,6 +102,8 @@ describe Ci::Processable do describe '#needs_attributes' do let(:build) { create(:ci_build, :created, project: project, pipeline: pipeline) } + subject { build.needs_attributes } + context 'with needs' do before do create(:ci_build_need, build: build, name: 'test1') @@ -154,7 +111,7 @@ describe Ci::Processable do end it 'returns all needs attributes' do - expect(build.needs_attributes).to contain_exactly( + is_expected.to contain_exactly( { 'artifacts' => true, 'name' => 'test1' }, { 'artifacts' => true, 'name' => 'test2' } ) @@ -162,75 +119,7 @@ describe Ci::Processable do end context 'without needs' do - it 'returns all needs attributes' do - expect(build.needs_attributes).to be_empty - end - end - end - - describe '#merge_request?' do - subject { pipeline.processables.first.merge_request? } - - context 'in a detached merge request pipeline' do - let(:pipeline) { detached_merge_request_pipeline } - - it { is_expected.to eq(pipeline.merge_request?) } - end - - context 'in a legacy detached merge_request_pipeline' do - let(:pipeline) { legacy_detached_merge_request_pipeline } - - it { is_expected.to eq(pipeline.merge_request?) } - end - - context 'in a pipeline for merged results' do - let(:pipeline) { merged_result_pipeline } - - it { is_expected.to eq(pipeline.merge_request?) } - end - end - - describe '#merge_request_ref?' do - subject { pipeline.processables.first.merge_request_ref? } - - context 'in a detached merge request pipeline' do - let(:pipeline) { detached_merge_request_pipeline } - - it { is_expected.to eq(pipeline.merge_request_ref?) } - end - - context 'in a legacy detached merge_request_pipeline' do - let(:pipeline) { legacy_detached_merge_request_pipeline } - - it { is_expected.to eq(pipeline.merge_request_ref?) } - end - - context 'in a pipeline for merged results' do - let(:pipeline) { merged_result_pipeline } - - it { is_expected.to eq(pipeline.merge_request_ref?) } - end - end - - describe '#legacy_detached_merge_request_pipeline?' do - subject { pipeline.processables.first.legacy_detached_merge_request_pipeline? } - - context 'in a detached merge request pipeline' do - let(:pipeline) { detached_merge_request_pipeline } - - it { is_expected.to eq(pipeline.legacy_detached_merge_request_pipeline?) } - end - - context 'in a legacy detached merge_request_pipeline' do - let(:pipeline) { legacy_detached_merge_request_pipeline } - - it { is_expected.to eq(pipeline.legacy_detached_merge_request_pipeline?) } - end - - context 'in a pipeline for merged results' do - let(:pipeline) { merged_result_pipeline } - - it { is_expected.to eq(pipeline.legacy_detached_merge_request_pipeline?) } + it { is_expected.to be_empty } end end end diff --git a/spec/models/ci/runner_spec.rb b/spec/models/ci/runner_spec.rb index 2dedff7f15b..8b6a4fa6ade 100644 --- a/spec/models/ci/runner_spec.rb +++ b/spec/models/ci/runner_spec.rb @@ -270,7 +270,7 @@ describe Ci::Runner do it { is_expected.to eq([@runner2])} end - describe '#online?' do + describe '#online?', :clean_gitlab_redis_cache do let(:runner) { create(:ci_runner, :instance) } subject { runner.online? } @@ -332,7 +332,7 @@ describe Ci::Runner do end def stub_redis_runner_contacted_at(value) - Gitlab::Redis::SharedState.with do |redis| + Gitlab::Redis::Cache.with do |redis| cache_key = runner.send(:cache_attribute_key) expect(redis).to receive(:get).with(cache_key) .and_return({ contacted_at: value }.to_json).at_least(:once) @@ -640,7 +640,7 @@ describe Ci::Runner do end def expect_redis_update - Gitlab::Redis::SharedState.with do |redis| + Gitlab::Redis::Cache.with do |redis| redis_key = runner.send(:cache_attribute_key) expect(redis).to receive(:set).with(redis_key, anything, any_args) end @@ -664,7 +664,7 @@ describe Ci::Runner do end it 'cleans up the queue' do - Gitlab::Redis::SharedState.with do |redis| + Gitlab::Redis::Cache.with do |redis| expect(redis.get(queue_key)).to be_nil end end diff --git a/spec/models/ci/stage_spec.rb b/spec/models/ci/stage_spec.rb index 3aeaa27abce..a1549532559 100644 --- a/spec/models/ci/stage_spec.rb +++ b/spec/models/ci/stage_spec.rb @@ -3,7 +3,8 @@ require 'spec_helper' describe Ci::Stage, :models do - let(:stage) { create(:ci_stage_entity) } + let_it_be(:pipeline) { create(:ci_empty_pipeline) } + let(:stage) { create(:ci_stage_entity, pipeline: pipeline, project: pipeline.project) } it_behaves_like 'having unique enum values' @@ -55,6 +56,29 @@ describe Ci::Stage, :models do end end + describe '#set_status' do + where(:from_status, :to_status) do + from_status_names = described_class.state_machines[:status].states.map(&:name) + to_status_names = from_status_names - [:created] # we never want to transition into created + + from_status_names.product(to_status_names) + end + + with_them do + it do + stage.status = from_status.to_s + + if from_status != to_status + expect(stage.set_status(to_status.to_s)) + .to eq(true) + else + expect(stage.set_status(to_status.to_s)) + .to eq(false), "loopback transitions are not allowed" + end + end + end + end + describe '#update_status' do context 'when stage objects needs to be updated' do before do diff --git a/spec/models/clusters/applications/elastic_stack_spec.rb b/spec/models/clusters/applications/elastic_stack_spec.rb index b0992c43d11..02ada219e32 100644 --- a/spec/models/clusters/applications/elastic_stack_spec.rb +++ b/spec/models/clusters/applications/elastic_stack_spec.rb @@ -19,10 +19,12 @@ describe Clusters::Applications::ElasticStack do it 'is initialized with elastic stack arguments' do expect(subject.name).to eq('elastic-stack') - expect(subject.chart).to eq('stable/elastic-stack') - expect(subject.version).to eq('1.9.0') + expect(subject.chart).to eq('elastic-stack/elastic-stack') + expect(subject.version).to eq('3.0.0') + expect(subject.repository).to eq('https://charts.gitlab.io') expect(subject).to be_rbac expect(subject.files).to eq(elastic_stack.files) + expect(subject.preinstall).to be_empty end context 'on a non rbac enabled cluster' do @@ -33,15 +35,75 @@ describe Clusters::Applications::ElasticStack do it { is_expected.not_to be_rbac } end + context 'on versions older than 2' do + before do + elastic_stack.status = elastic_stack.status_states[:updating] + elastic_stack.version = "1.9.0" + end + + it 'includes a preinstall script' do + expect(subject.preinstall).not_to be_empty + expect(subject.preinstall.first).to include("delete") + end + end + + context 'on versions older than 3' do + before do + elastic_stack.status = elastic_stack.status_states[:updating] + elastic_stack.version = "2.9.0" + end + + it 'includes a preinstall script' do + expect(subject.preinstall).not_to be_empty + expect(subject.preinstall.first).to include("delete") + end + end + context 'application failed to install previously' do let(:elastic_stack) { create(:clusters_applications_elastic_stack, :errored, version: '0.0.1') } it 'is initialized with the locked version' do - expect(subject.version).to eq('1.9.0') + expect(subject.version).to eq('3.0.0') end end end + describe '#chart_above_v2?' do + let(:elastic_stack) { create(:clusters_applications_elastic_stack, version: version) } + + subject { elastic_stack.chart_above_v2? } + + context 'on v1.9.0' do + let(:version) { '1.9.0' } + + it { is_expected.to be_falsy } + end + + context 'on v2.0.0' do + let(:version) { '2.0.0' } + + it { is_expected.to be_truthy } + end + end + + describe '#chart_above_v3?' do + let(:elastic_stack) { create(:clusters_applications_elastic_stack, version: version) } + + subject { elastic_stack.chart_above_v3? } + + context 'on v1.9.0' do + let(:version) { '1.9.0' } + + it { is_expected.to be_falsy } + end + + context 'on v3.0.0' do + let(:version) { '3.0.0' } + + it { is_expected.to be_truthy } + end + end + describe '#uninstall_command' do let!(:elastic_stack) { create(:clusters_applications_elastic_stack) } @@ -57,7 +119,7 @@ describe Clusters::Applications::ElasticStack do it 'specifies a post delete command to remove custom resource definitions' do expect(subject.postdelete).to eq([ - 'kubectl delete pvc --selector release\\=elastic-stack' + 'kubectl delete pvc --selector app\\=elastic-stack-elasticsearch-master --namespace gitlab-managed-apps' ]) end end diff --git a/spec/models/clusters/applications/fluentd_spec.rb b/spec/models/clusters/applications/fluentd_spec.rb index 7e9680b0ab4..4e9548990ed 100644 --- a/spec/models/clusters/applications/fluentd_spec.rb +++ b/spec/models/clusters/applications/fluentd_spec.rb @@ -3,7 +3,9 @@ require 'spec_helper' describe Clusters::Applications::Fluentd do - let(:fluentd) { create(:clusters_applications_fluentd) } + let(:waf_log_enabled) { true } + let(:cilium_log_enabled) { true } + let(:fluentd) { create(:clusters_applications_fluentd, waf_log_enabled: waf_log_enabled, cilium_log_enabled: cilium_log_enabled) } include_examples 'cluster application core specs', :clusters_applications_fluentd include_examples 'cluster application status specs', :clusters_applications_fluentd @@ -47,4 +49,36 @@ describe Clusters::Applications::Fluentd do expect(values).to include('output.conf', 'general.conf') end end + + describe '#values' do + let(:modsecurity_log_path) { "/var/log/containers/*#{Clusters::Applications::Ingress::MODSECURITY_LOG_CONTAINER_NAME}*.log" } + let(:cilium_log_path) { "/var/log/containers/*#{described_class::CILIUM_CONTAINER_NAME}*.log" } + + subject { fluentd.values } + + context 'with both logs variables set to false' do + let(:waf_log_enabled) { false } + let(:cilium_log_enabled) { false } + + it "raises ActiveRecord::RecordInvalid" do + expect {subject}.to raise_error(ActiveRecord::RecordInvalid) + end + end + + context 'with both logs variables set to true' do + it { is_expected.to include("#{modsecurity_log_path},#{cilium_log_path}") } + end + + context 'with waf_log_enabled set to true' do + let(:cilium_log_enabled) { false } + + it { is_expected.to include(modsecurity_log_path) } + end + + context 'with cilium_log_enabled set to true' do + let(:waf_log_enabled) { false } + + it { is_expected.to include(cilium_log_path) } + end + end end diff --git a/spec/models/clusters/applications/ingress_spec.rb b/spec/models/clusters/applications/ingress_spec.rb index b070729ccac..8aee4eec0d3 100644 --- a/spec/models/clusters/applications/ingress_spec.rb +++ b/spec/models/clusters/applications/ingress_spec.rb @@ -220,6 +220,12 @@ describe Clusters::Applications::Ingress do expect(subject.values).to include('extraContainers') end + it 'executes command to tail modsecurity logs with -F option' do + args = YAML.safe_load(subject.values).dig('controller', 'extraContainers', 0, 'args') + + expect(args).to eq(['/bin/sh', '-c', 'tail -F /var/log/modsec/audit.log']) + end + it 'includes livenessProbe for modsecurity sidecar container' do probe_config = YAML.safe_load(subject.values).dig('controller', 'extraContainers', 0, 'livenessProbe') diff --git a/spec/models/clusters/applications/jupyter_spec.rb b/spec/models/clusters/applications/jupyter_spec.rb index 3bc5088d1ab..937db9217f3 100644 --- a/spec/models/clusters/applications/jupyter_spec.rb +++ b/spec/models/clusters/applications/jupyter_spec.rb @@ -57,7 +57,7 @@ describe Clusters::Applications::Jupyter do 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-beta.2') + expect(subject.version).to eq('0.9.0') expect(subject).to be_rbac expect(subject.repository).to eq('https://jupyterhub.github.io/helm-chart/') @@ -76,7 +76,7 @@ describe Clusters::Applications::Jupyter 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-beta.2') + expect(subject.version).to eq('0.9.0') end end end diff --git a/spec/models/clusters/cluster_spec.rb b/spec/models/clusters/cluster_spec.rb index db1d8672d1e..521ed98f637 100644 --- a/spec/models/clusters/cluster_spec.rb +++ b/spec/models/clusters/cluster_spec.rb @@ -590,6 +590,60 @@ describe Clusters::Cluster, :use_clean_rails_memory_store_caching do 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!(:helm) { create(:clusters_applications_helm, cluster: cluster) } + let!(:ingress) { create(:clusters_applications_ingress, cluster: cluster) } + let!(:cert_manager) { create(:clusters_applications_cert_manager, cluster: cluster) } + let!(:crossplane) { create(:clusters_applications_crossplane, 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) } + let!(:elastic_stack) { create(:clusters_applications_elastic_stack, cluster: cluster) } + let!(:fluentd) { create(:clusters_applications_fluentd, cluster: cluster) } + + it 'returns the persisted application', :aggregate_failures do + { + Clusters::Applications::Helm => helm, + Clusters::Applications::Ingress => ingress, + Clusters::Applications::CertManager => cert_manager, + Clusters::Applications::Crossplane => crossplane, + Clusters::Applications::Prometheus => prometheus, + Clusters::Applications::Runner => runner, + Clusters::Applications::Jupyter => jupyter, + Clusters::Applications::Knative => knative, + Clusters::Applications::ElasticStack => elastic_stack, + Clusters::Applications::Fluentd => fluentd + }.each do |application_class, expected_object| + application = cluster.find_or_build_application(application_class) + + expect(application).to eq(expected_object) + expect(application).to be_persisted + end + end + end + end + describe '#allow_user_defined_namespace?' do subject { cluster.allow_user_defined_namespace? } @@ -889,9 +943,9 @@ describe Clusters::Cluster, :use_clean_rails_memory_store_caching do end describe '#make_cleanup_errored!' do - NON_ERRORED_STATES = Clusters::Cluster.state_machines[:cleanup_status].states.keys - [:cleanup_errored] + non_errored_states = Clusters::Cluster.state_machines[:cleanup_status].states.keys - [:cleanup_errored] - NON_ERRORED_STATES.each do |state| + non_errored_states.each do |state| it "transitions cleanup_status from #{state} to cleanup_errored" do cluster = create(:cluster, state) @@ -948,6 +1002,22 @@ describe Clusters::Cluster, :use_clean_rails_memory_store_caching do end end + describe '#nodes' do + let(:cluster) { create(:cluster) } + + subject { cluster.nodes } + + it { is_expected.to be_nil } + + context 'with a cached status' do + before do + stub_reactive_cache(cluster, nodes: [kube_node]) + end + + it { is_expected.to eq([kube_node]) } + end + end + describe '#calculate_reactive_cache' do subject { cluster.calculate_reactive_cache } @@ -956,6 +1026,7 @@ describe Clusters::Cluster, :use_clean_rails_memory_store_caching do it 'does not populate the cache' do expect(cluster).not_to receive(:retrieve_connection_status) + expect(cluster).not_to receive(:retrieve_nodes) is_expected.to be_nil end @@ -964,12 +1035,12 @@ describe Clusters::Cluster, :use_clean_rails_memory_store_caching do context 'cluster is enabled' do let(:cluster) { create(:cluster, :provided_by_user, :group) } - context 'connection to the cluster is successful' do - before do - stub_kubeclient_discover(cluster.platform.api_url) - end + before do + stub_kubeclient_nodes_and_nodes_metrics(cluster.platform.api_url) + end - it { is_expected.to eq(connection_status: :connected) } + context 'connection to the cluster is successful' do + it { is_expected.to eq(connection_status: :connected, nodes: [kube_node.merge(kube_node_metrics)]) } end context 'cluster cannot be reached' do @@ -978,7 +1049,7 @@ describe Clusters::Cluster, :use_clean_rails_memory_store_caching do .and_raise(SocketError) end - it { is_expected.to eq(connection_status: :unreachable) } + it { is_expected.to eq(connection_status: :unreachable, nodes: []) } end context 'cluster cannot be authenticated to' do @@ -987,7 +1058,7 @@ describe Clusters::Cluster, :use_clean_rails_memory_store_caching do .and_raise(OpenSSL::X509::CertificateError.new("Certificate error")) end - it { is_expected.to eq(connection_status: :authentication_failure) } + it { is_expected.to eq(connection_status: :authentication_failure, nodes: []) } end describe 'Kubeclient::HttpError' do @@ -999,18 +1070,18 @@ describe Clusters::Cluster, :use_clean_rails_memory_store_caching do .and_raise(Kubeclient::HttpError.new(error_code, error_message, nil)) end - it { is_expected.to eq(connection_status: :authentication_failure) } + it { is_expected.to eq(connection_status: :authentication_failure, nodes: []) } context 'generic timeout' do let(:error_message) { 'Timed out connecting to server'} - it { is_expected.to eq(connection_status: :unreachable) } + it { is_expected.to eq(connection_status: :unreachable, nodes: []) } end context 'gateway timeout' do let(:error_message) { '504 Gateway Timeout for GET https://kubernetes.example.com/api/v1'} - it { is_expected.to eq(connection_status: :unreachable) } + it { is_expected.to eq(connection_status: :unreachable, nodes: []) } end end @@ -1020,11 +1091,12 @@ describe Clusters::Cluster, :use_clean_rails_memory_store_caching do .and_raise(StandardError) end - it { is_expected.to eq(connection_status: :unknown_failure) } + it { is_expected.to eq(connection_status: :unknown_failure, nodes: []) } it 'notifies Sentry' do expect(Gitlab::ErrorTracking).to receive(:track_exception) .with(instance_of(StandardError), hash_including(cluster_id: cluster.id)) + .twice subject end diff --git a/spec/models/commit_status_spec.rb b/spec/models/commit_status_spec.rb index 73b81b2225a..05d3329215a 100644 --- a/spec/models/commit_status_spec.rb +++ b/spec/models/commit_status_spec.rb @@ -751,4 +751,48 @@ describe CommitStatus do it { is_expected.to be_a(CommitStatusPresenter) } end + + describe '#recoverable?' do + using RSpec::Parameterized::TableSyntax + + let(:commit_status) { create(:commit_status, :pending) } + + subject(:recoverable?) { commit_status.recoverable? } + + context 'when commit status is failed' do + before do + commit_status.drop! + end + + where(:failure_reason, :recoverable) do + :script_failure | false + :missing_dependency_failure | false + :archived_failure | false + :scheduler_failure | false + :data_integrity_failure | false + :unknown_failure | true + :api_failure | true + :stuck_or_timeout_failure | true + :runner_system_failure | true + end + + with_them do + context "when failure reason is #{params[:failure_reason]}" do + before do + commit_status.update_attribute(:failure_reason, failure_reason) + end + + it { is_expected.to eq(recoverable) } + end + end + end + + context 'when commit status is not failed' do + before do + commit_status.success! + end + + it { is_expected.to eq(false) } + end + end end diff --git a/spec/models/concerns/awardable_spec.rb b/spec/models/concerns/awardable_spec.rb index 76da42cf243..29f911fcb04 100644 --- a/spec/models/concerns/awardable_spec.rb +++ b/spec/models/concerns/awardable_spec.rb @@ -91,4 +91,45 @@ describe Awardable do expect(issue.award_emoji).to eq issue.award_emoji.sort_by(&:id) end end + + describe "#grouped_awards" do + context 'default award emojis' do + let(:issue_without_downvote) { create(:issue) } + let(:issue_with_downvote) do + issue_with_downvote = create(:issue) + create(:award_emoji, :downvote, awardable: issue_with_downvote) + issue_with_downvote + end + + it "includes unused thumbs buttons by default" do + expect(issue_without_downvote.grouped_awards.keys.sort).to eq %w(thumbsdown thumbsup) + end + + it "doesn't include unused thumbs buttons when disabled in project" do + issue_without_downvote.project.show_default_award_emojis = false + + expect(issue_without_downvote.grouped_awards.keys.sort).to eq [] + end + + it "includes unused thumbs buttons when enabled in project" do + issue_without_downvote.project.show_default_award_emojis = true + + expect(issue_without_downvote.grouped_awards.keys.sort).to eq %w(thumbsdown thumbsup) + end + + it "doesn't include unused thumbs buttons in summary" do + expect(issue_without_downvote.grouped_awards(with_thumbs: false).keys).to eq [] + end + + it "includes used thumbs buttons when disabled in project" do + issue_with_downvote.project.show_default_award_emojis = false + + expect(issue_with_downvote.grouped_awards.keys).to eq %w(thumbsdown) + end + + it "includes used thumbs buttons in summary" do + expect(issue_with_downvote.grouped_awards(with_thumbs: false).keys).to eq %w(thumbsdown) + end + end + end end diff --git a/spec/models/concerns/blocks_json_serialization_spec.rb b/spec/models/concerns/blocks_json_serialization_spec.rb index 0ef5be3cb61..32870461019 100644 --- a/spec/models/concerns/blocks_json_serialization_spec.rb +++ b/spec/models/concerns/blocks_json_serialization_spec.rb @@ -3,8 +3,11 @@ require 'spec_helper' describe BlocksJsonSerialization do - DummyModel = Class.new do - include BlocksJsonSerialization + before do + stub_const('DummyModel', Class.new) + DummyModel.class_eval do + include BlocksJsonSerialization + end end it 'blocks as_json' do diff --git a/spec/models/concerns/cache_markdown_field_spec.rb b/spec/models/concerns/cache_markdown_field_spec.rb index 697a9e98505..193144fcb0e 100644 --- a/spec/models/concerns/cache_markdown_field_spec.rb +++ b/spec/models/concerns/cache_markdown_field_spec.rb @@ -223,6 +223,10 @@ describe CacheMarkdownField, :clean_gitlab_redis_cache do end context 'when the markdown cache is up to date' do + before do + thing.try(:save) + end + it 'does not call #refresh_markdown_cache' do expect(thing).not_to receive(:refresh_markdown_cache) @@ -256,6 +260,54 @@ describe CacheMarkdownField, :clean_gitlab_redis_cache do let(:klass) { ar_class } it_behaves_like 'a class with cached markdown fields' + + describe '#attribute_invalidated?' do + let(:thing) { klass.create(description: markdown, description_html: html, cached_markdown_version: cache_version) } + + it 'returns true when cached_markdown_version is different' do + thing.cached_markdown_version += 1 + + expect(thing.attribute_invalidated?(:description_html)).to eq(true) + end + + it 'returns true when markdown is changed' do + thing.description = updated_markdown + + expect(thing.attribute_invalidated?(:description_html)).to eq(true) + end + + it 'returns true when both markdown and HTML are changed' do + thing.description = updated_markdown + thing.description_html = updated_html + + expect(thing.attribute_invalidated?(:description_html)).to eq(true) + end + + it 'returns false when there are no changes' do + expect(thing.attribute_invalidated?(:description_html)).to eq(false) + end + end + + context 'when cache version is updated' do + let(:old_version) { cache_version - 1 } + let(:old_html) { '<p data-sourcepos="1:1-1:5" dir="auto" class="some-old-class"><code>Foo</code></p>' } + + let(:thing) do + # This forces the record to have outdated HTML. We can't use `create` because the `before_create` hook + # would re-render the HTML to the latest version + klass.create.tap do |thing| + thing.update_columns(description: markdown, description_html: old_html, cached_markdown_version: old_version) + end + end + + it 'correctly updates cached HTML even if refresh_markdown_cache is called before updating the attribute' do + thing.refresh_markdown_cache + + thing.update(description: updated_markdown) + + expect(thing.description_html).to eq(updated_html) + end + end end context 'for other classes' do diff --git a/spec/models/concerns/cacheable_attributes_spec.rb b/spec/models/concerns/cacheable_attributes_spec.rb index d8f940a808e..56e0d044247 100644 --- a/spec/models/concerns/cacheable_attributes_spec.rb +++ b/spec/models/concerns/cacheable_attributes_spec.rb @@ -205,11 +205,11 @@ describe CacheableAttributes do end end - it 'uses RequestStore in addition to Thread memory cache', :request_store do + it 'uses RequestStore in addition to process memory cache', :request_store do # Warm up the cache create(:application_setting).cache! - expect(ApplicationSetting.cache_backend).to eq(Gitlab::ThreadMemoryCache.cache_backend) + expect(ApplicationSetting.cache_backend).to eq(Gitlab::ProcessMemoryCache.cache_backend) expect(ApplicationSetting.cache_backend).to receive(:read).with(ApplicationSetting.cache_key).once.and_call_original 2.times { ApplicationSetting.current } diff --git a/spec/models/concerns/has_user_type_spec.rb b/spec/models/concerns/has_user_type_spec.rb new file mode 100644 index 00000000000..f12eee414f9 --- /dev/null +++ b/spec/models/concerns/has_user_type_spec.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe User 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 visual_review_bot migration_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| + let_it_be(type) { create(:user, username: type, user_type: type) } + end + let(:bots) { User::BOT_USER_TYPES.map { |type| public_send(type) } } + let(:non_internal) { User::NON_INTERNAL_USER_TYPES.map { |type| public_send(type) } } + let(:everyone) { User::USER_TYPES.keys.map { |type| public_send(type) } } + + describe '.humans' do + it 'includes humans only' do + expect(described_class.humans).to match_array([human]) + end + end + + describe '.bots' do + it 'includes all bots' do + expect(described_class.bots).to match_array(bots) + end + end + + describe '.bots_without_project_bot' do + it 'includes all bots except project_bot' do + expect(described_class.bots_without_project_bot).to match_array(bots - [project_bot]) + end + end + + describe '.non_internal' do + it 'includes all non_internal users' do + expect(described_class.non_internal).to match_array(non_internal) + end + end + + describe '.without_ghosts' do + it 'includes everyone except ghosts' do + expect(described_class.without_ghosts).to match_array(everyone - [ghost]) + end + end + + describe '.without_project_bot' do + it 'includes everyone except project_bot' do + expect(described_class.without_project_bot).to match_array(everyone - [project_bot]) + end + end + + describe '#bot?' do + it 'is true for all bot user types and false for others' do + expect(bots).to all(be_bot) + + (everyone - bots).each do |user| + expect(user).not_to be_bot + end + end + end + + describe '#human?' do + it 'is true for humans only' do + expect(human).to be_human + expect(alert_bot).not_to be_human + expect(User.new).to be_human + end + end + + describe '#internal?' do + it 'is true for all internal user types and false for others' do + expect(everyone - non_internal).to all(be_internal) + + non_internal.each do |user| + expect(user).not_to be_internal + end + end + end + end +end diff --git a/spec/models/concerns/mentionable_spec.rb b/spec/models/concerns/mentionable_spec.rb index 13a3d1cdd82..03fd1c69654 100644 --- a/spec/models/concerns/mentionable_spec.rb +++ b/spec/models/concerns/mentionable_spec.rb @@ -3,14 +3,17 @@ require 'spec_helper' describe Mentionable do - class Example - include Mentionable + before do + stub_const('Example', Class.new) + Example.class_eval do + include Mentionable - attr_accessor :project, :message - attr_mentionable :message + attr_accessor :project, :message + attr_mentionable :message - def author - nil + def author + nil + end end end @@ -28,11 +31,11 @@ describe Mentionable do end describe '#any_mentionable_attributes_changed?' do - Message = Struct.new(:text) + message = Struct.new(:text) let(:mentionable) { Example.new } let(:changes) do - msg = Message.new('test') + msg = message.new('test') changes = {} changes[msg] = ['', 'some message'] @@ -325,3 +328,36 @@ describe Snippet, 'Mentionable' do end end end + +describe PersonalSnippet, 'Mentionable' do + describe '#store_mentions!' do + it_behaves_like 'mentions in description', :personal_snippet + it_behaves_like 'mentions in notes', :personal_snippet do + let(:note) { create(:note_on_personal_snippet) } + let(:mentionable) { note.noteable } + end + end + + describe 'load mentions' do + it_behaves_like 'load mentions from DB', :personal_snippet do + let(:note) { create(:note_on_personal_snippet) } + let(:mentionable) { note.noteable } + end + end +end + +describe DesignManagement::Design do + describe '#store_mentions!' do + it_behaves_like 'mentions in notes', :design do + let(:note) { create(:diff_note_on_design) } + let(:mentionable) { note.noteable } + end + end + + describe 'load mentions' do + it_behaves_like 'load mentions from DB', :design do + let(:note) { create(:diff_note_on_design) } + let(:mentionable) { note.noteable } + end + end +end diff --git a/spec/models/concerns/noteable_spec.rb b/spec/models/concerns/noteable_spec.rb index 097bc24d90f..5c8c5425ca7 100644 --- a/spec/models/concerns/noteable_spec.rb +++ b/spec/models/concerns/noteable_spec.rb @@ -241,7 +241,7 @@ describe Noteable do describe '.resolvable_types' do it 'exposes the replyable types' do - expect(described_class.resolvable_types).to include('MergeRequest') + expect(described_class.resolvable_types).to include('MergeRequest', 'DesignManagement::Design') end end diff --git a/spec/models/concerns/reactive_caching_spec.rb b/spec/models/concerns/reactive_caching_spec.rb index 96a9c317fb8..cfca383e0b0 100644 --- a/spec/models/concerns/reactive_caching_spec.rb +++ b/spec/models/concerns/reactive_caching_spec.rb @@ -6,39 +6,47 @@ describe ReactiveCaching, :use_clean_rails_memory_store_caching do include ExclusiveLeaseHelpers include ReactiveCachingHelpers - class CacheTest - include ReactiveCaching + let(:cache_class_test) do + Class.new do + include ReactiveCaching - self.reactive_cache_key = ->(thing) { ["foo", thing.id] } + self.reactive_cache_key = ->(thing) { ["foo", thing.id] } - self.reactive_cache_lifetime = 5.minutes - self.reactive_cache_refresh_interval = 15.seconds + self.reactive_cache_lifetime = 5.minutes + self.reactive_cache_refresh_interval = 15.seconds - attr_reader :id + attr_reader :id - def self.primary_key - :id - end + def self.primary_key + :id + end - def initialize(id, &blk) - @id = id - @calculator = blk - end + def initialize(id, &blk) + @id = id + @calculator = blk + end - def calculate_reactive_cache - @calculator.call - end + def calculate_reactive_cache + @calculator.call + end - def result - with_reactive_cache do |data| - data + def result + with_reactive_cache do |data| + data + end end end end + let(:external_dependency_cache_class_test) do + Class.new(cache_class_test) do + self.reactive_cache_work_type = :external_dependency + end + end + let(:calculation) { -> { 2 + 2 } } let(:cache_key) { "foo:666" } - let(:instance) { CacheTest.new(666, &calculation) } + let(:instance) { cache_class_test.new(666, &calculation) } describe '#with_reactive_cache' do before do @@ -47,6 +55,18 @@ describe ReactiveCaching, :use_clean_rails_memory_store_caching do subject(:go!) { instance.result } + shared_examples 'reactive worker call' do |worker_class| + let(:instance) do + test_class.new(666, &calculation) + end + + it 'performs caching with correct worker' do + expect(worker_class).to receive(:perform_async).with(test_class, 666) + + go! + end + end + shared_examples 'a cacheable value' do |cached_value| before do stub_reactive_cache(instance, cached_value) @@ -73,10 +93,12 @@ describe ReactiveCaching, :use_clean_rails_memory_store_caching do it { is_expected.to be_nil } - it 'refreshes cache' do - expect(ReactiveCachingWorker).to receive(:perform_async).with(CacheTest, 666) + it_behaves_like 'reactive worker call', ReactiveCachingWorker do + let(:test_class) { cache_class_test } + end - instance.with_reactive_cache { raise described_class::InvalidateReactiveCache } + it_behaves_like 'reactive worker call', ExternalServiceReactiveCachingWorker do + let(:test_class) { external_dependency_cache_class_test } end end end @@ -84,10 +106,12 @@ describe ReactiveCaching, :use_clean_rails_memory_store_caching do context 'when cache is empty' do it { is_expected.to be_nil } - it 'enqueues a background worker to bootstrap the cache' do - expect(ReactiveCachingWorker).to receive(:perform_async).with(CacheTest, 666) + it_behaves_like 'reactive worker call', ReactiveCachingWorker do + let(:test_class) { cache_class_test } + end - go! + it_behaves_like 'reactive worker call', ExternalServiceReactiveCachingWorker do + let(:test_class) { external_dependency_cache_class_test } end it 'updates the cache lifespan' do @@ -168,12 +192,14 @@ describe ReactiveCaching, :use_clean_rails_memory_store_caching do context 'with custom reactive_cache_worker_finder' do let(:args) { %w(arg1 arg2) } - let(:instance) { CustomFinderCacheTest.new(666, &calculation) } + let(:instance) { custom_finder_cache_test.new(666, &calculation) } - class CustomFinderCacheTest < CacheTest - self.reactive_cache_worker_finder = ->(_id, *args) { from_cache(*args) } + let(:custom_finder_cache_test) do + Class.new(cache_class_test) do + self.reactive_cache_worker_finder = ->(_id, *args) { from_cache(*args) } - def self.from_cache(*args); end + def self.from_cache(*args); end + end end before do @@ -234,6 +260,18 @@ describe ReactiveCaching, :use_clean_rails_memory_store_caching do go! end + context 'when :external_dependency cache' do + let(:instance) do + external_dependency_cache_class_test.new(666, &calculation) + end + + it 'enqueues a repeat worker' do + expect_reactive_cache_update_queued(instance, worker_klass: ExternalServiceReactiveCachingWorker) + + go! + end + end + it 'calls a reactive_cache_updated only once if content did not change on subsequent update' do expect(instance).to receive(:calculate_reactive_cache).twice expect(instance).to receive(:reactive_cache_updated).once @@ -262,7 +300,7 @@ describe ReactiveCaching, :use_clean_rails_memory_store_caching do it_behaves_like 'ExceededReactiveCacheLimit' context 'when reactive_cache_hard_limit is overridden' do - let(:test_class) { Class.new(CacheTest) { self.reactive_cache_hard_limit = 3.megabytes } } + let(:test_class) { Class.new(cache_class_test) { self.reactive_cache_hard_limit = 3.megabytes } } let(:instance) { test_class.new(666, &calculation) } it_behaves_like 'successful cache' diff --git a/spec/models/concerns/redis_cacheable_spec.rb b/spec/models/concerns/redis_cacheable_spec.rb index f88d64e2013..1cf6afcc167 100644 --- a/spec/models/concerns/redis_cacheable_spec.rb +++ b/spec/models/concerns/redis_cacheable_spec.rb @@ -31,7 +31,7 @@ describe RedisCacheable do subject { instance.cached_attribute(payload.each_key.first) } it 'gets the cache attribute' do - Gitlab::Redis::SharedState.with do |redis| + Gitlab::Redis::Cache.with do |redis| expect(redis).to receive(:get).with(cache_key) .and_return(payload.to_json) end @@ -44,7 +44,7 @@ describe RedisCacheable do subject { instance.cache_attributes(payload) } it 'sets the cache attributes' do - Gitlab::Redis::SharedState.with do |redis| + Gitlab::Redis::Cache.with do |redis| expect(redis).to receive(:set).with(cache_key, payload.to_json, anything) end @@ -52,7 +52,7 @@ describe RedisCacheable do end end - describe '#cached_attr_reader', :clean_gitlab_redis_shared_state do + describe '#cached_attr_reader', :clean_gitlab_redis_cache do subject { instance.name } before do diff --git a/spec/models/concerns/spammable_spec.rb b/spec/models/concerns/spammable_spec.rb index b8537dd39f6..a8d27e174b7 100644 --- a/spec/models/concerns/spammable_spec.rb +++ b/spec/models/concerns/spammable_spec.rb @@ -39,43 +39,100 @@ describe Spammable do describe '#invalidate_if_spam' do using RSpec::Parameterized::TableSyntax + before do + stub_application_setting(recaptcha_enabled: true) + end + context 'when the model is spam' do - where(:recaptcha_enabled, :error) do - true | /solve the reCAPTCHA to proceed/ - false | /has been discarded/ + subject { invalidate_if_spam(is_spam: true) } + + it 'has an error related to spam on the model' do + expect(subject.errors.messages[:base]).to match_array /has been discarded/ end + end - with_them do - subject { invalidate_if_spam(true, recaptcha_enabled) } + context 'when the model needs recaptcha' do + subject { invalidate_if_spam(needs_recaptcha: true) } - it 'has an error related to spam on the model' do - expect(subject.errors.messages[:base]).to match_array error - end + it 'has an error related to spam on the model' do + expect(subject.errors.messages[:base]).to match_array /solve the reCAPTCHA/ end end - context 'when the model is not spam' do - [true, false].each do |enabled| - let(:recaptcha_enabled) { enabled } + context 'if the model is spam and also needs recaptcha' do + subject { invalidate_if_spam(is_spam: true, needs_recaptcha: true) } + + it 'has an error related to spam on the model' do + expect(subject.errors.messages[:base]).to match_array /solve the reCAPTCHA/ + end + end - subject { invalidate_if_spam(false, recaptcha_enabled) } + context 'when the model is not spam nor needs recaptcha' do + subject { invalidate_if_spam } - it 'returns no error' do - expect(subject.errors.messages[:base]).to be_empty - end + it 'returns no error' do + expect(subject.errors.messages[:base]).to be_empty end end - def invalidate_if_spam(is_spam, recaptcha_enabled) - stub_application_setting(recaptcha_enabled: recaptcha_enabled) + context 'if recaptcha is not enabled and the model needs recaptcha' do + before do + stub_application_setting(recaptcha_enabled: false) + end + + subject { invalidate_if_spam(needs_recaptcha: true) } + it 'has no errors' do + expect(subject.errors.messages[:base]).to match_array /has been discarded/ + end + end + + def invalidate_if_spam(is_spam: false, needs_recaptcha: false) issue.tap do |i| i.spam = is_spam + i.needs_recaptcha = needs_recaptcha i.invalidate_if_spam end end end + describe 'spam flags' do + before do + issue.spam = false + issue.needs_recaptcha = false + end + + describe '#spam!' do + it 'adds only `spam` flag' do + issue.spam! + + expect(issue.spam).to be_truthy + expect(issue.needs_recaptcha).to be_falsey + end + end + + describe '#needs_recaptcha!' do + it 'adds `needs_recaptcha` flag' do + issue.needs_recaptcha! + + expect(issue.spam).to be_falsey + expect(issue.needs_recaptcha).to be_truthy + end + end + + describe '#clear_spam_flags!' do + it 'clears spam and recaptcha flags' do + issue.spam = true + issue.needs_recaptcha = true + + issue.clear_spam_flags! + + expect(issue).not_to be_spam + expect(issue.needs_recaptcha).to be_falsey + end + end + end + describe '#submittable_as_spam_by?' do let(:admin) { build(:admin) } let(:user) { build(:user) } diff --git a/spec/models/container_repository_spec.rb b/spec/models/container_repository_spec.rb index 5bcd9dfd396..1eecefe5d4a 100644 --- a/spec/models/container_repository_spec.rb +++ b/spec/models/container_repository_spec.rb @@ -19,7 +19,7 @@ describe ContainerRepository do .with(headers: { 'Accept' => ContainerRegistry::Client::ACCEPTED_TYPES.join(', ') }) .to_return( status: 200, - body: JSON.dump(tags: ['test_tag']), + body: Gitlab::Json.dump(tags: ['test_tag']), headers: { 'Content-Type' => 'application/json' }) end @@ -309,4 +309,14 @@ describe ContainerRepository do it { is_expected.to eq([]) } end end + + describe '.search_by_name' do + let!(:another_repository) do + create(:container_repository, name: 'my_foo_bar', project: project) + end + + subject { described_class.search_by_name('my_image') } + + it { is_expected.to contain_exactly(repository) } + end end diff --git a/spec/models/cycle_analytics/code_spec.rb b/spec/models/cycle_analytics/code_spec.rb index 441f8265629..f6ab8e0ece6 100644 --- a/spec/models/cycle_analytics/code_spec.rb +++ b/spec/models/cycle_analytics/code_spec.rb @@ -7,7 +7,7 @@ describe 'CycleAnalytics#code' do let_it_be(:project) { create(:project, :repository) } let_it_be(:from_date) { 10.days.ago } - let_it_be(:user) { create(:user, :admin) } + let_it_be(:user) { project.owner } let_it_be(:project_level) { CycleAnalytics::ProjectLevel.new(project, options: { from: from_date }) } subject { project_level } diff --git a/spec/models/cycle_analytics/group_level_spec.rb b/spec/models/cycle_analytics/group_level_spec.rb deleted file mode 100644 index ac169ebc0cf..00000000000 --- a/spec/models/cycle_analytics/group_level_spec.rb +++ /dev/null @@ -1,44 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -describe CycleAnalytics::GroupLevel do - let_it_be(:group) { create(:group)} - let_it_be(:project) { create(:project, :repository, namespace: group) } - let_it_be(:from_date) { 10.days.ago } - let_it_be(:user) { create(:user, :admin) } - let(:issue) { create(:issue, project: project, created_at: 2.days.ago) } - let_it_be(:milestone) { create(:milestone, project: project) } - let(:mr) { create_merge_request_closing_issue(user, project, issue, commit_message: "References #{issue.to_reference}") } - let(:pipeline) { create(:ci_empty_pipeline, status: 'created', project: project, ref: mr.source_branch, sha: mr.source_branch_sha, head_pipeline_of: mr) } - - subject { described_class.new(group: group, options: { from: from_date, current_user: user }) } - - describe '#permissions' do - it 'returns true for all stages' do - expect(subject.permissions.values.uniq).to eq([true]) - end - end - - describe '#stats' do - before do - create_cycle(user, project, issue, mr, milestone, pipeline) - deploy_master(user, project) - end - - it 'returns medians for each stage for a specific group' do - expect(subject.no_stats?).to eq(false) - end - end - - describe '#summary' do - before do - create_cycle(user, project, issue, mr, milestone, pipeline) - deploy_master(user, project) - end - - it 'returns medians for each stage for a specific group' do - expect(subject.summary.map { |summary| summary[:value] }).to contain_exactly('0.1', '1', '1') - end - end -end diff --git a/spec/models/cycle_analytics/issue_spec.rb b/spec/models/cycle_analytics/issue_spec.rb index 726f2f8b018..b4ab763e0e6 100644 --- a/spec/models/cycle_analytics/issue_spec.rb +++ b/spec/models/cycle_analytics/issue_spec.rb @@ -7,7 +7,7 @@ describe 'CycleAnalytics#issue' do let_it_be(:project) { create(:project, :repository) } let_it_be(:from_date) { 10.days.ago } - let_it_be(:user) { create(:user, :admin) } + let_it_be(:user) { project.owner } let_it_be(:project_level) { CycleAnalytics::ProjectLevel.new(project, options: { from: from_date }) } subject { project_level } diff --git a/spec/models/cycle_analytics/plan_spec.rb b/spec/models/cycle_analytics/plan_spec.rb index 3bd9f317ca7..6765b2e2cbc 100644 --- a/spec/models/cycle_analytics/plan_spec.rb +++ b/spec/models/cycle_analytics/plan_spec.rb @@ -7,7 +7,7 @@ describe 'CycleAnalytics#plan' do let_it_be(:project) { create(:project, :repository) } let_it_be(:from_date) { 10.days.ago } - let_it_be(:user) { create(:user, :admin) } + let_it_be(:user) { project.owner } let_it_be(:project_level) { CycleAnalytics::ProjectLevel.new(project, options: { from: from_date }) } subject { project_level } diff --git a/spec/models/cycle_analytics/production_spec.rb b/spec/models/cycle_analytics/production_spec.rb index 01d88bbeec9..2f2bcd63acd 100644 --- a/spec/models/cycle_analytics/production_spec.rb +++ b/spec/models/cycle_analytics/production_spec.rb @@ -7,7 +7,7 @@ describe 'CycleAnalytics#production' do let_it_be(:project) { create(:project, :repository) } let_it_be(:from_date) { 10.days.ago } - let_it_be(:user) { create(:user, :admin) } + let_it_be(:user) { project.owner } let_it_be(:project_level) { CycleAnalytics::ProjectLevel.new(project, options: { from: from_date }) } subject { project_level } diff --git a/spec/models/cycle_analytics/project_level_spec.rb b/spec/models/cycle_analytics/project_level_spec.rb index 2fc81777746..bb296351a29 100644 --- a/spec/models/cycle_analytics/project_level_spec.rb +++ b/spec/models/cycle_analytics/project_level_spec.rb @@ -5,7 +5,7 @@ require 'spec_helper' describe CycleAnalytics::ProjectLevel do let_it_be(:project) { create(:project, :repository) } let_it_be(:from_date) { 10.days.ago } - let_it_be(:user) { create(:user, :admin) } + let_it_be(:user) { project.owner } let_it_be(:issue) { create(:issue, project: project, created_at: 2.days.ago) } let_it_be(:milestone) { create(:milestone, project: project) } let(:mr) { create_merge_request_closing_issue(user, project, issue, commit_message: "References #{issue.to_reference}") } diff --git a/spec/models/cycle_analytics/review_spec.rb b/spec/models/cycle_analytics/review_spec.rb index 50670188e85..25e8f1441d3 100644 --- a/spec/models/cycle_analytics/review_spec.rb +++ b/spec/models/cycle_analytics/review_spec.rb @@ -7,7 +7,7 @@ describe 'CycleAnalytics#review' do let_it_be(:project) { create(:project, :repository) } let_it_be(:from_date) { 10.days.ago } - let_it_be(:user) { create(:user, :admin) } + let_it_be(:user) { project.owner } subject { CycleAnalytics::ProjectLevel.new(project, options: { from: from_date }) } diff --git a/spec/models/cycle_analytics/staging_spec.rb b/spec/models/cycle_analytics/staging_spec.rb index cf0695f175a..effbc7056cc 100644 --- a/spec/models/cycle_analytics/staging_spec.rb +++ b/spec/models/cycle_analytics/staging_spec.rb @@ -7,7 +7,7 @@ describe 'CycleAnalytics#staging' do let_it_be(:project) { create(:project, :repository) } let_it_be(:from_date) { 10.days.ago } - let_it_be(:user) { create(:user, :admin) } + let_it_be(:user) { project.owner } let_it_be(:project_level) { CycleAnalytics::ProjectLevel.new(project, options: { from: from_date }) } subject { project_level } diff --git a/spec/models/cycle_analytics/test_spec.rb b/spec/models/cycle_analytics/test_spec.rb index 24800aafca7..7e7ba4d9994 100644 --- a/spec/models/cycle_analytics/test_spec.rb +++ b/spec/models/cycle_analytics/test_spec.rb @@ -7,7 +7,7 @@ describe 'CycleAnalytics#test' do let_it_be(:project) { create(:project, :repository) } let_it_be(:from_date) { 10.days.ago } - let_it_be(:user) { create(:user, :admin) } + let_it_be(:user) { project.owner } let_it_be(:issue) { create(:issue, project: project) } let_it_be(:project_level) { CycleAnalytics::ProjectLevel.new(project, options: { from: from_date }) } let!(:merge_request) { create_merge_request_closing_issue(user, project, issue) } diff --git a/spec/models/deploy_token_spec.rb b/spec/models/deploy_token_spec.rb index a2d4c046d46..819e2850644 100644 --- a/spec/models/deploy_token_spec.rb +++ b/spec/models/deploy_token_spec.rb @@ -72,8 +72,10 @@ describe DeployToken do describe '#scopes' do context 'with all the scopes' do + let_it_be(:deploy_token) { create(:deploy_token, :all_scopes) } + it 'returns scopes assigned to DeployToken' do - expect(deploy_token.scopes).to eq([:read_repository, :read_registry]) + expect(deploy_token.scopes).to eq(DeployToken::AVAILABLE_SCOPES) end end diff --git a/spec/models/design_management/action_spec.rb b/spec/models/design_management/action_spec.rb new file mode 100644 index 00000000000..753c31b1549 --- /dev/null +++ b/spec/models/design_management/action_spec.rb @@ -0,0 +1,105 @@ +# frozen_string_literal: true +require 'spec_helper' + +describe DesignManagement::Action do + describe 'relations' do + it { is_expected.to belong_to(:design) } + it { is_expected.to belong_to(:version) } + end + + describe 'scopes' do + describe '.most_recent' do + let_it_be(:design_a) { create(:design) } + let_it_be(:design_b) { create(:design) } + let_it_be(:design_c) { create(:design) } + + let(:designs) { [design_a, design_b, design_c] } + + before_all do + create(:design_version, designs: [design_a, design_b, design_c]) + create(:design_version, designs: [design_a, design_b]) + create(:design_version, designs: [design_a]) + end + + it 'finds the correct version for each design' do + dvs = described_class.where(design: designs) + + expected = designs + .map(&:id) + .zip(dvs.order("version_id DESC").pluck(:version_id).uniq) + + actual = dvs.most_recent.map { |dv| [dv.design_id, dv.version_id] } + + expect(actual).to eq(expected) + end + end + + describe '.up_to_version' do + let_it_be(:issue) { create(:issue) } + let_it_be(:design_a) { create(:design, issue: issue) } + let_it_be(:design_b) { create(:design, issue: issue) } + + # let bindings are not available in before(:all) contexts, + # so we need to redefine the array on each construction. + let_it_be(:oldest) { create(:design_version, designs: [design_a, design_b]) } + let_it_be(:middle) { create(:design_version, designs: [design_a, design_b]) } + let_it_be(:newest) { create(:design_version, designs: [design_a, design_b]) } + + subject { described_class.where(design: issue.designs).up_to_version(version) } + + context 'the version is nil' do + let(:version) { nil } + + it 'returns all design_versions' do + is_expected.to have_attributes(size: 6) + end + end + + context 'when given a Version instance' do + context 'the version is the most current' do + let(:version) { newest } + + it { is_expected.to have_attributes(size: 6) } + end + + context 'the version is the oldest' do + let(:version) { oldest } + + it { is_expected.to have_attributes(size: 2) } + end + + context 'the version is the middle one' do + let(:version) { middle } + + it { is_expected.to have_attributes(size: 4) } + end + end + + context 'when given a commit SHA' do + context 'the version is the most current' do + let(:version) { newest.sha } + + it { is_expected.to have_attributes(size: 6) } + end + + context 'the version is the oldest' do + let(:version) { oldest.sha } + + it { is_expected.to have_attributes(size: 2) } + end + + context 'the version is the middle one' do + let(:version) { middle.sha } + + it { is_expected.to have_attributes(size: 4) } + end + end + + context 'when given a String that is not a commit SHA' do + let(:version) { 'foo' } + + it { expect { subject }.to raise_error(ArgumentError) } + end + end + end +end diff --git a/spec/models/design_management/design_action_spec.rb b/spec/models/design_management/design_action_spec.rb new file mode 100644 index 00000000000..da4ad41dfcb --- /dev/null +++ b/spec/models/design_management/design_action_spec.rb @@ -0,0 +1,98 @@ +# frozen_string_literal: true +require 'spec_helper' + +describe DesignManagement::DesignAction do + describe 'validations' do + describe 'the design' do + let(:fail_validation) { raise_error(/design/i) } + + it 'must not be nil' do + expect { described_class.new(nil, :create, :foo) }.to fail_validation + end + end + + describe 'the action' do + let(:fail_validation) { raise_error(/action/i) } + + it 'must not be nil' do + expect { described_class.new(double, nil, :foo) }.to fail_validation + end + + it 'must be a known action' do + expect { described_class.new(double, :wibble, :foo) }.to fail_validation + end + end + + describe 'the content' do + context 'content is necesary' do + let(:fail_validation) { raise_error(/needs content/i) } + + %i[create update].each do |action| + it "must not be nil if the action is #{action}" do + expect { described_class.new(double, action, nil) }.to fail_validation + end + end + end + + context 'content is forbidden' do + let(:fail_validation) { raise_error(/forbids content/i) } + + it "must not be nil if the action is delete" do + expect { described_class.new(double, :delete, :foo) }.to fail_validation + end + end + end + end + + describe '#gitaly_action' do + let(:path) { 'some/path/somewhere' } + let(:design) { OpenStruct.new(full_path: path) } + + subject { described_class.new(design, action, content) } + + context 'the action needs content' do + let(:action) { :create } + let(:content) { :foo } + + it 'produces a good gitaly action' do + expect(subject.gitaly_action).to eq( + action: action, + file_path: path, + content: content + ) + end + end + + context 'the action forbids content' do + let(:action) { :delete } + let(:content) { nil } + + it 'produces a good gitaly action' do + expect(subject.gitaly_action).to eq(action: action, file_path: path) + end + end + end + + describe '#issue_id' do + let(:issue_id) { :foo } + let(:design) { OpenStruct.new(issue_id: issue_id) } + + subject { described_class.new(design, :delete) } + + it 'delegates to the design' do + expect(subject.issue_id).to eq(issue_id) + end + end + + describe '#performed' do + let(:design) { double } + + subject { described_class.new(design, :delete) } + + it 'calls design#clear_version_cache when the action has been performed' do + expect(design).to receive(:clear_version_cache) + + subject.performed + end + end +end diff --git a/spec/models/design_management/design_at_version_spec.rb b/spec/models/design_management/design_at_version_spec.rb new file mode 100644 index 00000000000..f6fa8df243c --- /dev/null +++ b/spec/models/design_management/design_at_version_spec.rb @@ -0,0 +1,426 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe DesignManagement::DesignAtVersion do + include DesignManagementTestHelpers + + let_it_be(:issue, reload: true) { create(:issue) } + let_it_be(:issue_b, reload: true) { create(:issue) } + let_it_be(:design, reload: true) { create(:design, issue: issue) } + let_it_be(:version) { create(:design_version, designs: [design]) } + + describe '#id' do + subject { described_class.new(design: design, version: version) } + + it 'combines design.id and version.id' do + expect(subject.id).to include(design.id.to_s, version.id.to_s) + end + end + + describe '#==' do + it 'identifies objects created with the same parameters as equal' do + design = build_stubbed(:design, issue: issue) + version = build_stubbed(:design_version, designs: [design], issue: issue) + + this = build_stubbed(:design_at_version, design: design, version: version) + other = build_stubbed(:design_at_version, design: design, version: version) + + expect(this).to eq(other) + expect(other).to eq(this) + end + + it 'identifies unequal objects as unequal, by virtue of their version' do + design = build_stubbed(:design, issue: issue) + version_a = build_stubbed(:design_version, designs: [design]) + version_b = build_stubbed(:design_version, designs: [design]) + + this = build_stubbed(:design_at_version, design: design, version: version_a) + other = build_stubbed(:design_at_version, design: design, version: version_b) + + expect(this).not_to eq(nil) + expect(this).not_to eq(design) + expect(this).not_to eq(other) + expect(other).not_to eq(this) + end + + it 'identifies unequal objects as unequal, by virtue of their design' do + design_a = build_stubbed(:design, issue: issue) + design_b = build_stubbed(:design, issue: issue) + version = build_stubbed(:design_version, designs: [design_a, design_b]) + + this = build_stubbed(:design_at_version, design: design_a, version: version) + other = build_stubbed(:design_at_version, design: design_b, version: version) + + expect(this).not_to eq(other) + expect(other).not_to eq(this) + end + + it 'rejects objects with the same id and the wrong class' do + dav = build_stubbed(:design_at_version) + + expect(dav).not_to eq(OpenStruct.new(id: dav.id)) + end + + it 'expects objects to be of the same type, not subtypes' do + subtype = Class.new(described_class) + dav = build_stubbed(:design_at_version) + other = subtype.new(design: dav.design, version: dav.version) + + expect(dav).not_to eq(other) + end + end + + describe 'status methods' do + let!(:design_a) { create(:design, issue: issue) } + let!(:design_b) { create(:design, issue: issue) } + + let!(:version_a) do + create(:design_version, designs: [design_a]) + end + let!(:version_b) do + create(:design_version, designs: [design_b]) + end + let!(:version_mod) do + create(:design_version, modified_designs: [design_a, design_b]) + end + let!(:version_c) do + create(:design_version, deleted_designs: [design_a]) + end + let!(:version_d) do + create(:design_version, deleted_designs: [design_b]) + end + let!(:version_e) do + create(:design_version, designs: [design_a]) + end + + describe 'a design before it has been created' do + subject { build(:design_at_version, design: design_b, version: version_a) } + + it 'is not deleted' do + expect(subject).not_to be_deleted + end + + it 'has the status :not_created_yet' do + expect(subject).to have_attributes(status: :not_created_yet) + end + end + + describe 'a design as of its creation' do + subject { build(:design_at_version, design: design_a, version: version_a) } + + it 'is not deleted' do + expect(subject).not_to be_deleted + end + + it 'has the status :current' do + expect(subject).to have_attributes(status: :current) + end + end + + describe 'a design after it has been created, but before deletion' do + subject { build(:design_at_version, design: design_b, version: version_c) } + + it 'is not deleted' do + expect(subject).not_to be_deleted + end + + it 'has the status :current' do + expect(subject).to have_attributes(status: :current) + end + end + + describe 'a design as of its modification' do + subject { build(:design_at_version, design: design_a, version: version_mod) } + + it 'is not deleted' do + expect(subject).not_to be_deleted + end + + it 'has the status :current' do + expect(subject).to have_attributes(status: :current) + end + end + + describe 'a design as of its deletion' do + subject { build(:design_at_version, design: design_a, version: version_c) } + + it 'is deleted' do + expect(subject).to be_deleted + end + + it 'has the status :deleted' do + expect(subject).to have_attributes(status: :deleted) + end + end + + describe 'a design after its deletion' do + subject { build(:design_at_version, design: design_b, version: version_e) } + + it 'is deleted' do + expect(subject).to be_deleted + end + + it 'has the status :deleted' do + expect(subject).to have_attributes(status: :deleted) + end + end + + describe 'a design on its recreation' do + subject { build(:design_at_version, design: design_a, version: version_e) } + + it 'is not deleted' do + expect(subject).not_to be_deleted + end + + it 'has the status :current' do + expect(subject).to have_attributes(status: :current) + end + end + end + + describe 'validations' do + subject(:design_at_version) { build(:design_at_version) } + + it { is_expected.to be_valid } + + describe 'a design-at-version without a design' do + subject { described_class.new(design: nil, version: build(:design_version)) } + + it { is_expected.to be_invalid } + + it 'mentions the design in the errors' do + subject.valid? + + expect(subject.errors[:design]).to be_present + end + end + + describe 'a design-at-version without a version' do + subject { described_class.new(design: build(:design), version: nil) } + + it { is_expected.to be_invalid } + + it 'mentions the version in the errors' do + subject.valid? + + expect(subject.errors[:version]).to be_present + end + end + + describe 'design_and_version_belong_to_the_same_issue' do + context 'both design and version are supplied' do + subject(:design_at_version) { build(:design_at_version, design: design, version: version) } + + context 'the design belongs to the same issue as the version' do + it { is_expected.to be_valid } + end + + context 'the design does not belong to the same issue as the version' do + let(:design) { create(:design) } + let(:version) { create(:design_version) } + + it { is_expected.to be_invalid } + end + end + + context 'the factory is just supplied with a design' do + let(:design) { create(:design) } + + subject(:design_at_version) { build(:design_at_version, design: design) } + + it { is_expected.to be_valid } + end + + context 'the factory is just supplied with a version' do + let(:version) { create(:design_version) } + + subject(:design_at_version) { build(:design_at_version, version: version) } + + it { is_expected.to be_valid } + end + end + + describe 'design_and_version_have_issue_id' do + subject(:design_at_version) { build(:design_at_version, design: design, version: version) } + + context 'the design has no issue_id, because it is being imported' do + let(:design) { create(:design, :importing) } + + it { is_expected.to be_invalid } + end + + context 'the version has no issue_id, because it is being imported' do + let(:version) { create(:design_version, :importing) } + + it { is_expected.to be_invalid } + end + + context 'both the design and the version are being imported' do + let(:version) { create(:design_version, :importing) } + let(:design) { create(:design, :importing) } + + it { is_expected.to be_invalid } + end + end + end + + def id_of(design, version) + build(:design_at_version, design: design, version: version).id + end + + describe '.instantiate' do + context 'when attrs are valid' do + subject do + described_class.instantiate(design: design, version: version) + end + + it { is_expected.to be_a(described_class).and(be_valid) } + end + + context 'when attrs are invalid' do + subject do + described_class.instantiate( + design: create(:design), + version: create(:design_version) + ) + end + + it 'raises a validation error' do + expect { subject }.to raise_error(ActiveModel::ValidationError) + end + end + end + + describe '.lazy_find' do + let!(:version_a) do + create(:design_version, designs: create_list(:design, 3, issue: issue)) + end + let!(:version_b) do + create(:design_version, designs: create_list(:design, 1, issue: issue)) + end + let!(:version_c) do + create(:design_version, designs: create_list(:design, 1, issue: issue_b)) + end + + let(:id_a) { id_of(version_a.designs.first, version_a) } + let(:id_b) { id_of(version_a.designs.second, version_a) } + let(:id_c) { id_of(version_a.designs.last, version_a) } + let(:id_d) { id_of(version_b.designs.first, version_b) } + let(:id_e) { id_of(version_c.designs.first, version_c) } + let(:bad_id) { id_of(version_c.designs.first, version_a) } + + def find(the_id) + described_class.lazy_find(the_id) + end + + let(:db_calls) { 2 } + + it 'issues fewer queries than the naive approach would' do + expect do + dav_a = find(id_a) + dav_b = find(id_b) + dav_c = find(id_c) + dav_d = find(id_d) + dav_e = find(id_e) + should_not_exist = find(bad_id) + + expect(dav_a.version).to eq(version_a) + expect(dav_b.version).to eq(version_a) + expect(dav_c.version).to eq(version_a) + expect(dav_d.version).to eq(version_b) + expect(dav_e.version).to eq(version_c) + expect(should_not_exist).not_to be_present + + expect(version_a.designs).to include(dav_a.design, dav_b.design, dav_c.design) + expect(version_b.designs).to include(dav_d.design) + expect(version_c.designs).to include(dav_e.design) + end.not_to exceed_query_limit(db_calls) + end + end + + describe '.find' do + let(:results) { described_class.find(ids) } + + # 2 versions, with 5 total designs on issue A, so 2*5 = 10 + let!(:version_a) do + create(:design_version, designs: create_list(:design, 3, issue: issue)) + end + let!(:version_b) do + create(:design_version, designs: create_list(:design, 2, issue: issue)) + end + # 1 version, with 3 designs on issue B, so 1*3 = 3 + let!(:version_c) do + create(:design_version, designs: create_list(:design, 3, issue: issue_b)) + end + + context 'invalid ids' do + let(:ids) do + version_b.designs.map { |d| id_of(d, version_c) } + end + + describe '#count' do + it 'counts 0 records' do + expect(results.count).to eq(0) + end + end + + describe '#empty?' do + it 'is empty' do + expect(results).to be_empty + end + end + + describe '#to_a' do + it 'finds no records' do + expect(results.to_a).to eq([]) + end + end + end + + context 'valid ids' do + let(:red_herrings) { issue_b.designs.sample(2).map { |d| id_of(d, version_a) } } + + let(:ids) do + a_ids = issue.designs.sample(2).map { |d| id_of(d, version_a) } + b_ids = issue.designs.sample(2).map { |d| id_of(d, version_b) } + c_ids = issue_b.designs.sample(2).map { |d| id_of(d, version_c) } + + a_ids + b_ids + c_ids + red_herrings + end + + before do + ids.size # force IDs + end + + describe '#count' do + it 'counts 2 records' do + expect(results.count).to eq(6) + end + + it 'issues at most two queries' do + expect { results.count }.not_to exceed_query_limit(2) + end + end + + describe '#to_a' do + it 'finds 6 records' do + expect(results.size).to eq(6) + expect(results).to all(be_a(described_class)) + end + + it 'only returns records with matching IDs' do + expect(results.map(&:id)).to match_array(ids - red_herrings) + end + + it 'only returns valid records' do + expect(results).to all(be_valid) + end + + it 'issues at most two queries' do + expect { results.to_a }.not_to exceed_query_limit(2) + end + end + end + end +end diff --git a/spec/models/design_management/design_collection_spec.rb b/spec/models/design_management/design_collection_spec.rb new file mode 100644 index 00000000000..bd48f742042 --- /dev/null +++ b/spec/models/design_management/design_collection_spec.rb @@ -0,0 +1,82 @@ +# frozen_string_literal: true +require 'spec_helper' + +describe DesignManagement::DesignCollection do + include DesignManagementTestHelpers + + let_it_be(:issue, reload: true) { create(:issue) } + + subject(:collection) { described_class.new(issue) } + + describe ".find_or_create_design!" do + it "finds an existing design" do + design = create(:design, issue: issue, filename: 'world.png') + + expect(collection.find_or_create_design!(filename: 'world.png')).to eq(design) + end + + it "creates a new design if one didn't exist" do + expect(issue.designs.size).to eq(0) + + new_design = collection.find_or_create_design!(filename: 'world.png') + + expect(issue.designs.size).to eq(1) + expect(new_design.filename).to eq('world.png') + expect(new_design.issue).to eq(issue) + end + + it "only queries the designs once" do + create(:design, issue: issue, filename: 'hello.png') + create(:design, issue: issue, filename: 'world.jpg') + + expect do + collection.find_or_create_design!(filename: 'hello.png') + collection.find_or_create_design!(filename: 'world.jpg') + end.not_to exceed_query_limit(1) + end + end + + describe "#versions" do + it "includes versions for all designs" do + version_1 = create(:design_version) + version_2 = create(:design_version) + other_version = create(:design_version) + create(:design, issue: issue, versions: [version_1]) + create(:design, issue: issue, versions: [version_2]) + create(:design, versions: [other_version]) + + expect(collection.versions).to contain_exactly(version_1, version_2) + end + end + + describe "#repository" do + it "builds a design repository" do + expect(collection.repository).to be_a(DesignManagement::Repository) + end + end + + describe '#designs_by_filename' do + let(:designs) { create_list(:design, 5, :with_file, issue: issue) } + let(:filenames) { designs.map(&:filename) } + let(:query) { subject.designs_by_filename(filenames) } + + it 'finds all the designs with those filenames on this issue' do + expect(query).to have_attributes(size: 5) + end + + it 'only makes a single query' do + designs.each(&:id) + expect { query }.not_to exceed_query_limit(1) + end + + context 'some are deleted' do + before do + delete_designs(*designs.sample(2)) + end + + it 'takes deletion into account' do + expect(query).to have_attributes(size: 3) + end + end + end +end diff --git a/spec/models/design_management/design_spec.rb b/spec/models/design_management/design_spec.rb new file mode 100644 index 00000000000..95782c1f674 --- /dev/null +++ b/spec/models/design_management/design_spec.rb @@ -0,0 +1,575 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe DesignManagement::Design do + include DesignManagementTestHelpers + + let_it_be(:issue) { create(:issue) } + let_it_be(:design1) { create(:design, :with_versions, issue: issue, versions_count: 1) } + let_it_be(:design2) { create(:design, :with_versions, issue: issue, versions_count: 1) } + let_it_be(:design3) { create(:design, :with_versions, issue: issue, versions_count: 1) } + let_it_be(:deleted_design) { create(:design, :with_versions, deleted: true) } + + describe 'relations' do + it { is_expected.to belong_to(:project) } + it { is_expected.to belong_to(:issue) } + it { is_expected.to have_many(:actions) } + it { is_expected.to have_many(:versions) } + it { is_expected.to have_many(:notes).dependent(:delete_all) } + it { is_expected.to have_many(:user_mentions) } + end + + describe 'validations' do + subject(:design) { build(:design) } + + it { is_expected.to be_valid } + it { is_expected.to validate_presence_of(:project) } + it { is_expected.to validate_presence_of(:issue) } + it { is_expected.to validate_presence_of(:filename) } + it { is_expected.to validate_uniqueness_of(:filename).scoped_to(:issue_id) } + + it "validates that the extension is an image" do + design.filename = "thing.txt" + extensions = described_class::SAFE_IMAGE_EXT + described_class::DANGEROUS_IMAGE_EXT + + expect(design).not_to be_valid + expect(design.errors[:filename].first).to eq( + "does not have a supported extension. Only #{extensions.to_sentence} are supported" + ) + end + + describe 'validating files with .svg extension' do + before do + design.filename = "thing.svg" + end + + it "allows .svg files when feature flag is enabled" do + stub_feature_flags(design_management_allow_dangerous_images: true) + + expect(design).to be_valid + end + + it "does not allow .svg files when feature flag is disabled" do + stub_feature_flags(design_management_allow_dangerous_images: false) + + expect(design).not_to be_valid + expect(design.errors[:filename].first).to eq( + "does not have a supported extension. Only #{described_class::SAFE_IMAGE_EXT.to_sentence} are supported" + ) + end + end + end + + describe 'scopes' do + describe '.visible_at_version' do + let(:versions) { DesignManagement::Version.where(issue: issue).ordered } + let(:found) { described_class.visible_at_version(version) } + + context 'at oldest version' do + let(:version) { versions.last } + + it 'finds the first design only' do + expect(found).to contain_exactly(design1) + end + end + + context 'at version 2' do + let(:version) { versions.second } + + it 'finds the first and second designs' do + expect(found).to contain_exactly(design1, design2) + end + end + + context 'at latest version' do + let(:version) { versions.first } + + it 'finds designs' do + expect(found).to contain_exactly(design1, design2, design3) + end + end + + context 'when the argument is nil' do + let(:version) { nil } + + it 'finds all undeleted designs' do + expect(found).to contain_exactly(design1, design2, design3) + end + end + + describe 'one of the designs was deleted before the given version' do + before do + delete_designs(design2) + end + + it 'is not returned' do + current_version = versions.first + + expect(described_class.visible_at_version(current_version)).to contain_exactly(design1, design3) + end + end + + context 'a re-created history' do + before do + delete_designs(design1, design2) + restore_designs(design1) + end + + it 'is returned, though other deleted events are not' do + expect(described_class.visible_at_version(nil)).to contain_exactly(design1, design3) + end + end + + # test that a design that has been modified at various points + # can be queried for correctly at different points in its history + describe 'dead or alive' do + let(:versions) { DesignManagement::Version.where(issue: issue).map { |v| [v, :alive] } } + + before do + versions << [delete_designs(design1), :dead] + versions << [modify_designs(design2), :dead] + versions << [restore_designs(design1), :alive] + versions << [modify_designs(design3), :alive] + versions << [delete_designs(design1), :dead] + versions << [modify_designs(design2, design3), :dead] + versions << [restore_designs(design1), :alive] + end + + it 'can establish the history at any point' do + history = versions.map(&:first).map do |v| + described_class.visible_at_version(v).include?(design1) ? :alive : :dead + end + + expect(history).to eq(versions.map(&:second)) + end + end + end + + describe '.with_filename' do + it 'returns correct design when passed a single filename' do + expect(described_class.with_filename(design1.filename)).to eq([design1]) + end + + it 'returns correct designs when passed an Array of filenames' do + expect( + described_class.with_filename([design1, design2].map(&:filename)) + ).to contain_exactly(design1, design2) + end + end + + describe '.on_issue' do + it 'returns correct designs when passed a single issue' do + expect(described_class.on_issue(issue)).to match_array(issue.designs) + end + + it 'returns correct designs when passed an Array of issues' do + expect( + described_class.on_issue([issue, deleted_design.issue]) + ).to contain_exactly(design1, design2, design3, deleted_design) + end + end + + describe '.current' do + it 'returns just the undeleted designs' do + delete_designs(design3) + + expect(described_class.current).to contain_exactly(design1, design2) + end + end + end + + describe '#visible_in?' do + let_it_be(:issue) { create(:issue) } + + # It is expensive to re-create complex histories, so we do it once, and then + # assert that we can establish visibility at any given version. + it 'tells us when a design is visible' do + expected = [] + + first_design = create(:design, :with_versions, issue: issue, versions_count: 1) + prior_to_creation = first_design.versions.first + expected << [prior_to_creation, :not_created_yet, false] + + v = modify_designs(first_design) + expected << [v, :not_created_yet, false] + + design = create(:design, :with_versions, issue: issue, versions_count: 1) + created_in = design.versions.first + expected << [created_in, :created, true] + + # The future state should not affect the result for any state, so we + # ensure that most states have a long future as well as a rich past + 2.times do + v = modify_designs(first_design) + expected << [v, :unaffected_visible, true] + + v = modify_designs(design) + expected << [v, :modified, true] + + v = modify_designs(first_design) + expected << [v, :unaffected_visible, true] + + v = delete_designs(design) + expected << [v, :deleted, false] + + v = modify_designs(first_design) + expected << [v, :unaffected_nv, false] + + v = restore_designs(design) + expected << [v, :restored, true] + end + + delete_designs(design) # ensure visibility is not corelated with current state + + got = expected.map do |(v, sym, _)| + [v, sym, design.visible_in?(v)] + end + + expect(got).to eq(expected) + end + end + + describe '#to_ability_name' do + it { expect(described_class.new.to_ability_name).to eq('design') } + end + + describe '#status' do + context 'the design is new' do + subject { build(:design) } + + it { is_expected.to have_attributes(status: :new) } + end + + context 'the design is current' do + subject { design1 } + + it { is_expected.to have_attributes(status: :current) } + end + + context 'the design has been deleted' do + subject { deleted_design } + + it { is_expected.to have_attributes(status: :deleted) } + end + end + + describe '#deleted?' do + context 'the design is new' do + let(:design) { build(:design) } + + it 'is falsy' do + expect(design).not_to be_deleted + end + end + + context 'the design is current' do + let(:design) { design1 } + + it 'is falsy' do + expect(design).not_to be_deleted + end + end + + context 'the design has been deleted' do + let(:design) { deleted_design } + + it 'is truthy' do + expect(design).to be_deleted + end + end + + context 'the design has been deleted, but was then re-created' do + let(:design) { create(:design, :with_versions, versions_count: 1, deleted: true) } + + it 'is falsy' do + restore_designs(design) + + expect(design).not_to be_deleted + end + end + end + + describe "#new_design?" do + let(:design) { design1 } + + it "is false when there are versions" do + expect(design1).not_to be_new_design + end + + it "is true when there are no versions" do + expect(build(:design)).to be_new_design + end + + it 'is false for deleted designs' do + expect(deleted_design).not_to be_new_design + end + + it "does not cause extra queries when actions are loaded" do + design.actions.map(&:id) + + expect { design.new_design? }.not_to exceed_query_limit(0) + end + + it "implicitly caches values" do + expect do + design.new_design? + design.new_design? + end.not_to exceed_query_limit(1) + end + + it "queries again when the clear_version_cache trigger has been called" do + expect do + design.new_design? + design.clear_version_cache + design.new_design? + end.not_to exceed_query_limit(2) + end + + it "causes a single query when there versions are not loaded" do + design.reload + + expect { design.new_design? }.not_to exceed_query_limit(1) + end + end + + describe "#full_path" do + it "builds the full path for a design" do + design = build(:design, filename: "hello.jpg") + expected_path = "#{DesignManagement.designs_directory}/issue-#{design.issue.iid}/hello.jpg" + + expect(design.full_path).to eq(expected_path) + end + end + + describe '#diff_refs' do + let(:design) { create(:design, :with_file, versions_count: versions_count) } + + context 'there are several versions' do + let(:versions_count) { 3 } + + it "builds diff refs based on the first commit and it's for the design" do + expect(design.diff_refs.base_sha).to eq(design.versions.ordered.second.sha) + expect(design.diff_refs.head_sha).to eq(design.versions.ordered.first.sha) + end + end + + context 'there is just one version' do + let(:versions_count) { 1 } + + it 'builds diff refs based on the empty tree if there was only one version' do + design = create(:design, :with_file, versions_count: 1) + + expect(design.diff_refs.base_sha).to eq(Gitlab::Git::BLANK_SHA) + expect(design.diff_refs.head_sha).to eq(design.diff_refs.head_sha) + end + end + + it 'has no diff ref if new' do + design = build(:design) + + expect(design.diff_refs).to be_nil + end + end + + describe '#repository' do + it 'is a design repository' do + design = build(:design) + + expect(design.repository).to be_a(DesignManagement::Repository) + end + end + + describe '#note_etag_key' do + it 'returns a correct etag key' do + design = create(:design) + + expect(design.note_etag_key).to eq( + ::Gitlab::Routing.url_helpers.designs_project_issue_path(design.project, design.issue, { vueroute: design.filename }) + ) + end + end + + describe '#user_notes_count', :use_clean_rails_memory_store_caching do + let_it_be(:design) { create(:design, :with_file) } + + subject { design.user_notes_count } + + # Note: Cache invalidation tests are in `design_user_notes_count_service_spec.rb` + + it 'returns a count of user-generated notes' do + create(:diff_note_on_design, noteable: design) + + is_expected.to eq(1) + end + + it 'does not count notes on other designs' do + second_design = create(:design, :with_file) + create(:diff_note_on_design, noteable: second_design) + + is_expected.to eq(0) + end + + it 'does not count system notes' do + create(:diff_note_on_design, system: true, noteable: design) + + is_expected.to eq(0) + end + end + + describe '#after_note_changed' do + subject { build(:design) } + + it 'calls #delete_cache on DesignUserNotesCountService' do + expect_next_instance_of(DesignManagement::DesignUserNotesCountService) do |service| + expect(service).to receive(:delete_cache) + end + + subject.after_note_changed(build(:note)) + end + + it 'does not call #delete_cache on DesignUserNotesCountService when passed a system note' do + expect(DesignManagement::DesignUserNotesCountService).not_to receive(:new) + + subject.after_note_changed(build(:note, :system)) + end + end + + describe '.for_reference' do + let_it_be(:design_a) { create(:design) } + let_it_be(:design_b) { create(:design) } + + it 'avoids extra queries when calling to_reference' do + designs = described_class.for_reference.where(id: [design_a.id, design_b.id]).to_a + + expect { designs.map(&:to_reference) }.not_to exceed_query_limit(0) + end + end + + describe '#to_reference' do + let(:namespace) { build(:namespace, 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(:design) { build(:design, filename: filename, issue: issue, project: project) } + + context 'when nil argument' do + let(:reference) { design.to_reference } + + it 'uses the simple format' do + expect(reference).to eq "#1[homescreen.jpg]" + end + + context 'when the filename contains spaces, hyphens, periods, single-quotes, underscores and colons' do + let(:filename) { %q{a complex filename: containing - _ : etc., but still 'simple'.gif} } + + it 'uses the simple format' do + expect(reference).to eq "#1[#{filename}]" + end + end + + context 'when the filename contains HTML angle brackets' do + let(:filename) { 'a <em>great</em> filename.jpg' } + + it 'uses Base64 encoding' do + expect(reference).to eq "#1[base64:#{Base64.strict_encode64(filename)}]" + end + end + + context 'when the filename contains quotation marks' do + let(:filename) { %q{a "great" filename.jpg} } + + it 'uses enclosing quotes, with backslash encoding' do + expect(reference).to eq %q{#1["a \"great\" filename.jpg"]} + end + end + + context 'when the filename contains square brackets' do + let(:filename) { %q{a [great] filename.jpg} } + + it 'uses enclosing quotes' do + expect(reference).to eq %q{#1["a [great] filename.jpg"]} + end + end + end + + context 'when full is true' do + it 'returns complete path to the issue' do + refs = [ + design.to_reference(full: true), + design.to_reference(project, full: true), + design.to_reference(group, full: true) + ] + + expect(refs).to all(eq 'sample-namespace/sample-project#1/designs[homescreen.jpg]') + end + end + + context 'when full is false' do + it 'returns complete path to the issue' do + refs = [ + design.to_reference(build(:project), full: false), + design.to_reference(group, full: false) + ] + + expect(refs).to all(eq 'sample-namespace/sample-project#1[homescreen.jpg]') + end + end + + context 'when same project argument' do + it 'returns bare reference' do + expect(design.to_reference(project)).to eq("#1[homescreen.jpg]") + end + end + end + + describe 'reference_pattern' do + let(:match) { described_class.reference_pattern.match(ref) } + let(:ref) { design.to_reference } + let(:design) { build(:design, filename: filename) } + + context 'simple_file_name' do + let(:filename) { 'simple-file-name.jpg' } + + it 'matches :simple_file_name' do + expect(match[:simple_file_name]).to eq(filename) + end + end + + context 'quoted_file_name' do + let(:filename) { 'simple "file" name.jpg' } + + it 'matches :simple_file_name' do + expect(match[:escaped_filename].gsub(/\\"/, '"')).to eq(filename) + end + end + + context 'Base64 name' do + let(:filename) { '<>.png' } + + it 'matches base_64_encoded_name' do + expect(Base64.decode64(match[:base_64_encoded_name])).to eq(filename) + end + end + end + + describe '.by_issue_id_and_filename' do + let_it_be(:issue_a) { create(:issue) } + let_it_be(:issue_b) { create(:issue) } + + let_it_be(:design_a) { create(:design, issue: issue_a) } + let_it_be(:design_b) { create(:design, issue: issue_a) } + let_it_be(:design_c) { create(:design, issue: issue_b, filename: design_a.filename) } + let_it_be(:design_d) { create(:design, issue: issue_b, filename: design_b.filename) } + + it_behaves_like 'a where_composite scope', :by_issue_id_and_filename do + let(:all_results) { [design_a, design_b, design_c, design_d] } + let(:first_result) { design_a } + + let(:composite_ids) do + all_results.map { |design| { issue_id: design.issue_id, filename: design.filename } } + end + end + end +end diff --git a/spec/models/design_management/repository_spec.rb b/spec/models/design_management/repository_spec.rb new file mode 100644 index 00000000000..996316eeec9 --- /dev/null +++ b/spec/models/design_management/repository_spec.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe DesignManagement::Repository do + let(:project) { create(:project) } + let(:repository) { described_class.new(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 + 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' + 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 + 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 + end +end diff --git a/spec/models/design_management/version_spec.rb b/spec/models/design_management/version_spec.rb new file mode 100644 index 00000000000..ab6958ea94a --- /dev/null +++ b/spec/models/design_management/version_spec.rb @@ -0,0 +1,342 @@ +# frozen_string_literal: true +require 'spec_helper' + +describe DesignManagement::Version do + let_it_be(:issue) { create(:issue) } + + describe 'relations' do + it { is_expected.to have_many(:actions) } + it { is_expected.to have_many(:designs).through(:actions) } + + it 'constrains the designs relation correctly' do + design = create(:design) + version = create(:design_version, designs: [design]) + + expect { version.designs << design }.to raise_error(ActiveRecord::RecordNotUnique) + end + + it 'allows adding multiple versions to a single design' do + design = create(:design) + versions = create_list(:design_version, 2) + + expect { versions.each { |v| design.versions << v } } + .not_to raise_error + end + end + + describe 'validations' do + subject(:design_version) { build(:design_version) } + + it { is_expected.to be_valid } + it { is_expected.to validate_presence_of(:author) } + it { is_expected.to validate_presence_of(:sha) } + it { is_expected.to validate_presence_of(:designs) } + it { is_expected.to validate_presence_of(:issue_id) } + it { is_expected.to validate_uniqueness_of(:sha).scoped_to(:issue_id).case_insensitive } + end + + describe "scopes" do + let_it_be(:version_1) { create(:design_version) } + let_it_be(:version_2) { create(:design_version) } + + describe ".for_designs" do + it "only returns versions related to the specified designs" do + _other_version = create(:design_version) + designs = [create(:design, versions: [version_1]), + create(:design, versions: [version_2])] + + expect(described_class.for_designs(designs)) + .to contain_exactly(version_1, version_2) + end + end + + describe '.earlier_or_equal_to' do + it 'only returns versions created earlier or later than the given version' do + expect(described_class.earlier_or_equal_to(version_1)).to eq([version_1]) + expect(described_class.earlier_or_equal_to(version_2)).to contain_exactly(version_1, version_2) + end + + it 'can be passed either a DesignManagement::Version or an ID' do + [version_1, version_1.id].each do |arg| + expect(described_class.earlier_or_equal_to(arg)).to eq([version_1]) + end + end + end + + describe '.by_sha' do + it 'can find versions by sha' do + [version_1, version_2].each do |version| + expect(described_class.by_sha(version.sha)).to contain_exactly(version) + end + end + end + end + + describe ".create_for_designs" do + def current_version_id(design) + design.send(:head_version).try(:id) + end + + def as_actions(designs, action = :create) + designs.map do |d| + DesignManagement::DesignAction.new(d, action, action == :delete ? nil : :content) + end + end + + let_it_be(:author) { create(:user) } + let_it_be(:design_a) { create(:design, issue: issue) } + let_it_be(:design_b) { create(:design, issue: issue) } + let_it_be(:designs) { [design_a, design_b] } + + describe 'the error raised when there are no actions' do + let_it_be(:sha) { 'f00' } + + def call_with_empty_actions + described_class.create_for_designs([], sha, author) + end + + it 'raises CouldNotCreateVersion' do + expect { call_with_empty_actions } + .to raise_error(described_class::CouldNotCreateVersion) + end + + it 'has an appropriate cause' do + expect { call_with_empty_actions } + .to raise_error(have_attributes(cause: ActiveRecord::RecordInvalid)) + end + + it 'provides extra data sentry can consume' do + extra_info = a_hash_including(sha: sha) + + expect { call_with_empty_actions } + .to raise_error(have_attributes(sentry_extra_data: extra_info)) + end + end + + describe 'the error raised when the designs come from different issues' do + let_it_be(:sha) { 'f00' } + let_it_be(:designs) { create_list(:design, 2) } + let_it_be(:actions) { as_actions(designs) } + + def call_with_mismatched_designs + described_class.create_for_designs(actions, sha, author) + end + + it 'raises CouldNotCreateVersion' do + expect { call_with_mismatched_designs } + .to raise_error(described_class::CouldNotCreateVersion) + end + + it 'has an appropriate cause' do + expect { call_with_mismatched_designs } + .to raise_error(have_attributes(cause: described_class::NotSameIssue)) + end + + it 'provides extra data sentry can consume' do + extra_info = a_hash_including(design_ids: designs.map(&:id)) + + expect { call_with_mismatched_designs } + .to raise_error(have_attributes(sentry_extra_data: extra_info)) + end + end + + it 'does not leave invalid versions around if creation fails' do + expect do + described_class.create_for_designs([], 'abcdef', author) rescue nil + end.not_to change { described_class.count } + end + + it 'does not leave orphaned design-versions around if creation fails' do + actions = as_actions(designs) + expect do + described_class.create_for_designs(actions, '', author) rescue nil + end.not_to change { DesignManagement::Action.count } + end + + it 'creates a version and links it to multiple designs' do + actions = as_actions(designs, :create) + + version = described_class.create_for_designs(actions, 'abc', author) + + expect(version.designs).to contain_exactly(*designs) + expect(designs.map(&method(:current_version_id))).to all(eq version.id) + end + + it 'creates designs if they are new to git' do + actions = as_actions(designs, :create) + + described_class.create_for_designs(actions, 'abc', author) + + expect(designs.map(&:most_recent_action)).to all(be_creation) + end + + it 'correctly associates the version with the issue' do + actions = as_actions(designs) + + version = described_class.create_for_designs(actions, 'abc', author) + + expect(version.issue).to eq(issue) + end + + it 'correctly associates the version with the author' do + actions = as_actions(designs) + + version = described_class.create_for_designs(actions, 'abc', author) + + expect(version.author).to eq(author) + end + + it 'modifies designs if git updated them' do + actions = as_actions(designs, :update) + + described_class.create_for_designs(actions, 'abc', author) + + expect(designs.map(&:most_recent_action)).to all(be_modification) + end + + it 'deletes designs when the git action was delete' do + actions = as_actions(designs, :delete) + + described_class.create_for_designs(actions, 'def', author) + + expect(designs).to all(be_deleted) + end + + it 're-creates designs if they are deleted' do + described_class.create_for_designs(as_actions(designs, :create), 'abc', author) + described_class.create_for_designs(as_actions(designs, :delete), 'def', author) + + expect(designs).to all(be_deleted) + + described_class.create_for_designs(as_actions(designs, :create), 'ghi', author) + + expect(designs.map(&:most_recent_action)).to all(be_creation) + expect(designs).not_to include(be_deleted) + end + + it 'changes the version of the designs' do + actions = as_actions([design_a]) + described_class.create_for_designs(actions, 'before', author) + + expect do + described_class.create_for_designs(actions, 'after', author) + end.to change { current_version_id(design_a) } + end + end + + describe '#designs_by_event' do + context 'there is a single design' do + let_it_be(:design) { create(:design) } + + shared_examples :a_correctly_categorised_design do |kind, category| + let_it_be(:version) { create(:design_version, kind => [design]) } + + it 'returns a hash with a single key and the single design in that bucket' do + expect(version.designs_by_event).to eq(category => [design]) + end + end + + it_behaves_like :a_correctly_categorised_design, :created_designs, 'creation' + it_behaves_like :a_correctly_categorised_design, :modified_designs, 'modification' + it_behaves_like :a_correctly_categorised_design, :deleted_designs, 'deletion' + end + + 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)) + end + + it 'puts them in the right buckets' do + expect(version.designs_by_event).to match( + a_hash_including( + 'creation' => have_attributes(size: 3), + 'modification' => have_attributes(size: 4), + 'deletion' => have_attributes(size: 5) + ) + ) + end + + it 'does not suffer from N+1 queries' do + version.designs.map(&:id) # we don't care about the set-up queries + expect { version.designs_by_event }.not_to exceed_query_limit(2) + end + end + end + + describe '#author' do + it 'returns the author' do + author = build(:user) + version = build(:design_version, author: author) + + expect(version.author).to eq(author) + end + + it 'returns nil if author_id is nil and version is not persisted' do + version = build(:design_version, author: nil) + + expect(version.author).to eq(nil) + end + + it 'retrieves author from the Commit if author_id is nil and version has been persisted' do + author = create(:user) + version = create(:design_version, :committed, author: author) + author.destroy + version.reload + commit = version.issue.project.design_repository.commit(version.sha) + commit_user = create(:user, email: commit.author_email, name: commit.author_name) + + expect(version.author_id).to eq(nil) + expect(version.author).to eq(commit_user) + end + end + + describe '#diff_refs' do + let(:project) { issue.project } + + before do + expect(project.design_repository).to receive(:commit) + .once + .with(sha) + .and_return(commit) + end + + subject { create(:design_version, issue: issue, sha: sha) } + + context 'there is a commit in the repo by the SHA' do + let(:commit) { build(:commit) } + let(:sha) { commit.id } + + it { is_expected.to have_attributes(diff_refs: commit.diff_refs) } + + it 'memoizes calls to #diff_refs' do + expect(subject.diff_refs).to eq(subject.diff_refs) + end + end + + context 'there is no commit in the repo by the SHA' do + let(:commit) { nil } + let(:sha) { Digest::SHA1.hexdigest("points to nothing") } + + it { is_expected.to have_attributes(diff_refs: be_nil) } + end + end + + describe '#reset' do + subject { create(:design_version, issue: issue) } + + it 'removes memoized values' do + expect(subject).to receive(:commit).twice.and_return(nil) + + subject.diff_refs + subject.diff_refs + + subject.reset + + subject.diff_refs + subject.diff_refs + end + end +end diff --git a/spec/models/design_user_mention_spec.rb b/spec/models/design_user_mention_spec.rb new file mode 100644 index 00000000000..03c77c73c8d --- /dev/null +++ b/spec/models/design_user_mention_spec.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe DesignUserMention do + describe 'associations' do + it { is_expected.to belong_to(:design) } + it { is_expected.to belong_to(:note) } + end + + it_behaves_like 'has user mentions' +end diff --git a/spec/models/diff_note_spec.rb b/spec/models/diff_note_spec.rb index b802c8ba506..65f06a5b270 100644 --- a/spec/models/diff_note_spec.rb +++ b/spec/models/diff_note_spec.rb @@ -287,6 +287,24 @@ describe DiffNote do reply_diff_note.reload.diff_file end end + + context 'when noteable is a Design' do + it 'does not return a diff file' do + diff_note = create(:diff_note_on_design) + + expect(diff_note.diff_file).to be_nil + end + end + end + + describe '#latest_diff_file' do + context 'when noteable is a Design' do + it 'does not return a diff file' do + diff_note = create(:diff_note_on_design) + + expect(diff_note.latest_diff_file).to be_nil + end + end end describe "#diff_line" do diff --git a/spec/models/email_spec.rb b/spec/models/email_spec.rb index aa3a60b867a..f7b194abcee 100644 --- a/spec/models/email_spec.rb +++ b/spec/models/email_spec.rb @@ -3,8 +3,14 @@ require 'spec_helper' describe Email do + describe 'modules' do + subject { described_class } + + it { is_expected.to include_module(AsyncDeviseEmail) } + end + describe 'validations' do - it_behaves_like 'an object with email-formated attributes', :email do + it_behaves_like 'an object with RFC3696 compliant email-formated attributes', :email do subject { build(:email) } end end @@ -45,4 +51,16 @@ describe Email do expect(build(:email, user: user).username).to eq user.username end end + + describe 'Devise emails' do + let!(:user) { create(:user) } + + describe 'behaviour' do + it 'sends emails asynchronously' do + expect do + user.emails.create!(email: 'hello@hello.com') + end.to have_enqueued_job.on_queue('mailers') + end + end + end end diff --git a/spec/models/environment_spec.rb b/spec/models/environment_spec.rb index d0305d878e3..c0b2a4ae984 100644 --- a/spec/models/environment_spec.rb +++ b/spec/models/environment_spec.rb @@ -1311,4 +1311,25 @@ describe Environment, :use_clean_rails_memory_store_caching do expect { environment.destroy }.to change { project.commit(deployment.ref_path) }.to(nil) end end + + describe '.count_by_state' do + context 'when environments are not empty' do + let!(:environment1) { create(:environment, project: project, state: 'stopped') } + let!(:environment2) { create(:environment, project: project, state: 'available') } + let!(:environment3) { create(:environment, project: project, state: 'stopped') } + + it 'returns the environments count grouped by state' do + expect(project.environments.count_by_state).to eq({ stopped: 2, available: 1 }) + end + + it 'returns the environments count grouped by state with zero value' do + environment2.update(state: 'stopped') + expect(project.environments.count_by_state).to eq({ stopped: 3, available: 0 }) + end + end + + it 'returns zero state counts when environments are empty' do + expect(project.environments.count_by_state).to eq({ stopped: 0, available: 0 }) + end + end end diff --git a/spec/models/event_spec.rb b/spec/models/event_spec.rb index 3239c7a843a..ac89f8fe9e1 100644 --- a/spec/models/event_spec.rb +++ b/spec/models/event_spec.rb @@ -84,6 +84,21 @@ describe Event do end end + describe 'scopes' do + describe 'created_at' do + it 'can find the right event' do + time = 1.day.ago + event = create(:event, created_at: time) + false_positive = create(:event, created_at: 2.days.ago) + + found = described_class.created_at(time) + + expect(found).to include(event) + expect(found).not_to include(false_positive) + end + end + end + describe "Push event" do let(:project) { create(:project, :private) } let(:user) { project.owner } @@ -195,11 +210,13 @@ describe Event do let(:confidential_issue) { create(:issue, :confidential, project: project, author: author, assignees: [assignee]) } let(:project_snippet) { create(:project_snippet, :public, project: project, author: author) } let(:personal_snippet) { create(:personal_snippet, :public, author: author) } + let(:design) { create(:design, issue: issue, project: project) } let(:note_on_commit) { create(:note_on_commit, project: project) } let(:note_on_issue) { create(:note_on_issue, noteable: issue, project: project) } let(:note_on_confidential_issue) { create(:note_on_issue, noteable: confidential_issue, project: project) } let(:note_on_project_snippet) { create(:note_on_project_snippet, author: author, noteable: project_snippet, project: project) } let(:note_on_personal_snippet) { create(:note_on_personal_snippet, author: author, noteable: personal_snippet, project: nil) } + 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, @@ -270,8 +287,16 @@ describe Event do context 'private project' do let(:project) { create(:project, :private, :repository) } - include_examples 'visibility examples' do - let(:visibility) { visible_to_none_except(:member, :admin) } + context 'when admin mode enabled', :enable_admin_mode do + include_examples 'visibility examples' do + let(:visibility) { visible_to_none_except(:member, :admin) } + end + end + + context 'when admin mode disabled' do + include_examples 'visibility examples' do + let(:visibility) { visible_to_none_except(:member) } + end end end end @@ -283,6 +308,7 @@ describe Event do include_examples 'visibility examples' do let(:visibility) { visible_to_all } end + include_examples 'visible to assignee and author', true end @@ -292,6 +318,7 @@ describe Event do include_examples 'visibility examples' do let(:visibility) { visible_to_none_except(:member, :admin) } end + include_examples 'visible to assignee and author', true end end @@ -303,6 +330,7 @@ describe Event do include_examples 'visibility examples' do let(:visibility) { visible_to_all } end + include_examples 'visible to assignee and author', true end @@ -312,6 +340,7 @@ describe Event do include_examples 'visibility examples' do let(:visibility) { visible_to_none_except(:member, :admin) } end + include_examples 'visible to assignee and author', true end @@ -319,8 +348,16 @@ describe Event do let(:project) { private_project } let(:target) { note_on_issue } - include_examples 'visibility examples' do - let(:visibility) { visible_to_none_except(:guest, :member, :admin) } + context 'when admin mode enabled', :enable_admin_mode do + include_examples 'visibility examples' do + let(:visibility) { visible_to_none_except(:guest, :member, :admin) } + end + end + + context 'when admin mode disabled' do + include_examples 'visibility examples' do + let(:visibility) { visible_to_none_except(:guest, :member) } + end end include_examples 'visible to assignee and author', false @@ -345,8 +382,16 @@ describe Event do context 'private project' do let(:project) { private_project } - include_examples 'visibility examples' do - let(:visibility) { visible_to_none_except(:member, :admin) } + context 'when admin mode enabled', :enable_admin_mode do + include_examples 'visibility examples' do + let(:visibility) { visible_to_none_except(:member, :admin) } + end + end + + context 'when admin mode disabled' do + include_examples 'visibility examples' do + let(:visibility) { visible_to_none_except(:member) } + end end include_examples 'visible to assignee', false @@ -363,16 +408,32 @@ describe Event do context 'on public project with private issue tracker and merge requests' do let(:project) { create(:project, :public, :issues_private, :merge_requests_private) } - include_examples 'visibility examples' do - let(:visibility) { visible_to_all_except(:logged_out, :non_member) } + context 'when admin mode enabled', :enable_admin_mode do + include_examples 'visibility examples' do + let(:visibility) { visible_to_all_except(:logged_out, :non_member) } + end + end + + context 'when admin mode disabled' do + include_examples 'visibility examples' do + let(:visibility) { visible_to_all_except(:logged_out, :non_member, :admin) } + end end end context 'on private project' do let(:project) { create(:project, :private) } - include_examples 'visibility examples' do - let(:visibility) { visible_to_all_except(:logged_out, :non_member) } + context 'when admin mode enabled', :enable_admin_mode do + include_examples 'visibility examples' do + let(:visibility) { visible_to_all_except(:logged_out, :non_member) } + end + end + + context 'when admin mode disabled' do + include_examples 'visibility examples' do + let(:visibility) { visible_to_all_except(:logged_out, :non_member, :admin) } + end end end end @@ -383,8 +444,16 @@ describe Event do context 'on private project', :aggregate_failures do let(:project) { create(:project, :wiki_repo) } - include_examples 'visibility examples' do - let(:visibility) { visible_to_all_except(:logged_out, :non_member) } + context 'when admin mode enabled', :enable_admin_mode do + include_examples 'visibility examples' do + let(:visibility) { visible_to_all_except(:logged_out, :non_member) } + end + end + + context 'when admin mode disabled' do + include_examples 'visibility examples' do + let(:visibility) { visible_to_all_except(:logged_out, :non_member, :admin) } + end end end @@ -407,22 +476,42 @@ describe Event do context 'on public project with private snippets' do let(:project) { create(:project, :public, :snippets_private) } - include_examples 'visibility examples' do - let(:visibility) { visible_to_none_except(:guest, :member, :admin) } + context 'when admin mode enabled', :enable_admin_mode do + include_examples 'visibility examples' do + let(:visibility) { visible_to_none_except(:guest, :member, :admin) } + end + end + + context 'when admin mode disabled' do + include_examples 'visibility examples' do + let(:visibility) { visible_to_none_except(:guest, :member) } + end end + # Normally, we'd expect the author of a comment to be able to view it. # However, this doesn't seem to be the case for comments on snippets. + include_examples 'visible to author', false end context 'on private project' do let(:project) { create(:project, :private) } - include_examples 'visibility examples' do - let(:visibility) { visible_to_none_except(:guest, :member, :admin) } + context 'when admin mode enabled', :enable_admin_mode do + include_examples 'visibility examples' do + let(:visibility) { visible_to_none_except(:guest, :member, :admin) } + end end + + context 'when admin mode disabled' do + include_examples 'visibility examples' do + let(:visibility) { visible_to_none_except(:guest, :member) } + end + end + # Normally, we'd expect the author of a comment to be able to view it. # However, this doesn't seem to be the case for comments on snippets. + include_examples 'visible to author', false end end @@ -433,6 +522,7 @@ describe Event do include_examples 'visibility examples' do let(:visibility) { visible_to_all } end + include_examples 'visible to author', true context 'on internal snippet' do @@ -446,12 +536,47 @@ describe Event do context 'on private snippet' do let(:personal_snippet) { create(:personal_snippet, :private, author: author) } - include_examples 'visibility examples' do - let(:visibility) { visible_to_none_except(:admin) } + context 'when admin mode enabled', :enable_admin_mode do + include_examples 'visibility examples' do + let(:visibility) { visible_to_none_except(:admin) } + end end + + context 'when admin mode disabled' do + include_examples 'visibility examples' do + let(:visibility) { visible_to_none } + end + end + include_examples 'visible to author', true end end + + context 'design event' do + include DesignManagementTestHelpers + + let(:target) { note_on_design } + + before do + enable_design_management + end + + include_examples 'visibility examples' do + let(:visibility) { visible_to_all } + end + + include_examples 'visible to assignee and author', true + + context 'the event refers to a design on a confidential issue' do + let(:design) { create(:design, issue: confidential_issue, project: project) } + + include_examples 'visibility examples' do + let(:visibility) { visible_to_none_except(:member, :admin) } + end + + include_examples 'visible to assignee and author', true + end + end end describe 'wiki_page predicate scopes' do @@ -483,6 +608,14 @@ describe Event do expect(described_class.not_wiki_page).to match_array(non_wiki_events) end end + + describe '.for_wiki_meta' do + it 'finds events for a given wiki page metadata object' do + event = events.select(&:wiki_page?).first + + expect(described_class.for_wiki_meta(event.target)).to contain_exactly(event) + end + end end describe '#wiki_page and #wiki_page?' do @@ -490,7 +623,7 @@ describe Event do context 'for a wiki page event' do let(:wiki_page) do - create(:wiki_page, :with_real_page, project: project) + create(:wiki_page, project: project) end subject(:event) { create(:wiki_page_event, project: project, wiki_page: wiki_page) } diff --git a/spec/models/group_spec.rb b/spec/models/group_spec.rb index 576ac880fca..a4e49f88115 100644 --- a/spec/models/group_spec.rb +++ b/spec/models/group_spec.rb @@ -24,6 +24,8 @@ describe Group do it { is_expected.to have_many(:cluster_groups).class_name('Clusters::Group') } it { is_expected.to have_many(:clusters).class_name('Clusters::Cluster') } it { is_expected.to have_many(:container_repositories) } + it { is_expected.to have_many(:milestones) } + it { is_expected.to have_many(:iterations) } describe '#members & #requesters' do let(:requester) { create(:user) } @@ -553,114 +555,72 @@ describe Group do group_access: GroupMember::DEVELOPER }) end - context 'when feature flag share_group_with_group is enabled' do - before do - stub_feature_flags(share_group_with_group: true) - end - - context 'with user in the group' do - let(:user) { group_user } - - it 'returns correct access level' do - expect(shared_group_parent.max_member_access_for_user(user)).to eq(Gitlab::Access::NO_ACCESS) - expect(shared_group.max_member_access_for_user(user)).to eq(Gitlab::Access::DEVELOPER) - expect(shared_group_child.max_member_access_for_user(user)).to eq(Gitlab::Access::DEVELOPER) - end + context 'with user in the group' do + let(:user) { group_user } - context 'with lower group access level than max access level for share' do - let(:user) { create(:user) } - - it 'returns correct access level' do - group.add_reporter(user) - - expect(shared_group_parent.max_member_access_for_user(user)).to eq(Gitlab::Access::NO_ACCESS) - expect(shared_group.max_member_access_for_user(user)).to eq(Gitlab::Access::REPORTER) - expect(shared_group_child.max_member_access_for_user(user)).to eq(Gitlab::Access::REPORTER) - end - end + it 'returns correct access level' do + expect(shared_group_parent.max_member_access_for_user(user)).to eq(Gitlab::Access::NO_ACCESS) + expect(shared_group.max_member_access_for_user(user)).to eq(Gitlab::Access::DEVELOPER) + expect(shared_group_child.max_member_access_for_user(user)).to eq(Gitlab::Access::DEVELOPER) end - context 'with user in the parent group' do - let(:user) { parent_group_user } + context 'with lower group access level than max access level for share' do + let(:user) { create(:user) } it 'returns correct access level' do - expect(shared_group_parent.max_member_access_for_user(user)).to eq(Gitlab::Access::NO_ACCESS) - expect(shared_group.max_member_access_for_user(user)).to eq(Gitlab::Access::NO_ACCESS) - expect(shared_group_child.max_member_access_for_user(user)).to eq(Gitlab::Access::NO_ACCESS) - end - end - - context 'with user in the child group' do - let(:user) { child_group_user } + group.add_reporter(user) - it 'returns correct access level' do expect(shared_group_parent.max_member_access_for_user(user)).to eq(Gitlab::Access::NO_ACCESS) - expect(shared_group.max_member_access_for_user(user)).to eq(Gitlab::Access::NO_ACCESS) - expect(shared_group_child.max_member_access_for_user(user)).to eq(Gitlab::Access::NO_ACCESS) + expect(shared_group.max_member_access_for_user(user)).to eq(Gitlab::Access::REPORTER) + expect(shared_group_child.max_member_access_for_user(user)).to eq(Gitlab::Access::REPORTER) end end + end - context 'unrelated project owner' do - let(:common_id) { [Project.maximum(:id).to_i, Namespace.maximum(:id).to_i].max + 999 } - let!(:group) { create(:group, id: common_id) } - let!(:unrelated_project) { create(:project, id: common_id) } - let(:user) { unrelated_project.owner } + context 'with user in the parent group' do + let(:user) { parent_group_user } - it 'returns correct access level' do - expect(shared_group_parent.max_member_access_for_user(user)).to eq(Gitlab::Access::NO_ACCESS) - expect(shared_group.max_member_access_for_user(user)).to eq(Gitlab::Access::NO_ACCESS) - expect(shared_group_child.max_member_access_for_user(user)).to eq(Gitlab::Access::NO_ACCESS) - end + it 'returns correct access level' do + expect(shared_group_parent.max_member_access_for_user(user)).to eq(Gitlab::Access::NO_ACCESS) + expect(shared_group.max_member_access_for_user(user)).to eq(Gitlab::Access::NO_ACCESS) + expect(shared_group_child.max_member_access_for_user(user)).to eq(Gitlab::Access::NO_ACCESS) end + end - context 'user without accepted access request' do - let!(:user) { create(:user) } - - before do - create(:group_member, :developer, :access_request, user: user, group: group) - end + context 'with user in the child group' do + let(:user) { child_group_user } - it 'returns correct access level' do - expect(shared_group_parent.max_member_access_for_user(user)).to eq(Gitlab::Access::NO_ACCESS) - expect(shared_group.max_member_access_for_user(user)).to eq(Gitlab::Access::NO_ACCESS) - expect(shared_group_child.max_member_access_for_user(user)).to eq(Gitlab::Access::NO_ACCESS) - end + it 'returns correct access level' do + expect(shared_group_parent.max_member_access_for_user(user)).to eq(Gitlab::Access::NO_ACCESS) + expect(shared_group.max_member_access_for_user(user)).to eq(Gitlab::Access::NO_ACCESS) + expect(shared_group_child.max_member_access_for_user(user)).to eq(Gitlab::Access::NO_ACCESS) end end - context 'when feature flag share_group_with_group is disabled' do - before do - stub_feature_flags(share_group_with_group: false) - end - - context 'with user in the group' do - let(:user) { group_user } + context 'unrelated project owner' do + let(:common_id) { [Project.maximum(:id).to_i, Namespace.maximum(:id).to_i].max + 999 } + let!(:group) { create(:group, id: common_id) } + let!(:unrelated_project) { create(:project, id: common_id) } + let(:user) { unrelated_project.owner } - it 'returns correct access level' do - expect(shared_group_parent.max_member_access_for_user(user)).to eq(Gitlab::Access::NO_ACCESS) - expect(shared_group.max_member_access_for_user(user)).to eq(Gitlab::Access::NO_ACCESS) - expect(shared_group_child.max_member_access_for_user(user)).to eq(Gitlab::Access::NO_ACCESS) - end + it 'returns correct access level' do + expect(shared_group_parent.max_member_access_for_user(user)).to eq(Gitlab::Access::NO_ACCESS) + expect(shared_group.max_member_access_for_user(user)).to eq(Gitlab::Access::NO_ACCESS) + expect(shared_group_child.max_member_access_for_user(user)).to eq(Gitlab::Access::NO_ACCESS) end + end - context 'with user in the parent group' do - let(:user) { parent_group_user } + context 'user without accepted access request' do + let!(:user) { create(:user) } - it 'returns correct access level' do - expect(shared_group_parent.max_member_access_for_user(user)).to eq(Gitlab::Access::NO_ACCESS) - expect(shared_group.max_member_access_for_user(user)).to eq(Gitlab::Access::NO_ACCESS) - expect(shared_group_child.max_member_access_for_user(user)).to eq(Gitlab::Access::NO_ACCESS) - end + before do + create(:group_member, :developer, :access_request, user: user, group: group) end - context 'with user in the child group' do - let(:user) { child_group_user } - - it 'returns correct access level' do - expect(shared_group_parent.max_member_access_for_user(user)).to eq(Gitlab::Access::NO_ACCESS) - expect(shared_group.max_member_access_for_user(user)).to eq(Gitlab::Access::NO_ACCESS) - expect(shared_group_child.max_member_access_for_user(user)).to eq(Gitlab::Access::NO_ACCESS) - end + it 'returns correct access level' do + expect(shared_group_parent.max_member_access_for_user(user)).to eq(Gitlab::Access::NO_ACCESS) + expect(shared_group.max_member_access_for_user(user)).to eq(Gitlab::Access::NO_ACCESS) + expect(shared_group_child.max_member_access_for_user(user)).to eq(Gitlab::Access::NO_ACCESS) end end end @@ -672,8 +632,6 @@ describe Group do let(:shared_group) { create(:group, :private, parent: shared_group_parent) } before do - stub_feature_flags(share_group_with_group: true) - group.add_owner(user) create(:group_group_link, { shared_with_group: group, @@ -701,6 +659,42 @@ describe Group do end end + describe '#members_from_self_and_ancestors_with_effective_access_level' do + let!(:group_parent) { create(:group, :private) } + let!(:group) { create(:group, :private, parent: group_parent) } + let!(:group_child) { create(:group, :private, parent: group) } + + let!(:user) { create(:user) } + + let(:parent_group_access_level) { Gitlab::Access::REPORTER } + let(:group_access_level) { Gitlab::Access::DEVELOPER } + let(:child_group_access_level) { Gitlab::Access::MAINTAINER } + + before do + create(:group_member, user: user, group: group_parent, access_level: parent_group_access_level) + create(:group_member, user: user, group: group, access_level: group_access_level) + create(:group_member, user: user, group: group_child, access_level: child_group_access_level) + end + + it 'returns effective access level for user' do + expect(group_parent.members_from_self_and_ancestors_with_effective_access_level.as_json).to( + contain_exactly( + hash_including('user_id' => user.id, 'access_level' => parent_group_access_level) + ) + ) + expect(group.members_from_self_and_ancestors_with_effective_access_level.as_json).to( + contain_exactly( + hash_including('user_id' => user.id, 'access_level' => group_access_level) + ) + ) + expect(group_child.members_from_self_and_ancestors_with_effective_access_level.as_json).to( + contain_exactly( + hash_including('user_id' => user.id, 'access_level' => child_group_access_level) + ) + ) + end + end + describe '#direct_and_indirect_members' do let!(:group) { create(:group, :nested) } let!(:sub_group) { create(:group, parent: group) } diff --git a/spec/models/hooks/project_hook_spec.rb b/spec/models/hooks/project_hook_spec.rb index a945f0d1516..ccf8171049d 100644 --- a/spec/models/hooks/project_hook_spec.rb +++ b/spec/models/hooks/project_hook_spec.rb @@ -11,6 +11,10 @@ describe ProjectHook do it { is_expected.to validate_presence_of(:project) } end + it_behaves_like 'includes Limitable concern' do + subject { build(:project_hook, project: create(:project)) } + end + describe '.push_hooks' do it 'returns hooks for push events only' do hook = create(:project_hook, push_events: true) diff --git a/spec/models/issue_spec.rb b/spec/models/issue_spec.rb index e8103be0682..dd5ff3dbdde 100644 --- a/spec/models/issue_spec.rb +++ b/spec/models/issue_spec.rb @@ -7,13 +7,30 @@ describe Issue 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(:moved_to).class_name('Issue') } + it { is_expected.to have_one(:moved_from).class_name('Issue') } it { is_expected.to belong_to(:duplicated_to).class_name('Issue') } it { is_expected.to belong_to(:closed_by).class_name('User') } it { is_expected.to have_many(:assignees) } it { is_expected.to have_many(:user_mentions).class_name("IssueUserMention") } + it { is_expected.to have_many(:designs) } + it { is_expected.to have_many(:design_versions) } it { is_expected.to have_one(:sentry_issue) } + it { is_expected.to have_one(:alert_management_alert) } + it { is_expected.to have_many(:resource_milestone_events) } + it { is_expected.to have_many(:resource_state_events) } + + describe 'versions.most_recent' do + it 'returns the most recent version' do + issue = create(:issue) + create_list(:design_version, 2, issue: issue) + last_version = create(:design_version, issue: issue) + + expect(issue.design_versions.most_recent).to eq(last_version) + end + end end describe 'modules' do @@ -23,6 +40,8 @@ describe Issue do it { is_expected.to include_module(Referable) } it { is_expected.to include_module(Sortable) } it { is_expected.to include_module(Taskable) } + it { is_expected.to include_module(MilestoneEventable) } + it { is_expected.to include_module(StateEventable) } it_behaves_like 'AtomicInternalId' do let(:internal_id_attribute) { :iid } @@ -61,6 +80,18 @@ describe Issue do end end + describe '.with_alert_management_alerts' do + subject { described_class.with_alert_management_alerts } + + it 'gets only issues with alerts' do + alert = create(:alert_management_alert, issue: create(:issue)) + issue = create(:issue) + + expect(subject).to contain_exactly(alert.issue) + expect(subject).not_to include(issue) + end + end + describe 'locking' do using RSpec::Parameterized::TableSyntax @@ -593,8 +624,15 @@ describe Issue do context 'with an admin user' do let(:user) { build(:admin) } - it_behaves_like 'issue readable by user' - it_behaves_like 'confidential issue readable by user' + context 'when admin mode is enabled', :enable_admin_mode do + it_behaves_like 'issue readable by user' + it_behaves_like 'confidential issue readable by user' + end + + context 'when admin mode is disabled' do + it_behaves_like 'issue not readable by user' + it_behaves_like 'confidential issue not readable by user' + end end context 'with an owner' do @@ -713,13 +751,29 @@ describe Issue do expect(issue.visible_to_user?(user)).to be_falsy end - it 'does not check the external webservice for admins' do - issue = build(:issue) - user = build(:admin) + context 'with an admin' do + context 'when admin mode is enabled', :enable_admin_mode do + it 'does not check the external webservice' do + issue = build(:issue) + user = build(:admin) - expect(::Gitlab::ExternalAuthorization).not_to receive(:access_allowed?) + expect(::Gitlab::ExternalAuthorization).not_to receive(:access_allowed?) - issue.visible_to_user?(user) + issue.visible_to_user?(user) + end + end + + context 'when admin mode is disabled' do + it 'checks the external service to determine if an issue is readable by the admin' do + project = build(:project, :public, + external_authorization_classification_label: 'a-label') + issue = build(:issue, project: project) + user = build(:admin) + + expect(::Gitlab::ExternalAuthorization).to receive(:access_allowed?).with(user, 'a-label') { false } + expect(issue.visible_to_user?(user)).to be_falsy + end + end end end @@ -967,4 +1021,68 @@ describe Issue do expect(issue.previous_updated_at).to eq(Time.new(2013, 02, 06)) end end + + describe '#design_collection' do + it 'returns a design collection' do + issue = build(:issue) + collection = issue.design_collection + + expect(collection).to be_a(DesignManagement::DesignCollection) + expect(collection.issue).to eq(issue) + end + end + + describe 'current designs' do + let(:issue) { create(:issue) } + + subject { issue.designs.current } + + context 'an issue has no designs' do + it { is_expected.to be_empty } + end + + context 'an issue only has current designs' do + let!(:design_a) { create(:design, :with_file, issue: issue) } + let!(:design_b) { create(:design, :with_file, issue: issue) } + let!(:design_c) { create(:design, :with_file, issue: issue) } + + it { is_expected.to include(design_a, design_b, design_c) } + end + + context 'an issue only has deleted designs' do + let!(:design_a) { create(:design, :with_file, issue: issue, deleted: true) } + let!(:design_b) { create(:design, :with_file, issue: issue, deleted: true) } + let!(:design_c) { create(:design, :with_file, issue: issue, deleted: true) } + + it { is_expected.to be_empty } + end + + context 'an issue has a mixture of current and deleted designs' do + let!(:design_a) { create(:design, :with_file, issue: issue) } + let!(:design_b) { create(:design, :with_file, issue: issue, deleted: true) } + let!(:design_c) { create(:design, :with_file, issue: issue) } + + it { is_expected.to contain_exactly(design_a, design_c) } + end + end + + describe '.with_label_attributes' do + subject { described_class.with_label_attributes(label_attributes) } + + let(:label_attributes) { { title: 'hello world', description: 'hi' } } + + it 'gets issues with given label attributes' do + label = create(:label, **label_attributes) + labeled_issue = create(:labeled_issue, project: label.project, labels: [label]) + + expect(subject).to include(labeled_issue) + end + + it 'excludes issues without given label attributes' do + label = create(:label, title: 'GitLab', description: 'tanuki') + labeled_issue = create(:labeled_issue, project: label.project, labels: [label]) + + expect(subject).not_to include(labeled_issue) + end + end end diff --git a/spec/models/iteration_spec.rb b/spec/models/iteration_spec.rb new file mode 100644 index 00000000000..e5b7b746639 --- /dev/null +++ b/spec/models/iteration_spec.rb @@ -0,0 +1,170 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Iteration do + let_it_be(:project) { create(:project) } + let_it_be(:group) { create(:group) } + + it_behaves_like 'a timebox', :iteration do + let(:timebox_table_name) { described_class.table_name.to_sym } + end + + describe "#iid" do + it "is properly scoped on project and group" do + iteration1 = create(:iteration, project: project) + iteration2 = create(:iteration, project: project) + iteration3 = create(:iteration, group: group) + iteration4 = create(:iteration, group: group) + iteration5 = create(:iteration, project: project) + + want = { + iteration1: 1, + iteration2: 2, + iteration3: 1, + iteration4: 2, + iteration5: 3 + } + got = { + iteration1: iteration1.iid, + iteration2: iteration2.iid, + iteration3: iteration3.iid, + iteration4: iteration4.iid, + iteration5: iteration5.iid + } + expect(got).to eq(want) + end + end + + context 'Validations' do + subject { build(:iteration, group: group, start_date: start_date, due_date: due_date) } + + describe '#dates_do_not_overlap' do + let_it_be(:existing_iteration) { create(:iteration, group: group, start_date: 4.days.from_now, due_date: 1.week.from_now) } + + context 'when no Iteration dates overlap' do + let(:start_date) { 2.weeks.from_now } + let(:due_date) { 3.weeks.from_now } + + it { is_expected.to be_valid } + end + + context 'when dates overlap' do + context 'same group' do + context 'when start_date is in range' do + let(:start_date) { 5.days.from_now } + let(:due_date) { 3.weeks.from_now } + + it 'is not valid' do + expect(subject).not_to be_valid + expect(subject.errors[:base]).to include('Dates cannot overlap with other existing Iterations') + end + end + + context 'when end_date is in range' do + let(:start_date) { Time.now } + let(:due_date) { 6.days.from_now } + + it 'is not valid' do + expect(subject).not_to be_valid + expect(subject.errors[:base]).to include('Dates cannot overlap with other existing Iterations') + end + end + + context 'when both overlap' do + let(:start_date) { 5.days.from_now } + let(:due_date) { 6.days.from_now } + + it 'is not valid' do + expect(subject).not_to be_valid + expect(subject.errors[:base]).to include('Dates cannot overlap with other existing Iterations') + end + end + end + + context 'different group' do + let(:start_date) { 5.days.from_now } + let(:due_date) { 6.days.from_now } + let(:group) { create(:group) } + + it { is_expected.to be_valid } + end + end + end + + describe '#future_date' do + context 'when dates are in the future' do + let(:start_date) { Time.now } + let(:due_date) { 1.week.from_now } + + it { is_expected.to be_valid } + end + + context 'when start_date is in the past' do + let(:start_date) { 1.week.ago } + let(:due_date) { 1.week.from_now } + + it 'is not valid' do + expect(subject).not_to be_valid + expect(subject.errors[:start_date]).to include('cannot be in the past') + end + end + + context 'when due_date is in the past' do + let(:start_date) { Time.now } + let(:due_date) { 1.week.ago } + + it 'is not valid' do + expect(subject).not_to be_valid + expect(subject.errors[:due_date]).to include('cannot be in the past') + end + end + + context 'when start_date is over 500 years in the future' do + let(:start_date) { 501.years.from_now } + let(:due_date) { Time.now } + + it 'is not valid' do + expect(subject).not_to be_valid + expect(subject.errors[:start_date]).to include('cannot be more than 500 years in the future') + end + end + + context 'when due_date is over 500 years in the future' do + let(:start_date) { Time.now } + let(:due_date) { 501.years.from_now } + + it 'is not valid' do + expect(subject).not_to be_valid + expect(subject.errors[:due_date]).to include('cannot be more than 500 years in the future') + end + end + end + end + + describe '.within_timeframe' do + let_it_be(:now) { Time.now } + let_it_be(:project) { create(:project, :empty_repo) } + let_it_be(:iteration_1) { create(:iteration, project: project, start_date: now, due_date: 1.day.from_now) } + let_it_be(:iteration_2) { create(:iteration, project: project, start_date: 2.days.from_now, due_date: 3.days.from_now) } + let_it_be(:iteration_3) { create(:iteration, project: project, start_date: 4.days.from_now, due_date: 1.week.from_now) } + + it 'returns iterations with start_date and/or end_date between timeframe' do + iterations = described_class.within_timeframe(2.days.from_now, 3.days.from_now) + + expect(iterations).to match_array([iteration_2]) + end + + it 'returns iterations which starts before the timeframe' do + iterations = described_class.within_timeframe(1.day.from_now, 3.days.from_now) + + expect(iterations).to match_array([iteration_1, iteration_2]) + end + + it 'returns iterations which ends after the timeframe' do + iterations = described_class.within_timeframe(3.days.from_now, 5.days.from_now) + + expect(iterations).to match_array([iteration_2, iteration_3]) + end + end +end diff --git a/spec/models/jira_import_state_spec.rb b/spec/models/jira_import_state_spec.rb index 4d91bf25b5e..99f9e035205 100644 --- a/spec/models/jira_import_state_spec.rb +++ b/spec/models/jira_import_state_spec.rb @@ -124,6 +124,7 @@ describe JiraImportState do jira_import.schedule expect(jira_import.jid).to eq('some-job-id') + expect(jira_import.scheduled_at).to be_within(1.second).of(Time.now) end end diff --git a/spec/models/member_spec.rb b/spec/models/member_spec.rb index eeb2350359c..a8d864ad3f0 100644 --- a/spec/models/member_spec.rb +++ b/spec/models/member_spec.rb @@ -241,10 +241,22 @@ describe Member do expect(member).to be_persisted end - it 'sets members.created_by to the given current_user' do - member = described_class.add_user(source, user, :maintainer, current_user: admin) + context 'when admin mode is enabled', :enable_admin_mode do + it 'sets members.created_by to the given admin current_user' do + member = described_class.add_user(source, user, :maintainer, current_user: admin) - expect(member.created_by).to eq(admin) + expect(member.created_by).to eq(admin) + end + end + + context 'when admin mode is disabled' do + # Skipped because `Group#max_member_access_for_user` needs to be migrated to use admin mode + # https://gitlab.com/gitlab-org/gitlab/-/issues/207950 + xit 'rejects setting members.created_by to the given admin current_user' do + member = described_class.add_user(source, user, :maintainer, current_user: admin) + + expect(member.created_by).not_to be_persisted + end end it 'sets members.expires_at to the given expires_at' do @@ -353,7 +365,7 @@ describe Member do end end - context 'when current_user can update member' do + context 'when current_user can update member', :enable_admin_mode do it 'creates the member' do expect(source.users).not_to include(user) @@ -421,7 +433,7 @@ describe Member do end end - context 'when current_user can update member' do + context 'when current_user can update member', :enable_admin_mode do it 'updates the member' do expect(source.users).to include(user) diff --git a/spec/models/merge_request_diff_spec.rb b/spec/models/merge_request_diff_spec.rb index 016af4f269b..0839dde696a 100644 --- a/spec/models/merge_request_diff_spec.rb +++ b/spec/models/merge_request_diff_spec.rb @@ -3,6 +3,8 @@ require 'spec_helper' describe MergeRequestDiff do + using RSpec::Parameterized::TableSyntax + include RepoHelpers let(:diff_with_commits) { create(:merge_request).merge_request_diff } @@ -125,18 +127,71 @@ describe MergeRequestDiff do end end + describe '#update_external_diff_store' do + let_it_be(:merge_request) { create(:merge_request) } + + let(:diff) { merge_request.merge_request_diff } + let(:store) { diff.external_diff.object_store } + + where(:change_stored_externally, :change_external_diff) do + false | false + false | true + true | false + true | true + end + + with_them do + it do + diff.stored_externally = true if change_stored_externally + diff.external_diff = "new-filename" if change_external_diff + + update_store = receive(:update_column).with(:external_diff_store, store) + + if change_stored_externally || change_external_diff + expect(diff).to update_store + else + expect(diff).not_to update_store + end + + diff.save! + end + end + end + describe '#migrate_files_to_external_storage!' do + let(:uploader) { ExternalDiffUploader } + let(:file_store) { uploader::Store::LOCAL } + let(:remote_store) { uploader::Store::REMOTE } let(:diff) { create(:merge_request).merge_request_diff } - it 'converts from in-database to external storage' do + it 'converts from in-database to external file storage' do expect(diff).not_to be_stored_externally stub_external_diffs_setting(enabled: true) - expect(diff).to receive(:save!) + + expect(diff).to receive(:save!).and_call_original + + diff.migrate_files_to_external_storage! + + expect(diff).to be_stored_externally + expect(diff.external_diff_store).to eq(file_store) + end + + it 'converts from in-database to external object storage' do + expect(diff).not_to be_stored_externally + + stub_external_diffs_setting(enabled: true) + + # Without direct_upload: true, the files would be saved to disk, and a + # background job would be enqueued to move the file to object storage + stub_external_diffs_object_storage(uploader, direct_upload: true) + + expect(diff).to receive(:save!).and_call_original diff.migrate_files_to_external_storage! expect(diff).to be_stored_externally + expect(diff.external_diff_store).to eq(remote_store) end it 'does nothing with an external diff' do diff --git a/spec/models/merge_request_spec.rb b/spec/models/merge_request_spec.rb index cbb837c139e..fc4590f7b22 100644 --- a/spec/models/merge_request_spec.rb +++ b/spec/models/merge_request_spec.rb @@ -18,6 +18,10 @@ describe MergeRequest do it { is_expected.to have_many(:assignees).through(:merge_request_assignees) } 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) } context 'for forks' do let!(:project) { create(:project) } @@ -176,6 +180,8 @@ describe MergeRequest do it { is_expected.to include_module(Referable) } it { is_expected.to include_module(Sortable) } it { is_expected.to include_module(Taskable) } + it { is_expected.to include_module(MilestoneEventable) } + it { is_expected.to include_module(StateEventable) } it_behaves_like 'AtomicInternalId' do let(:internal_id_attribute) { :iid } @@ -1610,6 +1616,32 @@ describe MergeRequest do end end + describe '#has_accessibility_reports?' do + subject { merge_request.has_accessibility_reports? } + + let(:project) { create(:project, :repository) } + + context 'when head pipeline has an accessibility reports' do + let(:merge_request) { create(:merge_request, :with_accessibility_reports, source_project: project) } + + it { is_expected.to be_truthy } + + context 'when feature flag is disabled' do + before do + stub_feature_flags(accessibility_report_view: false) + end + + it { is_expected.to be_falsey } + end + end + + context 'when head pipeline does not have accessibility reports' do + let(:merge_request) { create(:merge_request, source_project: project) } + + it { is_expected.to be_falsey } + end + end + describe '#has_coverage_reports?' do subject { merge_request.has_coverage_reports? } @@ -1628,6 +1660,26 @@ describe MergeRequest do end end + describe '#has_terraform_reports?' do + let_it_be(:project) { create(:project, :repository) } + + context 'when head pipeline has terraform reports' do + it 'returns true' do + merge_request = create(:merge_request, :with_terraform_reports, source_project: project) + + expect(merge_request.has_terraform_reports?).to be_truthy + end + end + + context 'when head pipeline does not have terraform reports' do + it 'returns false' do + merge_request = create(:merge_request, source_project: project) + + expect(merge_request.has_terraform_reports?).to be_falsey + end + end + end + describe '#calculate_reactive_cache' do let(:project) { create(:project, :repository) } let(:merge_request) { create(:merge_request, source_project: project) } @@ -1837,6 +1889,62 @@ describe MergeRequest do end end + describe '#compare_accessibility_reports' do + let_it_be(:project) { create(:project, :repository) } + let_it_be(:merge_request, reload: true) { create(:merge_request, :with_accessibility_reports, source_project: project) } + let_it_be(:pipeline) { merge_request.head_pipeline } + + subject { merge_request.compare_accessibility_reports } + + context 'when head pipeline has accessibility reports' do + let(:job) do + create(:ci_build, options: { artifacts: { reports: { pa11y: ['accessibility.json'] } } }, pipeline: pipeline) + end + + let(:artifacts_metadata) { create(:ci_job_artifact, :metadata, job: job) } + + context 'when reactive cache worker is parsing results asynchronously' do + it 'returns parsing status' do + expect(subject[:status]).to eq(:parsing) + end + end + + context 'when reactive cache worker is inline' do + before do + synchronous_reactive_cache(merge_request) + end + + it 'returns parsed status' do + expect(subject[:status]).to eq(:parsed) + expect(subject[:data]).to be_present + end + + context 'when an error occurrs' do + before do + merge_request.update!(head_pipeline: nil) + end + + it 'returns an error status' do + expect(subject[:status]).to eq(:error) + expect(subject[:status_reason]).to eq("This merge request does not have accessibility reports") + end + end + + context 'when cached result is not latest' do + before do + allow_next_instance_of(Ci::CompareAccessibilityReportsService) do |service| + allow(service).to receive(:latest?).and_return(false) + end + end + + it 'raises an InvalidateReactiveCache error' do + expect { subject }.to raise_error(ReactiveCaching::InvalidateReactiveCache) + end + end + end + end + end + describe '#all_commit_shas' do context 'when merge request is persisted' do let(:all_commit_shas) do @@ -3678,41 +3786,41 @@ describe MergeRequest do describe '#recent_visible_deployments' do let(:merge_request) { create(:merge_request) } - let(:environment) do - create(:environment, project: merge_request.target_project) - end - it 'returns visible deployments' do + envs = create_list(:environment, 3, project: merge_request.target_project) + created = create( :deployment, :created, project: merge_request.target_project, - environment: environment + environment: envs[0] ) success = create( :deployment, :success, project: merge_request.target_project, - environment: environment + environment: envs[1] ) failed = create( :deployment, :failed, project: merge_request.target_project, - environment: environment + environment: envs[2] ) - merge_request.deployment_merge_requests.create!(deployment: created) - merge_request.deployment_merge_requests.create!(deployment: success) - merge_request.deployment_merge_requests.create!(deployment: failed) + merge_request_relation = MergeRequest.where(id: merge_request.id) + created.link_merge_requests(merge_request_relation) + success.link_merge_requests(merge_request_relation) + failed.link_merge_requests(merge_request_relation) expect(merge_request.recent_visible_deployments).to eq([failed, success]) end it 'only returns a limited number of deployments' do 20.times do + environment = create(:environment, project: merge_request.target_project) deploy = create( :deployment, :success, @@ -3720,7 +3828,7 @@ describe MergeRequest do environment: environment ) - merge_request.deployment_merge_requests.create!(deployment: deploy) + deploy.link_merge_requests(MergeRequest.where(id: merge_request.id)) end expect(merge_request.recent_visible_deployments.count).to eq(10) @@ -3728,40 +3836,28 @@ describe MergeRequest do end describe '#diffable_merge_ref?' do - context 'diff_compare_with_head enabled' do - context 'merge request can be merged' do - context 'merge_to_ref is not calculated' do - it 'returns true' do - expect(subject.diffable_merge_ref?).to eq(false) - end - end - - context 'merge_to_ref is calculated' do - before do - MergeRequests::MergeToRefService.new(subject.project, subject.author).execute(subject) - end - - it 'returns true' do - expect(subject.diffable_merge_ref?).to eq(true) - end + context 'merge request can be merged' do + context 'merge_to_ref is not calculated' do + it 'returns true' do + expect(subject.diffable_merge_ref?).to eq(false) end end - context 'merge request cannot be merged' do - it 'returns false' do - subject.mark_as_unchecked! + context 'merge_to_ref is calculated' do + before do + MergeRequests::MergeToRefService.new(subject.project, subject.author).execute(subject) + end - expect(subject.diffable_merge_ref?).to eq(false) + it 'returns true' do + expect(subject.diffable_merge_ref?).to eq(true) end end end - context 'diff_compare_with_head disabled' do - before do - stub_feature_flags(diff_compare_with_head: { enabled: false, thing: subject.target_project }) - end - + context 'merge request cannot be merged' do it 'returns false' do + subject.mark_as_unchecked! + expect(subject.diffable_merge_ref?).to eq(false) end end diff --git a/spec/models/metrics/users_starred_dashboard_spec.rb b/spec/models/metrics/users_starred_dashboard_spec.rb new file mode 100644 index 00000000000..6cb14ae569e --- /dev/null +++ b/spec/models/metrics/users_starred_dashboard_spec.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Metrics::UsersStarredDashboard do + describe 'associations' do + it { is_expected.to belong_to(:project).inverse_of(:metrics_users_starred_dashboards) } + it { is_expected.to belong_to(:user).inverse_of(:metrics_users_starred_dashboards) } + end + + describe 'validation' do + subject { build(:metrics_users_starred_dashboard) } + + it { is_expected.to validate_presence_of(:user_id) } + it { is_expected.to validate_presence_of(:project_id) } + it { is_expected.to validate_presence_of(:dashboard_path) } + it { is_expected.to validate_length_of(:dashboard_path).is_at_most(255) } + it { is_expected.to validate_uniqueness_of(:dashboard_path).scoped_to(%i[user_id project_id]) } + end + + context 'scopes' do + let_it_be(:project) { create(:project) } + let_it_be(:starred_dashboard_a) { create(:metrics_users_starred_dashboard, project: project, dashboard_path: 'path_a') } + let_it_be(:starred_dashboard_b) { create(:metrics_users_starred_dashboard, project: project, dashboard_path: 'path_b') } + let_it_be(:starred_dashboard_c) { create(:metrics_users_starred_dashboard, dashboard_path: 'path_b') } + + describe '#for_project' do + it 'selects only starred dashboards belonging to project' do + expect(described_class.for_project(project)).to contain_exactly starred_dashboard_a, starred_dashboard_b + end + end + + describe '#for_project_dashboard' do + it 'selects only starred dashboards belonging to project with given dashboard path' do + expect(described_class.for_project_dashboard(project, 'path_b')).to contain_exactly starred_dashboard_b + end + end + end +end diff --git a/spec/models/milestone_note_spec.rb b/spec/models/milestone_note_spec.rb index 9e77ef91bb2..aad65cf0346 100644 --- a/spec/models/milestone_note_spec.rb +++ b/spec/models/milestone_note_spec.rb @@ -14,5 +14,15 @@ describe MilestoneNote do it_behaves_like 'a system note', exclude_project: true do let(:action) { 'milestone' } end + + context 'with a remove milestone event' do + let(:milestone) { create(:milestone) } + let(:event) { create(:resource_milestone_event, action: :remove, issue: noteable, milestone: milestone) } + + it 'creates the expected note' do + expect(subject.note_html).to include('removed milestone') + expect(subject.note_html).not_to include('changed milestone to') + end + end end end diff --git a/spec/models/milestone_spec.rb b/spec/models/milestone_spec.rb index ee4c35ebddd..e51108947a7 100644 --- a/spec/models/milestone_spec.rb +++ b/spec/models/milestone_spec.rb @@ -3,8 +3,10 @@ require 'spec_helper' describe Milestone do + it_behaves_like 'a timebox', :milestone + describe 'MilestoneStruct#serializable_hash' do - let(:predefined_milestone) { described_class::MilestoneStruct.new('Test Milestone', '#test', 1) } + let(:predefined_milestone) { described_class::TimeboxStruct.new('Test Milestone', '#test', 1) } it 'presents the predefined milestone as a hash' do expect(predefined_milestone.serializable_hash).to eq( @@ -15,69 +17,11 @@ describe Milestone do end end - describe 'modules' do - context 'with a project' do - it_behaves_like 'AtomicInternalId' do - let(:internal_id_attribute) { :iid } - let(:instance) { build(:milestone, project: build(:project), group: nil) } - let(:scope) { :project } - let(:scope_attrs) { { project: instance.project } } - let(:usage) { :milestones } - end - end - - context 'with a group' do - it_behaves_like 'AtomicInternalId' do - let(:internal_id_attribute) { :iid } - let(:instance) { build(:milestone, project: nil, group: build(:group)) } - let(:scope) { :group } - let(:scope_attrs) { { namespace: instance.group } } - let(:usage) { :milestones } - end - end - end - describe "Validation" do before do allow(subject).to receive(:set_iid).and_return(false) end - describe 'start_date' do - it 'adds an error when start_date is greater then due_date' do - milestone = build(:milestone, start_date: Date.tomorrow, due_date: Date.yesterday) - - expect(milestone).not_to be_valid - expect(milestone.errors[:due_date]).to include("must be greater than start date") - end - - it 'adds an error when start_date is greater than 9999-12-31' do - milestone = build(:milestone, start_date: Date.new(10000, 1, 1)) - - expect(milestone).not_to be_valid - expect(milestone.errors[:start_date]).to include("date must not be after 9999-12-31") - end - end - - describe 'due_date' do - it 'adds an error when due_date is greater than 9999-12-31' do - milestone = build(:milestone, due_date: Date.new(10000, 1, 1)) - - expect(milestone).not_to be_valid - expect(milestone.errors[:due_date]).to include("date must not be after 9999-12-31") - end - end - - describe 'title' do - it { is_expected.to validate_presence_of(:title) } - - it 'is invalid if title would be empty after sanitation' do - milestone = build(:milestone, project: project, title: '<img src=x onerror=prompt(1)>') - - expect(milestone).not_to be_valid - expect(milestone.errors[:title]).to include("can't be blank") - end - end - describe 'milestone_releases' do let(:milestone) { build(:milestone, project: project) } @@ -99,8 +43,6 @@ describe Milestone do end describe "Associations" do - it { is_expected.to belong_to(:project) } - it { is_expected.to have_many(:issues) } it { is_expected.to have_many(:releases) } it { is_expected.to have_many(:milestone_releases) } end @@ -110,87 +52,6 @@ describe Milestone do let(:issue) { create(:issue, project: project) } let(:user) { create(:user) } - describe "#title" do - let(:milestone) { create(:milestone, title: "<b>foo & bar -> 2.2</b>") } - - it "sanitizes title" do - expect(milestone.title).to eq("foo & bar -> 2.2") - end - end - - describe '#merge_requests_enabled?' do - context "per project" do - it "is true for projects with MRs enabled" do - project = create(:project, :merge_requests_enabled) - milestone = create(:milestone, project: project) - - expect(milestone.merge_requests_enabled?).to be(true) - end - - it "is false for projects with MRs disabled" do - project = create(:project, :repository_enabled, :merge_requests_disabled) - milestone = create(:milestone, project: project) - - expect(milestone.merge_requests_enabled?).to be(false) - end - - it "is false for projects with repository disabled" do - project = create(:project, :repository_disabled) - milestone = create(:milestone, project: project) - - expect(milestone.merge_requests_enabled?).to be(false) - end - end - - context "per group" do - let(:group) { create(:group) } - let(:milestone) { create(:milestone, group: group) } - - it "is always true for groups, for performance reasons" do - expect(milestone.merge_requests_enabled?).to be(true) - end - end - end - - describe "unique milestone title" do - context "per project" do - it "does not accept the same title in a project twice" do - new_milestone = described_class.new(project: milestone.project, title: milestone.title) - expect(new_milestone).not_to be_valid - end - - it "accepts the same title in another project" do - project = create(:project) - new_milestone = described_class.new(project: project, title: milestone.title) - - expect(new_milestone).to be_valid - end - end - - context "per group" do - let(:group) { create(:group) } - let(:milestone) { create(:milestone, group: group) } - - before do - project.update(group: group) - end - - it "does not accept the same title in a group twice" do - new_milestone = described_class.new(group: group, title: milestone.title) - - expect(new_milestone).not_to be_valid - end - - it "does not accept the same title of a child project milestone" do - create(:milestone, project: group.projects.first) - - new_milestone = described_class.new(group: group, title: milestone.title) - - expect(new_milestone).not_to be_valid - end - end - end - describe '.predefined_id?' do it 'returns true for a predefined Milestone ID' do expect(Milestone.predefined_id?(described_class::Upcoming.id)).to be true @@ -619,4 +480,22 @@ describe Milestone do it { is_expected.not_to match("#{Gitlab.config.gitlab.url}/gitlab-org/gitlab-foss/issues/123") } it { is_expected.not_to match("gitlab-org/gitlab-ce/milestones/123") } end + + describe '#parent' do + context 'with group' do + it 'returns the expected parent' do + group = create(:group) + + expect(build(:milestone, group: group).parent).to eq(group) + end + end + + context 'with project' do + it 'returns the expected parent' do + project = create(:project) + + expect(build(:milestone, project: project).parent).to eq(project) + end + end + end end diff --git a/spec/models/namespace/root_storage_size_spec.rb b/spec/models/namespace/root_storage_size_spec.rb new file mode 100644 index 00000000000..a8048b7f637 --- /dev/null +++ b/spec/models/namespace/root_storage_size_spec.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Namespace::RootStorageSize, type: :model do + let(:namespace) { create(:namespace) } + let(:current_size) { 50.megabytes } + let(:limit) { 100 } + let(:model) { described_class.new(namespace) } + let(:create_statistics) { create(:namespace_root_storage_statistics, namespace: namespace, storage_size: current_size)} + + before do + create_statistics + + stub_application_setting(namespace_storage_size_limit: limit) + end + + describe '#above_size_limit?' do + subject { model.above_size_limit? } + + context 'when limit is 0' do + let(:limit) { 0 } + + it { is_expected.to eq(false) } + end + + context 'when below limit' do + it { is_expected.to eq(false) } + end + + context 'when above limit' do + let(:current_size) { 101.megabytes } + + it { is_expected.to eq(true) } + end + end + + describe '#usage_ratio' do + subject { model.usage_ratio } + + it { is_expected.to eq(0.5) } + + context 'when limit is 0' do + let(:limit) { 0 } + + it { is_expected.to eq(0) } + end + + context 'when there are no root_storage_statistics' do + let(:create_statistics) { nil } + + it { is_expected.to eq(0) } + end + end + + describe '#current_size' do + subject { model.current_size } + + it { is_expected.to eq(current_size) } + end + + describe '#limit' do + subject { model.limit } + + it { is_expected.to eq(limit.megabytes) } + end +end diff --git a/spec/models/note_spec.rb b/spec/models/note_spec.rb index 74ec74e0def..6dd295ca915 100644 --- a/spec/models/note_spec.rb +++ b/spec/models/note_spec.rb @@ -105,6 +105,38 @@ describe Note do end end + describe 'callbacks' do + describe '#notify_after_create' do + it 'calls #after_note_created on the noteable' do + note = build(:note) + + expect(note).to receive(:notify_after_create).and_call_original + expect(note.noteable).to receive(:after_note_created).with(note) + + note.save! + end + end + + describe '#notify_after_destroy' do + it 'calls #after_note_destroyed on the noteable' do + note = create(:note) + + expect(note).to receive(:notify_after_destroy).and_call_original + expect(note.noteable).to receive(:after_note_destroyed).with(note) + + note.destroy + end + + it 'does not error if noteable is nil' do + note = create(:note) + + expect(note).to receive(:notify_after_destroy).and_call_original + expect(note).to receive(:noteable).at_least(:once).and_return(nil) + expect { note.destroy }.not_to raise_error + end + end + end + describe "Commit notes" do before do allow(Gitlab::Git::KeepAround).to receive(:execute).and_call_original @@ -751,6 +783,14 @@ describe Note do end end + describe '#for_design' do + it 'is true when the noteable is a design' do + note = build(:note, noteable: build(:design)) + + expect(note).to be_for_design + end + end + describe '#to_ability_name' do it 'returns note' do expect(build(:note).to_ability_name).to eq('note') diff --git a/spec/models/pages_domain_spec.rb b/spec/models/pages_domain_spec.rb index fa2648979e9..54747ddf525 100644 --- a/spec/models/pages_domain_spec.rb +++ b/spec/models/pages_domain_spec.rb @@ -620,7 +620,11 @@ describe PagesDomain do create(:pages_domain, :letsencrypt, :with_expired_certificate) end - it 'contains only domains needing verification' do + let!(:domain_with_failed_auto_ssl) do + create(:pages_domain, auto_ssl_enabled: true, auto_ssl_failed: true) + end + + it 'contains only domains needing ssl renewal' do is_expected.to( contain_exactly( domain_with_user_provided_certificate_and_auto_ssl, diff --git a/spec/models/performance_monitoring/prometheus_dashboard_spec.rb b/spec/models/performance_monitoring/prometheus_dashboard_spec.rb new file mode 100644 index 00000000000..e6fc03a0fb6 --- /dev/null +++ b/spec/models/performance_monitoring/prometheus_dashboard_spec.rb @@ -0,0 +1,102 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe PerformanceMonitoring::PrometheusDashboard do + let(:json_content) do + { + "dashboard" => "Dashboard Title", + "templating" => { + "variables" => { + "variable1" => %w(value1 value2 value3) + } + }, + "panel_groups" => [{ + "group" => "Group Title", + "panels" => [{ + "type" => "area-chart", + "title" => "Chart Title", + "y_label" => "Y-Axis", + "metrics" => [{ + "id" => "metric_of_ages", + "unit" => "count", + "label" => "Metric of Ages", + "query_range" => "http_requests_total" + }] + }] + }] + } + end + + describe '.from_json' do + subject { described_class.from_json(json_content) } + + it 'creates a PrometheusDashboard object' do + expect(subject).to be_a PerformanceMonitoring::PrometheusDashboard + expect(subject.dashboard).to eq(json_content['dashboard']) + expect(subject.panel_groups).to all(be_a PerformanceMonitoring::PrometheusPanelGroup) + end + + describe 'validations' do + context 'when dashboard is missing' do + before do + json_content['dashboard'] = nil + end + + subject { described_class.from_json(json_content) } + + it { expect { subject }.to raise_error(ActiveModel::ValidationError) } + end + + context 'when panel groups are missing' do + before do + json_content['panel_groups'] = [] + end + + subject { described_class.from_json(json_content) } + + it { expect { subject }.to raise_error(ActiveModel::ValidationError) } + end + end + end + + describe '.find_for' do + let(:project) { build_stubbed(:project) } + let(:user) { build_stubbed(:user) } + let(:environment) { build_stubbed(:environment) } + let(:path) { ::Metrics::Dashboard::SystemDashboardService::DASHBOARD_PATH } + + context 'dashboard has been found' do + it 'uses dashboard finder to find and load dashboard data and returns dashboard instance', :aggregate_failures do + expect(Gitlab::Metrics::Dashboard::Finder).to receive(:find).with(project, user, environment: environment, dashboard_path: path).and_return(status: :success, dashboard: json_content) + + dashboard_instance = described_class.find_for(project: project, user: user, path: path, options: { environment: environment }) + + expect(dashboard_instance).to be_instance_of described_class + expect(dashboard_instance.environment).to be environment + expect(dashboard_instance.path).to be path + end + end + + context 'dashboard has NOT been found' do + it 'returns nil' do + allow(Gitlab::Metrics::Dashboard::Finder).to receive(:find).and_return(status: :error) + + dashboard_instance = described_class.find_for(project: project, user: user, path: path, options: { environment: environment }) + + expect(dashboard_instance).to be_nil + end + end + end + + describe '#to_yaml' do + subject { prometheus_dashboard.to_yaml } + + let(:prometheus_dashboard) { described_class.from_json(json_content) } + let(:expected_yaml) do + "---\npanel_groups:\n- panels:\n - metrics:\n - id: metric_of_ages\n unit: count\n label: Metric of Ages\n query: \n query_range: http_requests_total\n type: area-chart\n title: Chart Title\n y_label: Y-Axis\n weight: \n group: Group Title\n priority: \ndashboard: Dashboard Title\n" + end + + it { is_expected.to eq(expected_yaml) } + end +end diff --git a/spec/models/performance_monitoring/prometheus_metric_spec.rb b/spec/models/performance_monitoring/prometheus_metric_spec.rb new file mode 100644 index 00000000000..08288e5d993 --- /dev/null +++ b/spec/models/performance_monitoring/prometheus_metric_spec.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe PerformanceMonitoring::PrometheusMetric do + let(:json_content) do + { + "id" => "metric_of_ages", + "unit" => "count", + "label" => "Metric of Ages", + "query_range" => "http_requests_total" + } + end + + describe '.from_json' do + subject { described_class.from_json(json_content) } + + it 'creates a PrometheusMetric object' do + expect(subject).to be_a PerformanceMonitoring::PrometheusMetric + expect(subject.id).to eq(json_content['id']) + expect(subject.unit).to eq(json_content['unit']) + expect(subject.label).to eq(json_content['label']) + expect(subject.query_range).to eq(json_content['query_range']) + end + + describe 'validations' do + context 'when unit is missing' do + before do + json_content['unit'] = nil + end + + subject { described_class.from_json(json_content) } + + it { expect { subject }.to raise_error(ActiveModel::ValidationError) } + end + + context 'when query and query_range is missing' do + before do + json_content['query_range'] = nil + end + + subject { described_class.from_json(json_content) } + + it { expect { subject }.to raise_error(ActiveModel::ValidationError) } + end + + context 'when query_range is missing but query is available' do + before do + json_content['query_range'] = nil + json_content['query'] = 'http_requests_total' + end + + subject { described_class.from_json(json_content) } + + it { is_expected.to be_valid } + end + end + end +end diff --git a/spec/models/performance_monitoring/prometheus_panel_group_spec.rb b/spec/models/performance_monitoring/prometheus_panel_group_spec.rb new file mode 100644 index 00000000000..2447bb5df94 --- /dev/null +++ b/spec/models/performance_monitoring/prometheus_panel_group_spec.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe PerformanceMonitoring::PrometheusPanelGroup do + let(:json_content) do + { + "group" => "Group Title", + "panels" => [{ + "type" => "area-chart", + "title" => "Chart Title", + "y_label" => "Y-Axis", + "metrics" => [{ + "id" => "metric_of_ages", + "unit" => "count", + "label" => "Metric of Ages", + "query_range" => "http_requests_total" + }] + }] + } + end + + describe '.from_json' do + subject { described_class.from_json(json_content) } + + it 'creates a PrometheusPanelGroup object' do + expect(subject).to be_a PerformanceMonitoring::PrometheusPanelGroup + expect(subject.group).to eq(json_content['group']) + expect(subject.panels).to all(be_a PerformanceMonitoring::PrometheusPanel) + end + + describe 'validations' do + context 'when group is missing' do + before do + json_content['group'] = nil + end + + subject { described_class.from_json(json_content) } + + it { expect { subject }.to raise_error(ActiveModel::ValidationError) } + end + + context 'when panels are missing' do + before do + json_content['panels'] = [] + end + + subject { described_class.from_json(json_content) } + + it { expect { subject }.to raise_error(ActiveModel::ValidationError) } + end + end + end +end diff --git a/spec/models/performance_monitoring/prometheus_panel_spec.rb b/spec/models/performance_monitoring/prometheus_panel_spec.rb new file mode 100644 index 00000000000..f5e04ec91e2 --- /dev/null +++ b/spec/models/performance_monitoring/prometheus_panel_spec.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe PerformanceMonitoring::PrometheusPanel do + let(:json_content) do + { + "max_value" => 1, + "type" => "area-chart", + "title" => "Chart Title", + "y_label" => "Y-Axis", + "weight" => 1, + "metrics" => [{ + "id" => "metric_of_ages", + "unit" => "count", + "label" => "Metric of Ages", + "query_range" => "http_requests_total" + }] + } + end + + describe '#new' do + it 'accepts old schema format' do + expect { described_class.new(json_content) }.not_to raise_error + end + + it 'accepts new schema format' do + expect { described_class.new(json_content.merge("y_axis" => { "precision" => 0 })) }.not_to raise_error + end + end + + describe '.from_json' do + subject { described_class.from_json(json_content) } + + it 'creates a PrometheusPanelGroup object' do + expect(subject).to be_a PerformanceMonitoring::PrometheusPanel + expect(subject.type).to eq(json_content['type']) + expect(subject.title).to eq(json_content['title']) + expect(subject.y_label).to eq(json_content['y_label']) + expect(subject.weight).to eq(json_content['weight']) + expect(subject.metrics).to all(be_a PerformanceMonitoring::PrometheusMetric) + end + + describe 'validations' do + context 'when title is missing' do + before do + json_content['title'] = nil + end + + subject { described_class.from_json(json_content) } + + it { expect { subject }.to raise_error(ActiveModel::ValidationError) } + end + + context 'when metrics are missing' do + before do + json_content['metrics'] = [] + end + + subject { described_class.from_json(json_content) } + + it { expect { subject }.to raise_error(ActiveModel::ValidationError) } + end + end + end + + describe '.id' do + it 'returns hexdigest of group_title, type and title as the panel id' do + group_title = 'Business Group' + panel_type = 'area-chart' + panel_title = 'New feature requests made' + + expect(Digest::SHA2).to receive(:hexdigest).with("#{group_title}#{panel_type}#{panel_title}").and_return('hexdigest') + expect(described_class.new(title: panel_title, type: panel_type).id(group_title)).to eql 'hexdigest' + end + end +end diff --git a/spec/models/personal_access_token_spec.rb b/spec/models/personal_access_token_spec.rb index b16d1f58be5..596b11613b3 100644 --- a/spec/models/personal_access_token_spec.rb +++ b/spec/models/personal_access_token_spec.rb @@ -179,4 +179,27 @@ describe PersonalAccessToken do end end end + + describe '.simple_sorts' do + it 'includes overriden keys' do + expect(described_class.simple_sorts.keys).to include(*%w(expires_at_asc expires_at_desc)) + end + end + + describe 'ordering by expires_at' do + let_it_be(:earlier_token) { create(:personal_access_token, expires_at: 2.days.ago) } + let_it_be(:later_token) { create(:personal_access_token, expires_at: 1.day.ago) } + + describe '.order_expires_at_asc' do + it 'returns ordered list in asc order of expiry date' do + expect(described_class.order_expires_at_asc).to match [earlier_token, later_token] + end + end + + describe '.order_expires_at_desc' do + it 'returns ordered list in desc order of expiry date' do + expect(described_class.order_expires_at_desc).to match [later_token, earlier_token] + end + end + end end diff --git a/spec/models/personal_snippet_spec.rb b/spec/models/personal_snippet_spec.rb index a055f107e33..fb96d6e8bc3 100644 --- a/spec/models/personal_snippet_spec.rb +++ b/spec/models/personal_snippet_spec.rb @@ -22,5 +22,6 @@ describe PersonalSnippet do let(:stubbed_container) { build_stubbed(:personal_snippet) } let(:expected_full_path) { "@snippets/#{container.id}" } let(:expected_web_url_path) { "snippets/#{container.id}" } + let(:expected_repo_url_path) { expected_web_url_path } end end diff --git a/spec/models/plan_limits_spec.rb b/spec/models/plan_limits_spec.rb new file mode 100644 index 00000000000..1366f088623 --- /dev/null +++ b/spec/models/plan_limits_spec.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe PlanLimits do + let(:plan_limits) { create(:plan_limits) } + let(:model) { ProjectHook } + let(:count) { model.count } + + before do + create(:project_hook) + end + + context 'without plan limits configured' do + describe '#exceeded?' do + it 'does not exceed any relation offset' do + expect(plan_limits.exceeded?(:project_hooks, model)).to be false + expect(plan_limits.exceeded?(:project_hooks, count)).to be false + end + end + end + + context 'with plan limits configured' do + before do + plan_limits.update!(project_hooks: 2) + end + + describe '#exceeded?' do + it 'does not exceed the relation offset' do + expect(plan_limits.exceeded?(:project_hooks, model)).to be false + expect(plan_limits.exceeded?(:project_hooks, count)).to be false + end + end + + context 'with boundary values' do + before do + create(:project_hook) + end + + describe '#exceeded?' do + it 'does exceed the relation offset' do + expect(plan_limits.exceeded?(:project_hooks, model)).to be true + expect(plan_limits.exceeded?(:project_hooks, count)).to be true + end + end + end + end + + context 'validates default values' do + let(:columns_with_zero) do + %w[ + ci_active_pipelines + ci_pipeline_size + ci_active_jobs + ] + end + + it "has positive values for enabled limits" do + attributes = plan_limits.attributes + attributes = attributes.except(described_class.primary_key) + attributes = attributes.except(described_class.reflections.values.map(&:foreign_key)) + attributes = attributes.except(*columns_with_zero) + + expect(attributes).to all(include(be_positive)) + end + + it "has zero values for disabled limits" do + attributes = plan_limits.attributes + attributes = attributes.slice(*columns_with_zero) + + expect(attributes).to all(include(be_zero)) + end + end +end diff --git a/spec/models/plan_spec.rb b/spec/models/plan_spec.rb new file mode 100644 index 00000000000..3f3b8046232 --- /dev/null +++ b/spec/models/plan_spec.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Plan do + describe '#default?' do + subject { plan.default? } + + Plan.default_plans.each do |plan| + context "when '#{plan}'" do + let(:plan) { build("#{plan}_plan".to_sym) } + + it { is_expected.to be_truthy } + end + end + end +end diff --git a/spec/models/project_ci_cd_setting_spec.rb b/spec/models/project_ci_cd_setting_spec.rb index 312cbbb0948..86115a61aa7 100644 --- a/spec/models/project_ci_cd_setting_spec.rb +++ b/spec/models/project_ci_cd_setting_spec.rb @@ -54,17 +54,5 @@ describe ProjectCiCdSetting do expect(project.reload.ci_cd_settings.default_git_depth).to eq(0) end - - context 'when feature flag :ci_set_project_default_git_depth is disabled' do - let(:project) { create(:project) } - - before do - stub_feature_flags(ci_set_project_default_git_depth: { enabled: false } ) - end - - it 'does not set default value for new records' do - expect(project.ci_cd_settings.default_git_depth).to eq(nil) - end - end end end diff --git a/spec/models/project_feature_spec.rb b/spec/models/project_feature_spec.rb index 38fba5ea071..e072cc21b38 100644 --- a/spec/models/project_feature_spec.rb +++ b/spec/models/project_feature_spec.rb @@ -31,27 +31,30 @@ describe ProjectFeature do context 'when features are disabled' do it "returns false" do + update_all_project_features(project, features, ProjectFeature::DISABLED) + features.each do |feature| - project.project_feature.update_attribute("#{feature}_access_level".to_sym, ProjectFeature::DISABLED) - expect(project.feature_available?(:issues, user)).to eq(false) + expect(project.feature_available?(feature.to_sym, user)).to eq(false), "#{feature} failed" end end end context 'when features are enabled only for team members' do it "returns false when user is not a team member" do + update_all_project_features(project, features, ProjectFeature::PRIVATE) + features.each do |feature| - project.project_feature.update_attribute("#{feature}_access_level".to_sym, ProjectFeature::PRIVATE) - expect(project.feature_available?(:issues, user)).to eq(false) + expect(project.feature_available?(feature.to_sym, user)).to eq(false), "#{feature} failed" end end it "returns true when user is a team member" do project.add_developer(user) + update_all_project_features(project, features, ProjectFeature::PRIVATE) + features.each do |feature| - project.project_feature.update_attribute("#{feature}_access_level".to_sym, ProjectFeature::PRIVATE) - expect(project.feature_available?(:issues, user)).to eq(true) + expect(project.feature_available?(feature.to_sym, user)).to eq(true), "#{feature} failed" end end @@ -60,27 +63,41 @@ describe ProjectFeature do project = create(:project, namespace: group) group.add_developer(user) + update_all_project_features(project, features, ProjectFeature::PRIVATE) + features.each do |feature| - project.project_feature.update_attribute("#{feature}_access_level".to_sym, ProjectFeature::PRIVATE) - expect(project.feature_available?(:issues, user)).to eq(true) + expect(project.feature_available?(feature.to_sym, user)).to eq(true), "#{feature} failed" end end - it "returns true if user is an admin" do - user.update_attribute(:admin, true) + context 'when admin mode is enabled', :enable_admin_mode do + it "returns true if user is an admin" do + user.update_attribute(:admin, true) - features.each do |feature| - project.project_feature.update_attribute("#{feature}_access_level".to_sym, ProjectFeature::PRIVATE) - expect(project.feature_available?(:issues, user)).to eq(true) + update_all_project_features(project, features, ProjectFeature::PRIVATE) + + features.each do |feature| + expect(project.feature_available?(feature.to_sym, user)).to eq(true), "#{feature} failed" + end + end + end + + context 'when admin mode is disabled' do + it "returns false when user is an admin" do + user.update_attribute(:admin, true) + + update_all_project_features(project, features, ProjectFeature::PRIVATE) + + features.each do |feature| + expect(project.feature_available?(feature.to_sym, user)).to eq(false), "#{feature} failed" + end end end end context 'when feature is enabled for everyone' do it "returns true" do - features.each do |feature| - expect(project.feature_available?(:issues, user)).to eq(true) - end + expect(project.feature_available?(:issues, user)).to eq(true) end end @@ -117,7 +134,7 @@ describe ProjectFeature do features.each do |feature| field = "#{feature}_access_level".to_sym project_feature.update_attribute(field, ProjectFeature::ENABLED) - expect(project_feature.valid?).to be_falsy + expect(project_feature.valid?).to be_falsy, "#{field} failed" end end end @@ -131,7 +148,7 @@ describe ProjectFeature do field = "#{feature}_access_level".to_sym project_feature.update_attribute(field, ProjectFeature::PUBLIC) - expect(project_feature.valid?).to be_falsy + expect(project_feature.valid?).to be_falsy, "#{field} failed" end end end @@ -140,22 +157,24 @@ describe ProjectFeature do let(:features) { %w(wiki builds merge_requests) } it "returns false when feature is disabled" do + update_all_project_features(project, features, ProjectFeature::DISABLED) + features.each do |feature| - project.project_feature.update_attribute("#{feature}_access_level".to_sym, ProjectFeature::DISABLED) - expect(project.public_send("#{feature}_enabled?")).to eq(false) + expect(project.public_send("#{feature}_enabled?")).to eq(false), "#{feature} failed" end end it "returns true when feature is enabled only for team members" do + update_all_project_features(project, features, ProjectFeature::PRIVATE) + features.each do |feature| - project.project_feature.update_attribute("#{feature}_access_level".to_sym, ProjectFeature::PRIVATE) - expect(project.public_send("#{feature}_enabled?")).to eq(true) + expect(project.public_send("#{feature}_enabled?")).to eq(true), "#{feature} failed" end end it "returns true when feature is enabled for everyone" do features.each do |feature| - expect(project.public_send("#{feature}_enabled?")).to eq(true) + expect(project.public_send("#{feature}_enabled?")).to eq(true), "#{feature} failed" end end end @@ -198,7 +217,7 @@ describe ProjectFeature do end describe '#public_pages?' do - it 'returns true if Pages access controll is not enabled' do + it 'returns true if Pages access control is not enabled' do stub_config(pages: { access_control: false }) project_feature = described_class.new(pages_access_level: described_class::PRIVATE) @@ -281,7 +300,7 @@ describe ProjectFeature do it 'raises error if feature is invalid' do expect do described_class.required_minimum_access_level(:foos) - end.to raise_error + end.to raise_error(ArgumentError) end end @@ -294,4 +313,9 @@ describe ProjectFeature do expect(described_class.required_minimum_access_level_for_private_project(:issues)).to eq(Gitlab::Access::GUEST) end end + + def update_all_project_features(project, features, value) + project_feature_attributes = features.map { |f| ["#{f}_access_level", value] }.to_h + project.project_feature.update(project_feature_attributes) + end end diff --git a/spec/models/project_repository_storage_move_spec.rb b/spec/models/project_repository_storage_move_spec.rb new file mode 100644 index 00000000000..146fc13bee0 --- /dev/null +++ b/spec/models/project_repository_storage_move_spec.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe ProjectRepositoryStorageMove, type: :model 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(:state) } + it { is_expected.to validate_presence_of(:source_storage_name) } + it { is_expected.to validate_presence_of(:destination_storage_name) } + + context 'source_storage_name inclusion' do + subject { build(:project_repository_storage_move, source_storage_name: 'missing') } + + it "does not allow repository storages that don't match a label in the configuration" do + expect(subject).not_to be_valid + expect(subject.errors[:source_storage_name].first).to match(/is not included in the list/) + end + end + + context 'destination_storage_name inclusion' do + subject { build(:project_repository_storage_move, destination_storage_name: 'missing') } + + it "does not allow repository storages that don't match a label in the configuration" do + expect(subject).not_to be_valid + expect(subject.errors[:destination_storage_name].first).to match(/is not included in the list/) + end + end + end + + describe 'state transitions' do + using RSpec::Parameterized::TableSyntax + + context 'when in the default state' do + subject(:storage_move) { create(:project_repository_storage_move, project: project, destination_storage_name: 'test_second_storage') } + + let(:project) { create(:project) } + + before do + stub_storage_settings('test_second_storage' => { 'path' => 'tmp/tests/extra_storage' }) + end + + context 'and transits to scheduled' do + it 'triggers ProjectUpdateRepositoryStorageWorker' do + expect(ProjectUpdateRepositoryStorageWorker).to receive(:perform_async).with(project.id, 'test_second_storage', storage_move.id) + + storage_move.schedule! + end + end + + context 'and transits to started' do + it 'does not allow the transition' do + expect { storage_move.start! } + .to raise_error(StateMachines::InvalidTransition) + end + end + end + end +end diff --git a/spec/models/project_services/chat_message/pipeline_message_spec.rb b/spec/models/project_services/chat_message/pipeline_message_spec.rb index e99148d1d1f..7c3e48f572a 100644 --- a/spec/models/project_services/chat_message/pipeline_message_spec.rb +++ b/spec/models/project_services/chat_message/pipeline_message_spec.rb @@ -55,475 +55,324 @@ describe ChatMessage::PipelineMessage do allow(Gitlab::UrlBuilder).to receive(:build).with(args[:user]).and_return("http://example.gitlab.com/hacker") end - context 'when the fancy_pipeline_slack_notifications feature flag is disabled' do - before do - stub_feature_flags(fancy_pipeline_slack_notifications: false) - end + it 'returns an empty pretext' do + expect(subject.pretext).to be_empty + end + + it "returns the pipeline summary in the activity's title" do + expect(subject.activity[:title]).to eq( + "Pipeline [#123](http://example.gitlab.com/pipelines/123)" \ + " of branch [develop](http://example.gitlab.com/commits/develop)" \ + " by The Hacker (hacker) has passed" + ) + end - it 'returns an empty pretext' do - expect(subject.pretext).to be_empty + context "when the pipeline failed" do + before do + args[:object_attributes][:status] = 'failed' end - it "returns the pipeline summary in the activity's title" do + it "returns the summary with a 'failed' status" do expect(subject.activity[:title]).to eq( "Pipeline [#123](http://example.gitlab.com/pipelines/123)" \ " of branch [develop](http://example.gitlab.com/commits/develop)" \ - " by The Hacker (hacker) passed" + " by The Hacker (hacker) has failed" ) end + end - context "when the pipeline failed" do - before do - args[:object_attributes][:status] = 'failed' - end - - it "returns the summary with a 'failed' status" do - expect(subject.activity[:title]).to eq( - "Pipeline [#123](http://example.gitlab.com/pipelines/123)" \ - " of branch [develop](http://example.gitlab.com/commits/develop)" \ - " by The Hacker (hacker) failed" - ) - end - end - - context 'when no user is provided because the pipeline was triggered by the API' do - before do - args[:user] = nil - end - - it "returns the summary with 'API' as the username" do - expect(subject.activity[:title]).to eq( - "Pipeline [#123](http://example.gitlab.com/pipelines/123)" \ - " of branch [develop](http://example.gitlab.com/commits/develop)" \ - " by API passed" - ) - end - end - - it "returns a link to the project in the activity's subtitle" do - expect(subject.activity[:subtitle]).to eq("in [project_name](http://example.gitlab.com)") - end - - it "returns the build duration in the activity's text property" do - expect(subject.activity[:text]).to eq("in 02:00:10") - end - - it "returns the user's avatar image URL in the activity's image property" do - expect(subject.activity[:image]).to eq("http://example.com/avatar") - end - - context 'when the user does not have an avatar' do - before do - args[:user][:avatar_url] = nil - end - - it "returns an empty string in the activity's image property" do - expect(subject.activity[:image]).to be_empty - end + context "when the pipeline passed with warnings" do + before do + args[:object_attributes][:detailed_status] = 'passed with warnings' end - it "returns the pipeline summary as the attachment's text property" do - expect(subject.attachments.first[:text]).to eq( - "<http://example.gitlab.com|project_name>:" \ - " Pipeline <http://example.gitlab.com/pipelines/123|#123>" \ - " of branch <http://example.gitlab.com/commits/develop|develop>" \ - " by The Hacker (hacker) passed in 02:00:10" + it "returns the summary with a 'passed with warnings' status" do + expect(subject.activity[:title]).to eq( + "Pipeline [#123](http://example.gitlab.com/pipelines/123)" \ + " of branch [develop](http://example.gitlab.com/commits/develop)" \ + " by The Hacker (hacker) has passed with warnings" ) end - - it "returns 'good' as the attachment's color property" do - expect(subject.attachments.first[:color]).to eq('good') - end - - context "when the pipeline failed" do - before do - args[:object_attributes][:status] = 'failed' - end - - it "returns 'danger' as the attachment's color property" do - expect(subject.attachments.first[:color]).to eq('danger') - end - end - - context 'when rendering markdown' do - before do - args[:markdown] = true - end - - it 'returns the pipeline summary as the attachments in markdown format' do - expect(subject.attachments).to eq( - "[project_name](http://example.gitlab.com):" \ - " Pipeline [#123](http://example.gitlab.com/pipelines/123)" \ - " of branch [develop](http://example.gitlab.com/commits/develop)" \ - " by The Hacker (hacker) passed in 02:00:10" - ) - end - end - - context 'when ref type is tag' do - before do - args[:object_attributes][:tag] = true - args[:object_attributes][:ref] = 'new_tag' - end - - it "returns the pipeline summary in the activity's title" do - expect(subject.activity[:title]).to eq( - "Pipeline [#123](http://example.gitlab.com/pipelines/123)" \ - " of tag [new_tag](http://example.gitlab.com/-/tags/new_tag)" \ - " by The Hacker (hacker) passed" - ) - end - - it "returns the pipeline summary as the attachment's text property" do - expect(subject.attachments.first[:text]).to eq( - "<http://example.gitlab.com|project_name>:" \ - " Pipeline <http://example.gitlab.com/pipelines/123|#123>" \ - " of tag <http://example.gitlab.com/-/tags/new_tag|new_tag>" \ - " by The Hacker (hacker) passed in 02:00:10" - ) - end - - context 'when rendering markdown' do - before do - args[:markdown] = true - end - - it 'returns the pipeline summary as the attachments in markdown format' do - expect(subject.attachments).to eq( - "[project_name](http://example.gitlab.com):" \ - " Pipeline [#123](http://example.gitlab.com/pipelines/123)" \ - " of tag [new_tag](http://example.gitlab.com/-/tags/new_tag)" \ - " by The Hacker (hacker) passed in 02:00:10" - ) - end - end - end end - context 'when the fancy_pipeline_slack_notifications feature flag is enabled' do + context 'when no user is provided because the pipeline was triggered by the API' do before do - stub_feature_flags(fancy_pipeline_slack_notifications: true) - end - - it 'returns an empty pretext' do - expect(subject.pretext).to be_empty + args[:user] = nil end - it "returns the pipeline summary in the activity's title" do + it "returns the summary with 'API' as the username" do expect(subject.activity[:title]).to eq( "Pipeline [#123](http://example.gitlab.com/pipelines/123)" \ " of branch [develop](http://example.gitlab.com/commits/develop)" \ - " by The Hacker (hacker) has passed" + " by API has passed" ) end + end - context "when the pipeline failed" do - before do - args[:object_attributes][:status] = 'failed' - end + it "returns a link to the project in the activity's subtitle" do + expect(subject.activity[:subtitle]).to eq("in [project_name](http://example.gitlab.com)") + end - it "returns the summary with a 'failed' status" do - expect(subject.activity[:title]).to eq( - "Pipeline [#123](http://example.gitlab.com/pipelines/123)" \ - " of branch [develop](http://example.gitlab.com/commits/develop)" \ - " by The Hacker (hacker) has failed" - ) - end - end + it "returns the build duration in the activity's text property" do + expect(subject.activity[:text]).to eq("in 02:00:10") + end - context "when the pipeline passed with warnings" do - before do - args[:object_attributes][:detailed_status] = 'passed with warnings' - end + it "returns the user's avatar image URL in the activity's image property" do + expect(subject.activity[:image]).to eq("http://example.com/avatar") + end - it "returns the summary with a 'passed with warnings' status" do - expect(subject.activity[:title]).to eq( - "Pipeline [#123](http://example.gitlab.com/pipelines/123)" \ - " of branch [develop](http://example.gitlab.com/commits/develop)" \ - " by The Hacker (hacker) has passed with warnings" - ) - end + context 'when the user does not have an avatar' do + before do + args[:user][:avatar_url] = nil end - context 'when no user is provided because the pipeline was triggered by the API' do - before do - args[:user] = nil - end - - it "returns the summary with 'API' as the username" do - expect(subject.activity[:title]).to eq( - "Pipeline [#123](http://example.gitlab.com/pipelines/123)" \ - " of branch [develop](http://example.gitlab.com/commits/develop)" \ - " by API has passed" - ) - end + it "returns an empty string in the activity's image property" do + expect(subject.activity[:image]).to be_empty end + end - it "returns a link to the project in the activity's subtitle" do - expect(subject.activity[:subtitle]).to eq("in [project_name](http://example.gitlab.com)") - end + it "returns the pipeline summary as the attachment's fallback property" do + expect(subject.attachments.first[:fallback]).to eq( + "<http://example.gitlab.com|project_name>:" \ + " Pipeline <http://example.gitlab.com/pipelines/123|#123>" \ + " of branch <http://example.gitlab.com/commits/develop|develop>" \ + " by The Hacker (hacker) has passed in 02:00:10" + ) + end - it "returns the build duration in the activity's text property" do - expect(subject.activity[:text]).to eq("in 02:00:10") - end + it "returns 'good' as the attachment's color property" do + expect(subject.attachments.first[:color]).to eq('good') + end - it "returns the user's avatar image URL in the activity's image property" do - expect(subject.activity[:image]).to eq("http://example.com/avatar") + context "when the pipeline failed" do + before do + args[:object_attributes][:status] = 'failed' end - context 'when the user does not have an avatar' do - before do - args[:user][:avatar_url] = nil - end - - it "returns an empty string in the activity's image property" do - expect(subject.activity[:image]).to be_empty - end + it "returns 'danger' as the attachment's color property" do + expect(subject.attachments.first[:color]).to eq('danger') end + end - it "returns the pipeline summary as the attachment's fallback property" do - expect(subject.attachments.first[:fallback]).to eq( - "<http://example.gitlab.com|project_name>:" \ - " Pipeline <http://example.gitlab.com/pipelines/123|#123>" \ - " of branch <http://example.gitlab.com/commits/develop|develop>" \ - " by The Hacker (hacker) has passed in 02:00:10" - ) + context "when the pipeline passed with warnings" do + before do + args[:object_attributes][:detailed_status] = 'passed with warnings' end - it "returns 'good' as the attachment's color property" do - expect(subject.attachments.first[:color]).to eq('good') + it "returns 'warning' as the attachment's color property" do + expect(subject.attachments.first[:color]).to eq('warning') end + end - context "when the pipeline failed" do - before do - args[:object_attributes][:status] = 'failed' - end + it "returns the committer's name and username as the attachment's author_name property" do + expect(subject.attachments.first[:author_name]).to eq('The Hacker (hacker)') + end - it "returns 'danger' as the attachment's color property" do - expect(subject.attachments.first[:color]).to eq('danger') - end - end + it "returns the committer's avatar URL as the attachment's author_icon property" do + expect(subject.attachments.first[:author_icon]).to eq('http://example.com/avatar') + end - context "when the pipeline passed with warnings" do - before do - args[:object_attributes][:detailed_status] = 'passed with warnings' - end + it "returns the committer's GitLab profile URL as the attachment's author_link property" do + expect(subject.attachments.first[:author_link]).to eq('http://example.gitlab.com/hacker') + end - it "returns 'warning' as the attachment's color property" do - expect(subject.attachments.first[:color]).to eq('warning') - end + context 'when no user is provided because the pipeline was triggered by the API' do + before do + args[:user] = nil end it "returns the committer's name and username as the attachment's author_name property" do - expect(subject.attachments.first[:author_name]).to eq('The Hacker (hacker)') + expect(subject.attachments.first[:author_name]).to eq('API') end - it "returns the committer's avatar URL as the attachment's author_icon property" do - expect(subject.attachments.first[:author_icon]).to eq('http://example.com/avatar') + it "returns nil as the attachment's author_icon property" do + expect(subject.attachments.first[:author_icon]).to be_nil end - it "returns the committer's GitLab profile URL as the attachment's author_link property" do - expect(subject.attachments.first[:author_link]).to eq('http://example.gitlab.com/hacker') + it "returns nil as the attachment's author_link property" do + expect(subject.attachments.first[:author_link]).to be_nil end + end - context 'when no user is provided because the pipeline was triggered by the API' do - before do - args[:user] = nil - end + it "returns the pipeline ID, status, and duration as the attachment's title property" do + expect(subject.attachments.first[:title]).to eq("Pipeline #123 has passed in 02:00:10") + end - it "returns the committer's name and username as the attachment's author_name property" do - expect(subject.attachments.first[:author_name]).to eq('API') - end + it "returns the pipeline URL as the attachment's title_link property" do + expect(subject.attachments.first[:title_link]).to eq("http://example.gitlab.com/pipelines/123") + end - it "returns nil as the attachment's author_icon property" do - expect(subject.attachments.first[:author_icon]).to be_nil - end + it "returns two attachment fields" do + expect(subject.attachments.first[:fields].count).to eq(2) + end - it "returns nil as the attachment's author_link property" do - expect(subject.attachments.first[:author_link]).to be_nil - end - end + it "returns the commit message as the attachment's second field property" do + expect(subject.attachments.first[:fields][0]).to eq({ + title: "Branch", + value: "<http://example.gitlab.com/commits/develop|develop>", + short: true + }) + end - it "returns the pipeline ID, status, and duration as the attachment's title property" do - expect(subject.attachments.first[:title]).to eq("Pipeline #123 has passed in 02:00:10") - end + it "returns the ref name and link as the attachment's second field property" do + expect(subject.attachments.first[:fields][1]).to eq({ + title: "Commit", + value: "<http://example.com/commit|A test commit message>", + short: true + }) + end - it "returns the pipeline URL as the attachment's title_link property" do - expect(subject.attachments.first[:title_link]).to eq("http://example.gitlab.com/pipelines/123") + context "when a job in the pipeline fails" do + before do + args[:builds] = [ + { id: 1, name: "rspec", status: "failed", stage: "test" }, + { id: 2, name: "karma", status: "success", stage: "test" } + ] end - it "returns two attachment fields" do - expect(subject.attachments.first[:fields].count).to eq(2) + it "returns four attachment fields" do + expect(subject.attachments.first[:fields].count).to eq(4) end - it "returns the commit message as the attachment's second field property" do - expect(subject.attachments.first[:fields][0]).to eq({ - title: "Branch", - value: "<http://example.gitlab.com/commits/develop|develop>", + it "returns the stage name and link to the 'Failed jobs' tab on the pipeline's page as the attachment's third field property" do + expect(subject.attachments.first[:fields][2]).to eq({ + title: "Failed stage", + value: "<http://example.gitlab.com/pipelines/123/failures|test>", short: true }) end - it "returns the ref name and link as the attachment's second field property" do - expect(subject.attachments.first[:fields][1]).to eq({ - title: "Commit", - value: "<http://example.com/commit|A test commit message>", + it "returns the job name and link as the attachment's fourth field property" do + expect(subject.attachments.first[:fields][3]).to eq({ + title: "Failed job", + value: "<http://example.gitlab.com/-/jobs/1|rspec>", short: true }) end + end - context "when a job in the pipeline fails" do - before do - args[:builds] = [ - { id: 1, name: "rspec", status: "failed", stage: "test" }, - { id: 2, name: "karma", status: "success", stage: "test" } - ] - end - - it "returns four attachment fields" do - expect(subject.attachments.first[:fields].count).to eq(4) - end - - it "returns the stage name and link to the 'Failed jobs' tab on the pipeline's page as the attachment's third field property" do - expect(subject.attachments.first[:fields][2]).to eq({ - title: "Failed stage", - value: "<http://example.gitlab.com/pipelines/123/failures|test>", - short: true - }) - end - - it "returns the job name and link as the attachment's fourth field property" do - expect(subject.attachments.first[:fields][3]).to eq({ - title: "Failed job", - value: "<http://example.gitlab.com/-/jobs/1|rspec>", - short: true - }) + context "when lots of jobs across multiple stages fail" do + before do + args[:builds] = (1..25).map do |i| + { id: i, name: "job-#{i}", status: "failed", stage: "stage-" + ((i % 3) + 1).to_s } end end - context "when lots of jobs across multiple stages fail" do - before do - args[:builds] = (1..25).map do |i| - { id: i, name: "job-#{i}", status: "failed", stage: "stage-" + ((i % 3) + 1).to_s } - end - end + it "returns the stage names and links to the 'Failed jobs' tab on the pipeline's page as the attachment's third field property" do + expect(subject.attachments.first[:fields][2]).to eq({ + title: "Failed stages", + value: "<http://example.gitlab.com/pipelines/123/failures|stage-2>, <http://example.gitlab.com/pipelines/123/failures|stage-1>, <http://example.gitlab.com/pipelines/123/failures|stage-3>", + short: true + }) + end - it "returns the stage names and links to the 'Failed jobs' tab on the pipeline's page as the attachment's third field property" do - expect(subject.attachments.first[:fields][2]).to eq({ - title: "Failed stages", - value: "<http://example.gitlab.com/pipelines/123/failures|stage-2>, <http://example.gitlab.com/pipelines/123/failures|stage-1>, <http://example.gitlab.com/pipelines/123/failures|stage-3>", - short: true - }) + it "returns the job names and links as the attachment's fourth field property" do + expected_jobs = 25.downto(16).map do |i| + "<http://example.gitlab.com/-/jobs/#{i}|job-#{i}>" end - it "returns the job names and links as the attachment's fourth field property" do - expected_jobs = 25.downto(16).map do |i| - "<http://example.gitlab.com/-/jobs/#{i}|job-#{i}>" - end + expected_jobs << "and <http://example.gitlab.com/pipelines/123/failures|15 more>" - expected_jobs << "and <http://example.gitlab.com/pipelines/123/failures|15 more>" - - expect(subject.attachments.first[:fields][3]).to eq({ - title: "Failed jobs", - value: expected_jobs.join(", "), - short: true - }) - end + expect(subject.attachments.first[:fields][3]).to eq({ + title: "Failed jobs", + value: expected_jobs.join(", "), + short: true + }) end + end - context "when jobs succeed on retries" do - before do - args[:builds] = [ - { id: 1, name: "job-1", status: "failed", stage: "stage-1" }, - { id: 2, name: "job-2", status: "failed", stage: "stage-2" }, - { id: 3, name: "job-3", status: "failed", stage: "stage-3" }, - { id: 7, name: "job-1", status: "failed", stage: "stage-1" }, - { id: 8, name: "job-1", status: "success", stage: "stage-1" } - ] - end - - it "do not return a job which succeeded on retry" do - expected_jobs = [ - "<http://example.gitlab.com/-/jobs/3|job-3>", - "<http://example.gitlab.com/-/jobs/2|job-2>" - ] - - expect(subject.attachments.first[:fields][3]).to eq( - title: "Failed jobs", - value: expected_jobs.join(", "), - short: true - ) - end + context "when jobs succeed on retries" do + before do + args[:builds] = [ + { id: 1, name: "job-1", status: "failed", stage: "stage-1" }, + { id: 2, name: "job-2", status: "failed", stage: "stage-2" }, + { id: 3, name: "job-3", status: "failed", stage: "stage-3" }, + { id: 7, name: "job-1", status: "failed", stage: "stage-1" }, + { id: 8, name: "job-1", status: "success", stage: "stage-1" } + ] + end + + it "do not return a job which succeeded on retry" do + expected_jobs = [ + "<http://example.gitlab.com/-/jobs/3|job-3>", + "<http://example.gitlab.com/-/jobs/2|job-2>" + ] + + expect(subject.attachments.first[:fields][3]).to eq( + title: "Failed jobs", + value: expected_jobs.join(", "), + short: true + ) end + end - context "when jobs failed even on retries" do - before do - args[:builds] = [ - { id: 1, name: "job-1", status: "failed", stage: "stage-1" }, - { id: 2, name: "job-2", status: "failed", stage: "stage-2" }, - { id: 3, name: "job-3", status: "failed", stage: "stage-3" }, - { id: 7, name: "job-1", status: "failed", stage: "stage-1" }, - { id: 8, name: "job-1", status: "failed", stage: "stage-1" } - ] - end - - it "returns only first instance of the failed job" do - expected_jobs = [ - "<http://example.gitlab.com/-/jobs/3|job-3>", - "<http://example.gitlab.com/-/jobs/2|job-2>", - "<http://example.gitlab.com/-/jobs/1|job-1>" - ] - - expect(subject.attachments.first[:fields][3]).to eq( - title: "Failed jobs", - value: expected_jobs.join(", "), - short: true - ) - end + context "when jobs failed even on retries" do + before do + args[:builds] = [ + { id: 1, name: "job-1", status: "failed", stage: "stage-1" }, + { id: 2, name: "job-2", status: "failed", stage: "stage-2" }, + { id: 3, name: "job-3", status: "failed", stage: "stage-3" }, + { id: 7, name: "job-1", status: "failed", stage: "stage-1" }, + { id: 8, name: "job-1", status: "failed", stage: "stage-1" } + ] + end + + it "returns only first instance of the failed job" do + expected_jobs = [ + "<http://example.gitlab.com/-/jobs/3|job-3>", + "<http://example.gitlab.com/-/jobs/2|job-2>", + "<http://example.gitlab.com/-/jobs/1|job-1>" + ] + + expect(subject.attachments.first[:fields][3]).to eq( + title: "Failed jobs", + value: expected_jobs.join(", "), + short: true + ) end + end - context "when the CI config file contains a YAML error" do - let(:has_yaml_errors) { true } - - it "returns three attachment fields" do - expect(subject.attachments.first[:fields].count).to eq(3) - end + context "when the CI config file contains a YAML error" do + let(:has_yaml_errors) { true } - it "returns the YAML error deatils as the attachment's third field property" do - expect(subject.attachments.first[:fields][2]).to eq({ - title: "Invalid CI config YAML file", - value: "yaml error description here", - short: false - }) - end + it "returns three attachment fields" do + expect(subject.attachments.first[:fields].count).to eq(3) end - it "returns the project's name as the attachment's footer property" do - expect(subject.attachments.first[:footer]).to eq("project_name") + it "returns the YAML error deatils as the attachment's third field property" do + expect(subject.attachments.first[:fields][2]).to eq({ + title: "Invalid CI config YAML file", + value: "yaml error description here", + short: false + }) end + end - it "returns the project's avatar URL as the attachment's footer_icon property" do - expect(subject.attachments.first[:footer_icon]).to eq("http://example.com/project_avatar") - end + it "returns the project's name as the attachment's footer property" do + expect(subject.attachments.first[:footer]).to eq("project_name") + end - it "returns the pipeline's timestamp as the attachment's ts property" do - expected_ts = Time.parse(args[:object_attributes][:finished_at]).to_i - expect(subject.attachments.first[:ts]).to eq(expected_ts) - end + it "returns the project's avatar URL as the attachment's footer_icon property" do + expect(subject.attachments.first[:footer_icon]).to eq("http://example.com/project_avatar") + end - context 'when rendering markdown' do - before do - args[:markdown] = true - end + it "returns the pipeline's timestamp as the attachment's ts property" do + expected_ts = Time.parse(args[:object_attributes][:finished_at]).to_i + expect(subject.attachments.first[:ts]).to eq(expected_ts) + end - it 'returns the pipeline summary as the attachments in markdown format' do - expect(subject.attachments).to eq( - "[project_name](http://example.gitlab.com):" \ - " Pipeline [#123](http://example.gitlab.com/pipelines/123)" \ - " of branch [develop](http://example.gitlab.com/commits/develop)" \ - " by The Hacker (hacker) has passed in 02:00:10" - ) - end + context 'when rendering markdown' do + before do + args[:markdown] = true + end + + it 'returns the pipeline summary as the attachments in markdown format' do + expect(subject.attachments).to eq( + "[project_name](http://example.gitlab.com):" \ + " Pipeline [#123](http://example.gitlab.com/pipelines/123)" \ + " of branch [develop](http://example.gitlab.com/commits/develop)" \ + " by The Hacker (hacker) has passed in 02:00:10" + ) end end end diff --git a/spec/models/project_services/irker_service_spec.rb b/spec/models/project_services/irker_service_spec.rb index badc964db16..88a93eef214 100644 --- a/spec/models/project_services/irker_service_spec.rb +++ b/spec/models/project_services/irker_service_spec.rb @@ -65,7 +65,7 @@ describe IrkerService do conn = @irker_server.accept conn.each_line do |line| - msg = JSON.parse(line.chomp("\n")) + msg = Gitlab::Json.parse(line.chomp("\n")) expect(msg.keys).to match_array(%w(to privmsg)) expect(msg['to']).to match_array(["irc://chat.freenode.net/#commits", "irc://test.net/#test"]) diff --git a/spec/models/project_services/jira_service_spec.rb b/spec/models/project_services/jira_service_spec.rb index 32e6b5afce5..a0d36f0a238 100644 --- a/spec/models/project_services/jira_service_spec.rb +++ b/spec/models/project_services/jira_service_spec.rb @@ -69,11 +69,23 @@ describe JiraService do end describe '.reference_pattern' do - it_behaves_like 'allows project key on reference pattern' + using RSpec::Parameterized::TableSyntax - it 'does not allow # on the code' do - expect(described_class.reference_pattern.match('#123')).to be_nil - expect(described_class.reference_pattern.match('1#23#12')).to be_nil + where(:key, :result) do + '#123' | '' + '1#23#12' | '' + 'JIRA-1234A' | 'JIRA-1234' + 'JIRA-1234-some_tag' | 'JIRA-1234' + 'JIRA-1234_some_tag' | 'JIRA-1234' + 'EXT_EXT-1234' | 'EXT_EXT-1234' + 'EXT3_EXT-1234' | 'EXT3_EXT-1234' + '3EXT_EXT-1234' | '' + end + + with_them do + specify do + expect(described_class.reference_pattern.match(key).to_s).to eq(result) + end end end @@ -570,6 +582,79 @@ describe JiraService do end end + describe '#create_cross_reference_note' do + let_it_be(:user) { build_stubbed(:user) } + let_it_be(:project) { create(:project, :repository) } + let(:jira_service) do + described_class.new( + project: project, + url: url, + username: username, + password: password + ) + end + let(:jira_issue) { ExternalIssue.new('JIRA-123', project) } + + subject { jira_service.create_cross_reference_note(jira_issue, resource, user) } + + shared_examples 'creates a comment on Jira' do + let(:issue_url) { "#{url}/rest/api/2/issue/JIRA-123" } + let(:comment_url) { "#{issue_url}/comment" } + let(:remote_link_url) { "#{issue_url}/remotelink" } + + before do + allow(JIRA::Resource::Remotelink).to receive(:all).and_return([]) + stub_request(:get, issue_url).with(basic_auth: [username, password]) + stub_request(:post, comment_url).with(basic_auth: [username, password]) + stub_request(:post, remote_link_url).with(basic_auth: [username, password]) + end + + it 'creates a comment on Jira' do + subject + + expect(WebMock).to have_requested(:post, comment_url).with( + body: /mentioned this issue in/ + ).once + end + end + + context 'when resource is a commit' do + let(:resource) { project.commit('master') } + + context 'when disabled' do + before do + allow_next_instance_of(JiraService) do |instance| + allow(instance).to receive(:commit_events) { false } + end + end + + it { is_expected.to eq('Events for commits are disabled.') } + end + + context 'when enabled' do + it_behaves_like 'creates a comment on Jira' + end + end + + context 'when resource is a merge request' do + let(:resource) { build_stubbed(:merge_request, source_project: project) } + + context 'when disabled' do + before do + allow_next_instance_of(JiraService) do |instance| + allow(instance).to receive(:merge_requests_events) { false } + end + end + + it { is_expected.to eq('Events for merge requests are disabled.') } + end + + context 'when enabled' do + it_behaves_like 'creates a comment on Jira' + end + end + end + describe '#test' do let(:jira_service) do described_class.new( diff --git a/spec/models/project_services/mattermost_slash_commands_service_spec.rb b/spec/models/project_services/mattermost_slash_commands_service_spec.rb index 87e482059f2..836181929e3 100644 --- a/spec/models/project_services/mattermost_slash_commands_service_spec.rb +++ b/spec/models/project_services/mattermost_slash_commands_service_spec.rb @@ -121,5 +121,12 @@ describe MattermostSlashCommandsService 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/project_services/microsoft_teams_service_spec.rb b/spec/models/project_services/microsoft_teams_service_spec.rb index d93b8a2cb40..425599c73d4 100644 --- a/spec/models/project_services/microsoft_teams_service_spec.rb +++ b/spec/models/project_services/microsoft_teams_service_spec.rb @@ -121,7 +121,7 @@ describe MicrosoftTeamsService do message: "user created page: Awesome wiki_page" } end - let(:wiki_page) { create(:wiki_page, wiki: project.wiki, attrs: opts) } + let(:wiki_page) { create(:wiki_page, wiki: project.wiki, **opts) } let(:wiki_page_sample_data) { Gitlab::DataBuilder::WikiPage.build(wiki_page, user, 'create') } it "calls Microsoft Teams API" do diff --git a/spec/models/project_services/webex_teams_service_spec.rb b/spec/models/project_services/webex_teams_service_spec.rb new file mode 100644 index 00000000000..38977ef3b7d --- /dev/null +++ b/spec/models/project_services/webex_teams_service_spec.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +require "spec_helper" + +describe WebexTeamsService do + it_behaves_like "chat service", "Webex Teams" do + let(:client_arguments) { webhook_url } + let(:content_key) { :markdown } + end +end diff --git a/spec/models/project_snippet_spec.rb b/spec/models/project_snippet_spec.rb index 719a74f995d..c17a24dc7cf 100644 --- a/spec/models/project_snippet_spec.rb +++ b/spec/models/project_snippet_spec.rb @@ -38,5 +38,6 @@ describe ProjectSnippet do let(:stubbed_container) { build_stubbed(:project_snippet) } let(:expected_full_path) { "#{container.project.full_path}/@snippets/#{container.id}" } let(:expected_web_url_path) { "#{container.project.full_path}/snippets/#{container.id}" } + let(:expected_repo_url_path) { expected_web_url_path } end end diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb index 4e75ef4fc87..5f8b51c250d 100644 --- a/spec/models/project_spec.rb +++ b/spec/models/project_spec.rb @@ -6,6 +6,7 @@ describe Project do include ProjectForksHelper include GitHelpers include ExternalAuthorizationServiceHelpers + using RSpec::Parameterized::TableSyntax it_behaves_like 'having unique enum values' @@ -20,6 +21,7 @@ describe Project do it { is_expected.to have_many(:merge_requests) } it { is_expected.to have_many(:issues) } it { is_expected.to have_many(:milestones) } + it { is_expected.to have_many(:iterations) } it { is_expected.to have_many(:project_members).dependent(:delete_all) } it { is_expected.to have_many(:users).through(:project_members) } it { is_expected.to have_many(:requesters).dependent(:delete_all) } @@ -34,6 +36,7 @@ describe Project do it { is_expected.to have_one(:mattermost_service) } it { is_expected.to have_one(:hangouts_chat_service) } it { is_expected.to have_one(:unify_circuit_service) } + it { is_expected.to have_one(:webex_teams_service) } it { is_expected.to have_one(:packagist_service) } it { is_expected.to have_one(:pushover_service) } it { is_expected.to have_one(:asana_service) } @@ -110,7 +113,10 @@ describe Project do it { is_expected.to have_many(:source_pipelines) } it { is_expected.to have_many(:prometheus_alert_events) } it { is_expected.to have_many(:self_managed_prometheus_alert_events) } + it { is_expected.to have_many(:alert_management_alerts) } it { is_expected.to have_many(:jira_imports) } + it { is_expected.to have_many(:metrics_users_starred_dashboards).inverse_of(:project) } + it { is_expected.to have_many(:repository_storage_moves) } it_behaves_like 'model with repository' do let_it_be(:container) { create(:project, :repository, path: 'somewhere') } @@ -118,6 +124,11 @@ describe Project do let(:expected_full_path) { "#{container.namespace.full_path}/somewhere" } end + it_behaves_like 'model with wiki' do + let(:container) { create(:project, :wiki_repo) } + let(:container_without_wiki) { create(:project) } + end + it 'has an inverse relationship with merge requests' do expect(described_class.reflect_on_association(:merge_requests).has_inverse?).to eq(:target_project) end @@ -263,27 +274,6 @@ describe Project do create(:project) end - describe 'wiki path conflict' do - context "when the new path has been used by the wiki of other Project" do - it 'has an error on the name attribute' do - new_project = build_stubbed(:project, namespace_id: project.namespace_id, path: "#{project.path}.wiki") - - expect(new_project).not_to be_valid - expect(new_project.errors[:name].first).to eq(_('has already been taken')) - end - end - - context "when the new wiki path has been used by the path of other Project" do - it 'has an error on the name attribute' do - project_with_wiki_suffix = create(:project, path: 'foo.wiki') - new_project = build_stubbed(:project, namespace_id: project_with_wiki_suffix.namespace_id, path: 'foo') - - expect(new_project).not_to be_valid - expect(new_project.errors[:name].first).to eq(_('has already been taken')) - end - end - end - context 'repository storages inclusion' do let(:project2) { build(:project, repository_storage: 'missing') } @@ -1791,6 +1781,7 @@ describe Project do let(:project) { create(:project, :repository) } let(:repo) { double(:repo, exists?: true) } let(:wiki) { double(:wiki, exists?: true) } + let(:design) { double(:design, exists?: true) } it 'expires the caches of the repository and wiki' do # In EE, there are design repositories as well @@ -1804,8 +1795,13 @@ describe Project do .with('foo.wiki', project, shard: project.repository_storage, repo_type: Gitlab::GlRepository::WIKI) .and_return(wiki) + allow(Repository).to receive(:new) + .with('foo.design', project, shard: project.repository_storage, repo_type: Gitlab::GlRepository::DESIGN) + .and_return(design) + expect(repo).to receive(:before_delete) expect(wiki).to receive(:before_delete) + expect(design).to receive(:before_delete) project.expire_caches_before_rename('foo') end @@ -2849,12 +2845,16 @@ describe Project do end it 'schedules the transfer of the repository to the new storage and locks the project' do - expect(ProjectUpdateRepositoryStorageWorker).to receive(:perform_async).with(project.id, 'test_second_storage') + expect(ProjectUpdateRepositoryStorageWorker).to receive(:perform_async).with(project.id, 'test_second_storage', anything) project.change_repository_storage('test_second_storage') project.save! expect(project).to be_repository_read_only + expect(project.repository_storage_moves.last).to have_attributes( + source_storage_name: "default", + destination_storage_name: "test_second_storage" + ) end it "doesn't schedule the transfer if the repository is already read-only" do @@ -3139,6 +3139,45 @@ describe Project do end end + describe '#ci_instance_variables_for' do + let(:project) { create(:project) } + + let!(:instance_variable) do + create(:ci_instance_variable, value: 'secret') + end + + let!(:protected_instance_variable) do + create(:ci_instance_variable, :protected, value: 'protected') + end + + subject { project.ci_instance_variables_for(ref: 'ref') } + + before do + stub_application_setting( + default_branch_protection: Gitlab::Access::PROTECTION_NONE) + end + + context 'when the ref is not protected' do + before do + allow(project).to receive(:protected_for?).with('ref').and_return(false) + end + + it 'contains only the CI variables' do + is_expected.to contain_exactly(instance_variable) + end + end + + context 'when the ref is protected' do + before do + allow(project).to receive(:protected_for?).with('ref').and_return(true) + end + + it 'contains all the variables' do + is_expected.to contain_exactly(instance_variable, protected_instance_variable) + end + end + end + describe '#any_lfs_file_locks?', :request_store do let_it_be(:project) { create(:project) } @@ -3637,6 +3676,24 @@ describe Project do expect(projects).to contain_exactly(public_project) end end + + context 'with deploy token users' do + let_it_be(:private_project) { create(:project, :private) } + + subject { described_class.all.public_or_visible_to_user(user) } + + context 'deploy token user without project' do + let_it_be(:user) { create(:deploy_token) } + + it { is_expected.to eq [] } + end + + context 'deploy token user with project' do + let_it_be(:user) { create(:deploy_token, projects: [private_project]) } + + it { is_expected.to include(private_project) } + end + end end describe '.ids_with_issuables_available_for' do @@ -3760,7 +3817,7 @@ describe Project do end end - describe '.filter_by_feature_visibility' do + describe '.filter_by_feature_visibility', :enable_admin_mode do include_context 'ProjectPolicyTable context' include ProjectHelpers using RSpec::Parameterized::TableSyntax @@ -3955,16 +4012,6 @@ describe Project do expect { project.remove_pages }.to change { pages_metadatum.reload.deployed }.from(true).to(false) end - it 'is a no-op when there is no namespace' do - project.namespace.delete - project.reload - - expect_any_instance_of(Projects::UpdatePagesConfigurationService).not_to receive(:execute) - expect_any_instance_of(Gitlab::PagesTransfer).not_to receive(:rename_project) - - expect { project.remove_pages }.not_to change { pages_metadatum.reload.deployed } - end - it 'is run when the project is destroyed' do expect(project).to receive(:remove_pages).and_call_original @@ -4716,20 +4763,6 @@ describe Project do end end - describe '#wiki_repository_exists?' do - it 'returns true when the wiki repository exists' do - project = create(:project, :wiki_repo) - - expect(project.wiki_repository_exists?).to eq(true) - end - - it 'returns false when the wiki repository does not exist' do - project = create(:project) - - expect(project.wiki_repository_exists?).to eq(false) - end - end - describe '#write_repository_config' do let_it_be(:project) { create(:project, :repository) } @@ -5972,6 +6005,158 @@ describe Project do end end + describe '#validate_jira_import_settings!' do + include JiraServiceHelper + + let_it_be(:project, reload: true) { create(:project) } + + shared_examples 'raise Jira import error' do |message| + it 'returns error' do + expect { subject }.to raise_error(Projects::ImportService::Error, message) + end + end + + shared_examples 'jira configuration base checks' do + context 'when feature flag is disabled' do + before do + stub_feature_flags(jira_issue_import: false) + end + + it_behaves_like 'raise Jira import error', 'Jira import feature is disabled.' + end + + context 'when feature flag is enabled' do + before do + stub_feature_flags(jira_issue_import: true) + end + + context 'when Jira service was not setup' do + it_behaves_like 'raise Jira import error', 'Jira integration not configured.' + end + + context 'when Jira service exists' do + let!(:jira_service) { create(:jira_service, project: project, active: true) } + + context 'when Jira connection is not valid' do + before do + WebMock.stub_request(:get, 'https://jira.example.com/rest/api/2/serverInfo') + .to_raise(JIRA::HTTPError.new(double(message: 'Some failure.'))) + end + + it_behaves_like 'raise Jira import error', 'Unable to connect to the Jira instance. Please check your Jira integration configuration.' + end + end + end + end + + before do + stub_jira_service_test + end + + context 'without user param' do + subject { project.validate_jira_import_settings! } + + it_behaves_like 'jira configuration base checks' + + context 'when jira connection is valid' do + let!(:jira_service) { create(:jira_service, project: project, active: true) } + + it 'does not return any error' do + expect { subject }.not_to raise_error + end + end + end + + context 'with user param provided' do + let_it_be(:user) { create(:user) } + + subject { project.validate_jira_import_settings!(user: user) } + + context 'when user has permission to run import' do + before do + project.add_maintainer(user) + end + + it_behaves_like 'jira configuration base checks' + end + + context 'when feature flag is enabled' do + before do + stub_feature_flags(jira_issue_import: true) + end + + context 'when user does not have permissions to run the import' do + before do + create(:jira_service, project: project, active: true) + + project.add_developer(user) + end + + it_behaves_like 'raise Jira import error', 'You do not have permissions to run the import.' + end + + context 'when user has permission to run import' do + before do + project.add_maintainer(user) + end + + let!(:jira_service) { create(:jira_service, project: project, active: true) } + + context 'when issues feature is disabled' do + let_it_be(:project, reload: true) { create(:project, :issues_disabled) } + + it_behaves_like 'raise Jira import error', 'Cannot import because issues are not available in this project.' + end + + context 'when everything is ok' do + it 'does not return any error' do + expect { subject }.not_to raise_error + end + end + end + end + end + end + + describe '#design_management_enabled?' do + let(:project) { build(:project) } + + where(:lfs_enabled, :hashed_storage_enabled, :expectation) do + false | false | false + true | false | false + false | true | false + true | true | true + end + + with_them do + before do + expect(project).to receive(:lfs_enabled?).and_return(lfs_enabled) + allow(project).to receive(:hashed_storage?).with(:repository).and_return(hashed_storage_enabled) + end + + it do + expect(project.design_management_enabled?).to be(expectation) + end + end + end + + describe '#bots' do + subject { project.bots } + + let_it_be(:project) { create(:project) } + let_it_be(:project_bot) { create(:user, :project_bot) } + let_it_be(:user) { create(:user) } + + before_all do + [project_bot, user].each do |member| + project.add_maintainer(member) + end + end + + it { is_expected.to contain_exactly(project_bot) } + it { is_expected.not_to include(user) } + end + def finish_job(export_job) export_job.start export_job.finish diff --git a/spec/models/project_wiki_spec.rb b/spec/models/project_wiki_spec.rb index 1b121b7dee1..a4181e3be9a 100644 --- a/spec/models/project_wiki_spec.rb +++ b/spec/models/project_wiki_spec.rb @@ -1,448 +1,35 @@ # frozen_string_literal: true -require "spec_helper" +require 'spec_helper' describe ProjectWiki do - let(:user) { create(:user, :commit_email) } - let(:project) { create(:project, :wiki_repo, namespace: user.namespace) } - let(:repository) { project.repository } - let(:gitlab_shell) { Gitlab::Shell.new } - let(:project_wiki) { described_class.new(project, user) } - let(:raw_repository) { Gitlab::Git::Repository.new(project.repository_storage, subject.disk_path + '.git', 'foo', 'group/project.wiki') } - let(:commit) { project_wiki.repository.head_commit } + 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) } - subject { project_wiki } + it { is_expected.to delegate_method(:storage).to(:container) } + it { is_expected.to delegate_method(:repository_storage).to(:container) } + it { is_expected.to delegate_method(:hashed_storage?).to(:container) } - it { is_expected.to delegate_method(:repository_storage).to :project } - it { is_expected.to delegate_method(:hashed_storage?).to :project } - - describe "#full_path" do - it "returns the project path with namespace with the .wiki extension" do - expect(subject.full_path).to eq(project.full_path + '.wiki') - end - - it 'returns the same value as #full_path' do - expect(subject.full_path).to eq(subject.full_path) - end - end - - describe '#web_url' do - it 'returns the full web URL to the wiki' do - expect(subject.web_url).to eq(Gitlab::UrlBuilder.build(subject)) - end - end - - describe "#url_to_repo" do - it "returns the correct ssh url to the repo" do - expect(subject.url_to_repo).to eq(Gitlab::RepositoryUrlBuilder.build(subject.repository.full_path, protocol: :ssh)) - end - end - - describe "#ssh_url_to_repo" do - it "equals #url_to_repo" do - expect(subject.ssh_url_to_repo).to eq(subject.url_to_repo) - end - end - - describe "#http_url_to_repo" do - it "returns the correct http url to the repo" do - expect(subject.http_url_to_repo).to eq(Gitlab::RepositoryUrlBuilder.build(subject.repository.full_path, protocol: :http)) - end - end - - describe "#wiki_base_path" do - it "returns the wiki base path" do - wiki_base_path = "#{Gitlab.config.gitlab.relative_url_root}/#{project.full_path}/-/wikis" - - expect(subject.wiki_base_path).to eq(wiki_base_path) - end - end - - describe "#wiki" do - it "contains a Gitlab::Git::Wiki instance" do - expect(subject.wiki).to be_a Gitlab::Git::Wiki - end - - it "creates a new wiki repo if one does not yet exist" do - expect(project_wiki.create_page("index", "test content")).to be_truthy - end - - it "creates a new wiki repo with a default commit message" do - expect(project_wiki.create_page("index", "test content", :markdown, "")).to be_truthy - - page = project_wiki.find_page('index') - - expect(page.last_version.message).to eq("#{user.username} created page: index") - end - - it "raises CouldNotCreateWikiError if it can't create the wiki repository" do - # Create a fresh project which will not have a wiki - project_wiki = described_class.new(create(:project), user) - expect(project_wiki.repository).to receive(:create_if_not_exists) { false } - - expect { project_wiki.send(:wiki) }.to raise_exception(ProjectWiki::CouldNotCreateWikiError) - end - end - - describe "#empty?" do - context "when the wiki repository is empty" do - describe '#empty?' do - subject { super().empty? } - - it { is_expected.to be_truthy } - end - end - - context "when the wiki has pages" do - before do - project_wiki.create_page("index", "This is an awesome new Gollum Wiki") - project_wiki.create_page("another-page", "This is another page") - end - - describe '#empty?' do - subject { super().empty? } - - it { is_expected.to be_falsey } - - it 'only instantiates a Wiki page once' do - expect(WikiPage).to receive(:new).once.and_call_original - - subject - end - end - end - end - - describe "#list_pages" do - let(:wiki_pages) { subject.list_pages } - - before do - create_page("index", "This is an index") - create_page("index2", "This is an index2") - create_page("an index3", "This is an index3") - end - - after do - wiki_pages.each do |wiki_page| - destroy_page(wiki_page.page) - end - end - - it "returns an array of WikiPage instances" do - expect(wiki_pages.first).to be_a WikiPage - end - - it 'does not load WikiPage content by default' do - wiki_pages.each do |page| - expect(page.content).to be_empty - end - end - - it 'returns all pages by default' do - expect(wiki_pages.count).to eq(3) - end - - context "with limit option" do - it 'returns limited set of pages' do - expect(subject.list_pages(limit: 1).count).to eq(1) - end - end - - context "with sorting options" do - it 'returns pages sorted by title by default' do - pages = ['an index3', 'index', 'index2'] - - expect(subject.list_pages.map(&:title)).to eq(pages) - expect(subject.list_pages(direction: "desc").map(&:title)).to eq(pages.reverse) - end - - it 'returns pages sorted by created_at' do - pages = ['index', 'index2', 'an index3'] - - expect(subject.list_pages(sort: 'created_at').map(&:title)).to eq(pages) - expect(subject.list_pages(sort: 'created_at', direction: "desc").map(&:title)).to eq(pages.reverse) - end - end - - context "with load_content option" do - let(:pages) { subject.list_pages(load_content: true) } - - it 'loads WikiPage content' do - expect(pages.first.content).to eq("This is an index3") - expect(pages.second.content).to eq("This is an index") - expect(pages.third.content).to eq("This is an index2") - end - end - end - - describe "#find_page" do - before do - create_page("index page", "This is an awesome Gollum Wiki") - end - - after do - subject.list_pages.each { |page| destroy_page(page.page) } - end - - it "returns the latest version of the page if it exists" do - page = subject.find_page("index page") - expect(page.title).to eq("index page") - end - - it "returns nil if the page does not exist" do - expect(subject.find_page("non-existent")).to eq(nil) - end - - it "can find a page by slug" do - page = subject.find_page("index-page") - expect(page.title).to eq("index page") - end - - it "returns a WikiPage instance" do - page = subject.find_page("index page") - expect(page).to be_a WikiPage - end - - context 'pages with multibyte-character title' do - before do - create_page("autre pagé", "C'est un génial Gollum Wiki") - end - - it "can find a page by slug" do - page = subject.find_page("autre pagé") - expect(page.title).to eq("autre pagé") - end - end - - context 'pages with invalidly-encoded content' do - before do - create_page("encoding is fun", "f\xFCr".b) - end - - it "can find the page" do - page = subject.find_page("encoding is fun") - expect(page.content).to eq("fr") + describe '#disk_path' do + it 'returns the repository storage path' do + expect(subject.disk_path).to eq("#{subject.container.disk_path}.wiki") end end - end - - describe '#find_sidebar' do - before do - create_page(described_class::SIDEBAR, 'This is an awesome Sidebar') - end - - after do - subject.list_pages.each { |page| destroy_page(page.page) } - end - - it 'finds the page defined as _sidebar' do - page = subject.find_page('_sidebar') - - expect(page.content).to eq('This is an awesome Sidebar') - end - end - describe '#find_file' do - let(:image) { File.open(Rails.root.join('spec', 'fixtures', 'big-image.png')) } + describe '#update_container_activity' do + it 'updates project activity' do + wiki_container.update!( + last_activity_at: nil, + last_repository_updated_at: nil + ) - before do - subject.wiki # Make sure the wiki repo exists + subject.create_page('Test Page', 'This is content') + wiki_container.reload - repo_path = Gitlab::GitalyClient::StorageSettings.allow_disk_access do - subject.repository.path_to_repo + expect(wiki_container.last_activity_at).to be_within(1.minute).of(Time.now) + expect(wiki_container.last_repository_updated_at).to be_within(1.minute).of(Time.now) end - - BareRepoOperations.new(repo_path).commit_file(image, 'image.png') - end - - it 'returns the latest version of the file if it exists' do - file = subject.find_file('image.png') - expect(file.mime_type).to eq('image/png') - end - - it 'returns nil if the page does not exist' do - expect(subject.find_file('non-existent')).to eq(nil) - end - - it 'returns a Gitlab::Git::WikiFile instance' do - file = subject.find_file('image.png') - expect(file).to be_a Gitlab::Git::WikiFile - end - - it 'returns the whole file' do - file = subject.find_file('image.png') - image.rewind - - expect(file.raw_data.b).to eq(image.read.b) - end - end - - describe "#create_page" do - after do - destroy_page(subject.list_pages.first.page) - end - - it "creates a new wiki page" do - expect(subject.create_page("test page", "this is content")).not_to eq(false) - expect(subject.list_pages.count).to eq(1) - end - - it "returns false when a duplicate page exists" do - subject.create_page("test page", "content") - expect(subject.create_page("test page", "content")).to eq(false) end - - it "stores an error message when a duplicate page exists" do - 2.times { subject.create_page("test page", "content") } - expect(subject.error_message).to match(/Duplicate page:/) - end - - it "sets the correct commit message" do - subject.create_page("test page", "some content", :markdown, "commit message") - expect(subject.list_pages.first.page.version.message).to eq("commit message") - end - - it 'sets the correct commit email' do - subject.create_page('test page', 'content') - - expect(user.commit_email).not_to eq(user.email) - expect(commit.author_email).to eq(user.commit_email) - expect(commit.committer_email).to eq(user.commit_email) - end - - it 'updates project activity' do - subject.create_page('Test Page', 'This is content') - - project.reload - - expect(project.last_activity_at).to be_within(1.minute).of(Time.now) - expect(project.last_repository_updated_at).to be_within(1.minute).of(Time.now) - end - end - - describe "#update_page" do - before do - create_page("update-page", "some content") - @gitlab_git_wiki_page = subject.wiki.page(title: "update-page") - subject.update_page( - @gitlab_git_wiki_page, - content: "some other content", - format: :markdown, - message: "updated page" - ) - @page = subject.list_pages(load_content: true).first.page - end - - after do - destroy_page(@page) - end - - it "updates the content of the page" do - expect(@page.raw_data).to eq("some other content") - end - - it "sets the correct commit message" do - expect(@page.version.message).to eq("updated page") - end - - it 'sets the correct commit email' do - expect(user.commit_email).not_to eq(user.email) - expect(commit.author_email).to eq(user.commit_email) - expect(commit.committer_email).to eq(user.commit_email) - end - - it 'updates project activity' do - subject.update_page( - @gitlab_git_wiki_page, - content: 'Yet more content', - format: :markdown, - message: 'Updated page again' - ) - - project.reload - - expect(project.last_activity_at).to be_within(1.minute).of(Time.now) - expect(project.last_repository_updated_at).to be_within(1.minute).of(Time.now) - end - end - - describe "#delete_page" do - before do - create_page("index", "some content") - @page = subject.wiki.page(title: "index") - end - - it "deletes the page" do - subject.delete_page(@page) - expect(subject.list_pages.count).to eq(0) - end - - it 'sets the correct commit email' do - subject.delete_page(@page) - - expect(user.commit_email).not_to eq(user.email) - expect(commit.author_email).to eq(user.commit_email) - expect(commit.committer_email).to eq(user.commit_email) - end - - it 'updates project activity' do - subject.delete_page(@page) - - project.reload - - expect(project.last_activity_at).to be_within(1.minute).of(Time.now) - expect(project.last_repository_updated_at).to be_within(1.minute).of(Time.now) - end - end - - describe '#ensure_repository' do - let(:project) { create(:project) } - - it 'creates the repository if it not exist' do - expect(raw_repository.exists?).to eq(false) - - subject.ensure_repository - - expect(raw_repository.exists?).to eq(true) - end - - it 'does not create the repository if it exists' do - subject.wiki - expect(raw_repository.exists?).to eq(true) - - expect(subject).not_to receive(:create_repo!) - - subject.ensure_repository - end - end - - describe '#hook_attrs' do - it 'returns a hash with values' do - expect(subject.hook_attrs).to be_a Hash - expect(subject.hook_attrs.keys).to contain_exactly(:web_url, :git_ssh_url, :git_http_url, :path_with_namespace, :default_branch) - end - end - - private - - def create_temp_repo(path) - FileUtils.mkdir_p path - system(*%W(#{Gitlab.config.git.bin_path} init --quiet --bare -- #{path})) - end - - def remove_temp_repo(path) - FileUtils.rm_rf path - end - - def commit_details - Gitlab::Git::Wiki::CommitDetails.new(user.id, user.username, user.name, user.commit_email, "test commit") - end - - def create_page(name, content) - subject.wiki.write_page(name, :markdown, content, commit_details) - end - - def destroy_page(page) - subject.delete_page(page, "test commit") end end diff --git a/spec/models/release_spec.rb b/spec/models/release_spec.rb index 8b1b738ab58..d72fd137f3f 100644 --- a/spec/models/release_spec.rb +++ b/spec/models/release_spec.rb @@ -111,26 +111,6 @@ RSpec.describe Release do end end - describe '#notify_new_release' do - context 'when a release is created' do - it 'instantiates NewReleaseWorker to send notifications' do - expect(NewReleaseWorker).to receive(:perform_async) - - create(:release) - end - end - - context 'when a release is updated' do - let!(:release) { create(:release) } - - it 'does not send any new notification' do - expect(NewReleaseWorker).not_to receive(:perform_async) - - release.update!(description: 'new description') - end - end - end - describe '#name' do context 'name is nil' do before do @@ -143,38 +123,6 @@ RSpec.describe Release do end end - describe '#evidence_sha' do - subject { release.evidence_sha } - - context 'when a release was created before evidence collection existed' do - let!(:release) { create(:release) } - - it { is_expected.to be_nil } - end - - context 'when a release was created with evidence collection' do - let!(:release) { create(:release, :with_evidence) } - - it { is_expected.to eq(release.evidences.first.summary_sha) } - end - end - - describe '#evidence_summary' do - subject { release.evidence_summary } - - context 'when a release was created before evidence collection existed' do - let!(:release) { create(:release) } - - it { is_expected.to eq({}) } - end - - context 'when a release was created with evidence collection' do - let!(:release) { create(:release, :with_evidence) } - - it { is_expected.to eq(release.evidences.first.summary) } - end - end - describe '#milestone_titles' do let(:release) { create(:release, :with_milestones) } diff --git a/spec/models/remote_mirror_spec.rb b/spec/models/remote_mirror_spec.rb index 15b162ae87a..a87cdcf9344 100644 --- a/spec/models/remote_mirror_spec.rb +++ b/spec/models/remote_mirror_spec.rb @@ -143,22 +143,54 @@ describe RemoteMirror, :mailer do end describe '#update_repository' do - let(:git_remote_mirror) { spy } + it 'performs update including options' do + git_remote_mirror = stub_const('Gitlab::Git::RemoteMirror', spy) + mirror = build(:remote_mirror) - before do - stub_const('Gitlab::Git::RemoteMirror', git_remote_mirror) + expect(mirror).to receive(:options_for_update).and_return(keep_divergent_refs: true) + mirror.update_repository + + expect(git_remote_mirror).to have_received(:new).with( + mirror.project.repository.raw, + mirror.remote_name, + keep_divergent_refs: true + ) + expect(git_remote_mirror).to have_received(:update) end + end - it 'includes the `keep_divergent_refs` setting' do + describe '#options_for_update' do + it 'includes the `keep_divergent_refs` option' do mirror = build_stubbed(:remote_mirror, keep_divergent_refs: true) - mirror.update_repository({}) + options = mirror.options_for_update - expect(git_remote_mirror).to have_received(:new).with( - anything, - mirror.remote_name, - hash_including(keep_divergent_refs: true) - ) + expect(options).to include(keep_divergent_refs: true) + end + + it 'includes the `only_branches_matching` option' do + branch = create(:protected_branch) + mirror = build_stubbed(:remote_mirror, project: branch.project, only_protected_branches: true) + + options = mirror.options_for_update + + expect(options).to include(only_branches_matching: [branch.name]) + end + + it 'includes the `ssh_key` option' do + mirror = build(:remote_mirror, :ssh, ssh_private_key: 'private-key') + + options = mirror.options_for_update + + expect(options).to include(ssh_key: 'private-key') + end + + it 'includes the `known_hosts` option' do + mirror = build(:remote_mirror, :ssh, ssh_known_hosts: 'known-hosts') + + options = mirror.options_for_update + + expect(options).to include(known_hosts: 'known-hosts') end end diff --git a/spec/models/repository_spec.rb b/spec/models/repository_spec.rb index ca04bd7a28a..be626dd6e32 100644 --- a/spec/models/repository_spec.rb +++ b/spec/models/repository_spec.rb @@ -2874,4 +2874,80 @@ describe Repository do expect(repository.submodule_links).to be_a(Gitlab::SubmoduleLinks) end end + + describe '#lfs_enabled?' do + let_it_be(:project) { create(:project, :repository, :design_repo, lfs_enabled: true) } + + subject { repository.lfs_enabled? } + + context 'for a project repository' do + let(:repository) { project.repository } + + it 'returns true when LFS is enabled' do + stub_lfs_setting(enabled: true) + + is_expected.to be_truthy + end + + it 'returns false when LFS is disabled' do + stub_lfs_setting(enabled: false) + + is_expected.to be_falsy + end + end + + context 'for a project wiki repository' do + let(:repository) { project.wiki.repository } + + it 'returns true when LFS is enabled' do + stub_lfs_setting(enabled: true) + + is_expected.to be_truthy + end + + it 'returns false when LFS is disabled' do + stub_lfs_setting(enabled: false) + + is_expected.to be_falsy + end + end + + context 'for a project snippet repository' do + let(:snippet) { create(:project_snippet, project: project) } + let(:repository) { snippet.repository } + + it 'returns false when LFS is enabled' do + stub_lfs_setting(enabled: true) + + is_expected.to be_falsy + end + end + + context 'for a personal snippet repository' do + let(:snippet) { create(:personal_snippet) } + let(:repository) { snippet.repository } + + it 'returns false when LFS is enabled' do + stub_lfs_setting(enabled: true) + + is_expected.to be_falsy + end + end + + context 'for a design repository' do + let(:repository) { project.design_repository } + + it 'returns true when LFS is enabled' do + stub_lfs_setting(enabled: true) + + is_expected.to be_truthy + end + + it 'returns false when LFS is disabled' do + stub_lfs_setting(enabled: false) + + is_expected.to be_falsy + end + end + end end diff --git a/spec/models/resource_label_event_spec.rb b/spec/models/resource_label_event_spec.rb index ca887b485a2..a1a2150f461 100644 --- a/spec/models/resource_label_event_spec.rb +++ b/spec/models/resource_label_event_spec.rb @@ -15,9 +15,6 @@ RSpec.describe ResourceLabelEvent, type: :model do it_behaves_like 'a resource event for merge requests' describe 'associations' do - it { is_expected.to belong_to(:user) } - it { is_expected.to belong_to(:issue) } - it { is_expected.to belong_to(:merge_request) } it { is_expected.to belong_to(:label) } end diff --git a/spec/models/resource_milestone_event_spec.rb b/spec/models/resource_milestone_event_spec.rb index bf8672f95c9..3f8d8b4c1df 100644 --- a/spec/models/resource_milestone_event_spec.rb +++ b/spec/models/resource_milestone_event_spec.rb @@ -78,4 +78,21 @@ describe ResourceMilestoneEvent, type: :model do let(:query_method) { :remove? } end end + + describe '#milestone_title' do + let(:milestone) { create(:milestone, title: 'v2.3') } + let(:event) { create(:resource_milestone_event, milestone: milestone) } + + it 'returns the expected title' do + expect(event.milestone_title).to eq('v2.3') + end + + context 'when milestone is nil' do + let(:event) { create(:resource_milestone_event, milestone: nil) } + + it 'returns nil' do + expect(event.milestone_title).to be_nil + end + end + end end diff --git a/spec/models/resource_state_event_spec.rb b/spec/models/resource_state_event_spec.rb new file mode 100644 index 00000000000..986a13cbd0d --- /dev/null +++ b/spec/models/resource_state_event_spec.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe ResourceStateEvent, type: :model do + subject { build(:resource_state_event, issue: issue) } + + let(:issue) { create(:issue) } + let(:merge_request) { create(:merge_request) } + + it_behaves_like 'a resource event' + it_behaves_like 'a resource event for issues' + it_behaves_like 'a resource event for merge requests' +end diff --git a/spec/models/sent_notification_spec.rb b/spec/models/sent_notification_spec.rb index fedaae372c4..087bc957373 100644 --- a/spec/models/sent_notification_spec.rb +++ b/spec/models/sent_notification_spec.rb @@ -326,4 +326,26 @@ describe SentNotification do end end end + + describe "#position=" do + subject { build(:sent_notification, noteable: create(:issue)) } + + it "doesn't accept non-hash JSON passed as a string" do + subject.position = "true" + + expect(subject.attributes_before_type_cast["position"]).to be(nil) + end + + it "does accept a position hash as a string" do + subject.position = '{ "base_sha": "test" }' + + expect(subject.position.base_sha).to eq("test") + end + + it "does accept a hash" do + subject.position = { "base_sha" => "test" } + + expect(subject.position.base_sha).to eq("test") + end + end end diff --git a/spec/models/service_spec.rb b/spec/models/service_spec.rb index cb8122b6573..106f8def42d 100644 --- a/spec/models/service_spec.rb +++ b/spec/models/service_spec.rb @@ -87,6 +87,20 @@ describe Service do end end + describe '#operating?' do + it 'is false when the service is not active' do + expect(build(:service).operating?).to eq(false) + end + + it 'is false when the service is not persisted' do + expect(build(:service, active: true).operating?).to eq(false) + end + + it 'is true when the service is active and persisted' do + expect(create(:service, active: true).operating?).to eq(true) + end + end + describe '.confidential_note_hooks' do it 'includes services where confidential_note_events is true' do create(:service, active: true, confidential_note_events: true) @@ -523,24 +537,6 @@ describe Service do end end - describe "#deprecated?" do - let(:project) { create(:project, :repository) } - - it 'returns false by default' do - service = create(:service, project: project) - expect(service.deprecated?).to be_falsy - end - end - - describe "#deprecation_message" do - let(:project) { create(:project, :repository) } - - it 'is empty by default' do - service = create(:service, project: project) - expect(service.deprecation_message).to be_nil - end - end - describe '#api_field_names' do let(:fake_service) do Class.new(Service) do diff --git a/spec/models/snippet_repository_spec.rb b/spec/models/snippet_repository_spec.rb index dc9f9a95d24..255f07ebfa5 100644 --- a/spec/models/snippet_repository_spec.rb +++ b/spec/models/snippet_repository_spec.rb @@ -202,6 +202,38 @@ describe SnippetRepository do it_behaves_like 'snippet repository with file names', 'snippetfile10.txt', 'snippetfile11.txt' end + + shared_examples 'snippet repository with git errors' do |path, error| + let(:new_file) { { file_path: path, content: 'bar' } } + + it 'raises a path specific error' do + expect do + snippet_repository.multi_files_action(user, data, commit_opts) + end.to raise_error(error) + end + end + + context 'with git errors' do + it_behaves_like 'snippet repository with git errors', 'invalid://path/here', described_class::InvalidPathError + it_behaves_like 'snippet repository with git errors', '../../path/traversal/here', described_class::InvalidPathError + it_behaves_like 'snippet repository with git errors', 'README', described_class::CommitError + + context 'when user name is invalid' do + let(:user) { create(:user, name: '.') } + + it_behaves_like 'snippet repository with git errors', 'non_existing_file', described_class::InvalidSignatureError + end + + context 'when user email is empty' do + let(:user) { create(:user) } + + before do + user.update_column(:email, '') + end + + it_behaves_like 'snippet repository with git errors', 'non_existing_file', described_class::InvalidSignatureError + end + end end def blob_at(snippet, path) diff --git a/spec/models/snippet_spec.rb b/spec/models/snippet_spec.rb index 2061084d5ea..4d6586c1df4 100644 --- a/spec/models/snippet_spec.rb +++ b/spec/models/snippet_spec.rb @@ -180,22 +180,6 @@ describe Snippet do end end - describe '.search_code' do - let(:snippet) { create(:snippet, content: 'class Foo; end') } - - it 'returns snippets with matching content' do - expect(described_class.search_code(snippet.content)).to eq([snippet]) - end - - it 'returns snippets with partially matching content' do - expect(described_class.search_code('class')).to eq([snippet]) - end - - it 'returns snippets with matching content regardless of the casing' do - expect(described_class.search_code('FOO')).to eq([snippet]) - end - end - describe 'when default snippet visibility set to internal' do using RSpec::Parameterized::TableSyntax @@ -545,11 +529,11 @@ describe Snippet do let(:snippet) { build(:snippet) } it 'excludes secret_token from generated json' do - expect(JSON.parse(to_json).keys).not_to include("secret_token") + expect(Gitlab::Json.parse(to_json).keys).not_to include("secret_token") end it 'does not override existing exclude option value' do - expect(JSON.parse(to_json(except: [:id])).keys).not_to include("secret_token", "id") + expect(Gitlab::Json.parse(to_json(except: [:id])).keys).not_to include("secret_token", "id") end def to_json(params = {}) @@ -735,31 +719,35 @@ describe Snippet do end end - describe '#versioned_enabled_for?' do - let_it_be(:user) { create(:user) } + describe '#url_to_repo' do + subject { snippet.url_to_repo } + + context 'with personal snippet' do + let(:snippet) { create(:personal_snippet) } - subject { snippet.versioned_enabled_for?(user) } + it { is_expected.to eq(Gitlab.config.gitlab_shell.ssh_path_prefix + "snippets/#{snippet.id}.git") } + end - context 'with repository and version_snippets enabled' do - let!(:snippet) { create(:personal_snippet, :repository, author: user) } + context 'with project snippet' do + let(:snippet) { create(:project_snippet) } - it { is_expected.to be_truthy } + it { is_expected.to eq(Gitlab.config.gitlab_shell.ssh_path_prefix + "#{snippet.project.full_path}/snippets/#{snippet.id}.git") } end + end - context 'without repository' do - let!(:snippet) { create(:personal_snippet, author: user) } + describe '.max_file_limit' do + subject { described_class.max_file_limit(nil) } - it { is_expected.to be_falsy } + it "returns #{Snippet::MAX_FILE_COUNT}" do + expect(subject).to eq Snippet::MAX_FILE_COUNT end - context 'without version_snippets feature disabled' do - let!(:snippet) { create(:personal_snippet, :repository, author: user) } + context 'when feature flag :snippet_multiple_files is disabled' do + it "returns #{described_class::MAX_SINGLE_FILE_COUNT}" do + stub_feature_flags(snippet_multiple_files: false) - before do - stub_feature_flags(version_snippets: false) + expect(subject).to eq described_class::MAX_SINGLE_FILE_COUNT end - - it { is_expected.to be_falsy } end end end diff --git a/spec/models/spam_log_spec.rb b/spec/models/spam_log_spec.rb index 8ebd97de9ff..8d0f247b5d6 100644 --- a/spec/models/spam_log_spec.rb +++ b/spec/models/spam_log_spec.rb @@ -20,15 +20,30 @@ describe SpamLog do expect { spam_log.remove_user(deleted_by: admin) }.to change { spam_log.user.blocked? }.to(true) end - it 'removes the user', :sidekiq_might_not_need_inline do - spam_log = build(:spam_log) - user = spam_log.user + context 'when admin mode is enabled', :enable_admin_mode do + it 'removes the user', :sidekiq_might_not_need_inline do + spam_log = build(:spam_log) + user = spam_log.user + + perform_enqueued_jobs do + spam_log.remove_user(deleted_by: admin) + end - perform_enqueued_jobs do - spam_log.remove_user(deleted_by: admin) + expect { User.find(user.id) }.to raise_error(ActiveRecord::RecordNotFound) end + end - expect { User.find(user.id) }.to raise_error(ActiveRecord::RecordNotFound) + context 'when admin mode is disabled' do + it 'does not allow to remove the user', :sidekiq_might_not_need_inline do + spam_log = build(:spam_log) + user = spam_log.user + + perform_enqueued_jobs do + spam_log.remove_user(deleted_by: admin) + end + + expect(User.exists?(user.id)).to be(true) + end end end diff --git a/spec/models/state_note_spec.rb b/spec/models/state_note_spec.rb new file mode 100644 index 00000000000..d3409315e41 --- /dev/null +++ b/spec/models/state_note_spec.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe StateNote do + describe '.from_event' do + let_it_be(:author) { create(:user) } + let_it_be(:project) { create(:project, :repository) } + let_it_be(:noteable) { create(:issue, author: author, project: project) } + + ResourceStateEvent.states.each do |state, _value| + context "with event state #{state}" do + let_it_be(:event) { create(:resource_state_event, issue: noteable, state: state, created_at: '2020-02-05') } + + subject { described_class.from_event(event, resource: noteable, resource_parent: project) } + + it_behaves_like 'a system note', exclude_project: true do + let(:action) { state.to_s } + end + + it 'contains the expected values' do + expect(subject.author).to eq(author) + expect(subject.created_at).to eq(event.created_at) + expect(subject.note_html).to eq("<p dir=\"auto\">#{state}</p>") + end + end + end + end +end diff --git a/spec/models/timelog_spec.rb b/spec/models/timelog_spec.rb index 33c1afad59f..ae1697fb7e6 100644 --- a/spec/models/timelog_spec.rb +++ b/spec/models/timelog_spec.rb @@ -56,12 +56,12 @@ RSpec.describe Timelog do end end - describe 'between_dates' do - it 'returns collection of timelogs within given dates' do + describe 'between_times' do + it 'returns collection of timelogs within given times' do create(:timelog, spent_at: 65.days.ago) timelog1 = create(:timelog, spent_at: 15.days.ago) timelog2 = create(:timelog, spent_at: 5.days.ago) - timelogs = described_class.between_dates(20.days.ago, 1.day.ago) + timelogs = described_class.between_times(20.days.ago, 1.day.ago) expect(timelogs).to contain_exactly(timelog1, timelog2) end diff --git a/spec/models/todo_spec.rb b/spec/models/todo_spec.rb index 3f0c95b2513..e125f58399e 100644 --- a/spec/models/todo_spec.rb +++ b/spec/models/todo_spec.rb @@ -61,11 +61,13 @@ describe Todo do describe '#done' do it 'changes state to done' do todo = create(:todo, state: :pending) + expect { todo.done }.to change(todo, :state).from('pending').to('done') end it 'does not raise error when is already done' do todo = create(:todo, state: :done) + expect { todo.done }.not_to raise_error end end @@ -73,15 +75,31 @@ describe Todo do describe '#for_commit?' do it 'returns true when target is a commit' do subject.target_type = 'Commit' + expect(subject.for_commit?).to eq true end it 'returns false when target is an issuable' do subject.target_type = 'Issue' + expect(subject.for_commit?).to eq false end end + describe '#for_design?' do + it 'returns true when target is a Design' do + subject.target_type = 'DesignManagement::Design' + + expect(subject.for_design?).to eq(true) + end + + it 'returns false when target is not a Design' do + subject.target_type = 'Issue' + + expect(subject.for_design?).to eq(false) + end + end + describe '#target' do context 'for commits' do let(:project) { create(:project, :repository) } @@ -108,6 +126,7 @@ describe Todo do it 'returns the issuable for issuables' do subject.target_id = issue.id subject.target_type = issue.class.name + expect(subject.target).to eq issue end end @@ -126,6 +145,7 @@ describe Todo do it 'returns full reference for issuables' do subject.target = issue + expect(subject.target_reference).to eq issue.to_reference(full: false) end end @@ -389,5 +409,17 @@ describe Todo do expect(described_class.update_state(:pending)).to be_empty end + + it 'updates updated_at' do + create(:todo, :pending) + + Timecop.freeze(1.day.from_now) do + expected_update_date = Time.now.utc + + ids = described_class.update_state(:done) + + expect(Todo.where(id: ids).map(&:updated_at)).to all(be_like_time(expected_update_date)) + end + end end end diff --git a/spec/models/tree_spec.rb b/spec/models/tree_spec.rb index c2d5dfdf9c4..7dde8459f9a 100644 --- a/spec/models/tree_spec.rb +++ b/spec/models/tree_spec.rb @@ -9,15 +9,18 @@ describe Tree do subject { described_class.new(repository, '54fcc214') } describe '#readme' do - class FakeBlob - attr_reader :name - - def initialize(name) - @name = name - end - - def readme? - name =~ /^readme/i + before do + stub_const('FakeBlob', Class.new) + FakeBlob.class_eval do + attr_reader :name + + def initialize(name) + @name = name + end + + def readme? + name =~ /^readme/i + end end end diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 8597397c3c6..94a3f6bafea 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -describe User, :do_not_mock_admin_mode do +describe User do include ProjectForksHelper include TermsHelper include ExclusiveLeaseHelpers @@ -17,6 +17,7 @@ describe User, :do_not_mock_admin_mode do it { is_expected.to include_module(Sortable) } it { is_expected.to include_module(TokenAuthenticatable) } it { is_expected.to include_module(BlocksJsonSerialization) } + it { is_expected.to include_module(AsyncDeviseEmail) } end describe 'delegations' do @@ -54,6 +55,7 @@ describe User, :do_not_mock_admin_mode do it { is_expected.to have_many(:reported_abuse_reports).dependent(:destroy).class_name('AbuseReport') } it { is_expected.to have_many(:custom_attributes).class_name('UserCustomAttribute') } it { is_expected.to have_many(:releases).dependent(:nullify) } + it { is_expected.to have_many(:metrics_users_starred_dashboards).inverse_of(:user) } describe "#bio" do it 'syncs bio with `user_details.bio` on create' do @@ -164,6 +166,18 @@ describe User, :do_not_mock_admin_mode do end end + describe 'Devise emails' do + let!(:user) { create(:user) } + + describe 'behaviour' do + it 'sends emails asynchronously' do + expect do + user.update!(email: 'hello@hello.com') + end.to have_enqueued_job.on_queue('mailers').exactly(:twice) + end + end + end + describe 'validations' do describe 'password' do let!(:user) { create(:user) } @@ -295,7 +309,7 @@ describe User, :do_not_mock_admin_mode do subject { build(:user) } end - it_behaves_like 'an object with email-formated attributes', :public_email, :notification_email do + it_behaves_like 'an object with RFC3696 compliant email-formated attributes', :public_email, :notification_email do subject { build(:user).tap { |user| user.emails << build(:email, email: email_value) } } end @@ -538,18 +552,6 @@ describe User, :do_not_mock_admin_mode do expect(user).to be_valid end - context 'when feature flag is turned off' do - before do - stub_feature_flags(email_restrictions: false) - end - - it 'does accept the email address' do - user = build(:user, email: 'info+1@test.com') - - expect(user).to be_valid - end - end - context 'when created_by_id is set' do it 'does accept the email address' do user = build(:user, email: 'info+1@test.com', created_by_id: 1) @@ -813,7 +815,7 @@ describe User, :do_not_mock_admin_mode do describe '.active_without_ghosts' do let_it_be(:user1) { create(:user, :external) } let_it_be(:user2) { create(:user, state: 'blocked') } - let_it_be(:user3) { create(:user, ghost: true) } + let_it_be(:user3) { create(:user, :ghost) } let_it_be(:user4) { create(:user) } it 'returns all active users but ghost users' do @@ -824,7 +826,7 @@ describe User, :do_not_mock_admin_mode do describe '.without_ghosts' do let_it_be(:user1) { create(:user, :external) } let_it_be(:user2) { create(:user, state: 'blocked') } - let_it_be(:user3) { create(:user, ghost: true) } + let_it_be(:user3) { create(:user, :ghost) } it 'returns users without ghosts users' do expect(described_class.without_ghosts).to match_array([user1, user2]) @@ -927,7 +929,6 @@ describe User, :do_not_mock_admin_mode do user.tap { |u| u.update!(email: new_email) }.reload end.to change(user, :unconfirmed_email).to(new_email) end - it 'does not change :notification_email' do expect do user.tap { |u| u.update!(email: new_email) }.reload @@ -3275,7 +3276,6 @@ describe User, :do_not_mock_admin_mode do expect(ghost.namespace).not_to be_nil expect(ghost.namespace).to be_persisted expect(ghost.user_type).to eq 'ghost' - expect(ghost.ghost).to eq true end it "does not create a second ghost user if one is already present" do @@ -4077,7 +4077,7 @@ describe User, :do_not_mock_admin_mode do context 'in single-user environment' do it 'requires user consent after one week' do - create(:user, ghost: true) + create(:user, :ghost) expect(user.requires_usage_stats_consent?).to be true end @@ -4355,31 +4355,15 @@ describe User, :do_not_mock_admin_mode do end end - describe 'internal methods' do - let_it_be(:user) { create(:user) } - let_it_be(:ghost) { described_class.ghost } - let_it_be(:alert_bot) { described_class.alert_bot } - let_it_be(:project_bot) { create(:user, :project_bot) } - let_it_be(:non_internal) { [user, project_bot] } - let_it_be(:internal) { [ghost, alert_bot] } + describe '.active_without_ghosts' do + let_it_be(:user1) { create(:user, :external) } + let_it_be(:user2) { create(:user, state: 'blocked') } + let_it_be(:user3) { create(:user, :ghost) } + let_it_be(:user4) { create(:user, user_type: :support_bot) } + let_it_be(:user5) { create(:user, state: 'blocked', user_type: :support_bot) } - it 'returns internal users' do - expect(described_class.internal).to match_array(internal) - expect(internal.all?(&:internal?)).to eq(true) - end - - it 'returns non internal users' do - expect(described_class.non_internal).to match_array(non_internal) - expect(non_internal.all?(&:internal?)).to eq(false) - end - - describe '#bot?' do - it 'marks bot users' do - expect(user.bot?).to eq(false) - expect(ghost.bot?).to eq(false) - - expect(alert_bot.bot?).to eq(true) - end + it 'returns all active users including active bots but ghost users' do + expect(described_class.active_without_ghosts).to match_array([user1, user4]) end end @@ -4417,19 +4401,6 @@ describe User, :do_not_mock_admin_mode do end end - describe 'bots & humans' do - it 'returns corresponding users' do - human = create(:user) - bot = create(:user, :bot) - project_bot = create(:user, :project_bot) - - expect(described_class.humans).to match_array([human]) - expect(described_class.bots).to match_array([bot, project_bot]) - expect(described_class.bots_without_project_bot).to match_array([bot]) - expect(described_class.with_project_bots).to match_array([human, project_bot]) - end - end - describe '#hook_attrs' do it 'includes name, username, avatar_url, and email' do user = create(:user) @@ -4458,45 +4429,6 @@ describe User, :do_not_mock_admin_mode do end end - describe '#gitlab_employee?' do - using RSpec::Parameterized::TableSyntax - - subject { user.gitlab_employee? } - - where(:email, :is_com, :expected_result) do - 'test@gitlab.com' | true | true - 'test@example.com' | true | false - 'test@gitlab.com' | false | false - 'test@example.com' | false | false - end - - with_them do - let(:user) { build(:user, email: email) } - - before do - allow(Gitlab).to receive(:com?).and_return(is_com) - end - - it { is_expected.to be expected_result } - end - - context 'when email is of Gitlab and is not confirmed' do - let(:user) { build(:user, email: 'test@gitlab.com', confirmed_at: nil) } - - it { is_expected.to be false } - end - - context 'when `:gitlab_employee_badge` feature flag is disabled' do - let(:user) { build(:user, email: 'test@gitlab.com') } - - before do - stub_feature_flags(gitlab_employee_badge: false) - end - - it { is_expected.to be false } - end - end - describe '#current_highest_access_level' do let_it_be(:user) { create(:user) } @@ -4517,27 +4449,6 @@ describe User, :do_not_mock_admin_mode do end end - describe '#organization' do - using RSpec::Parameterized::TableSyntax - - let(:user) { build(:user, organization: 'ACME') } - - subject { user.organization } - - where(:gitlab_employee?, :expected_result) do - true | 'GitLab' - false | 'ACME' - end - - with_them do - before do - allow(user).to receive(:gitlab_employee?).and_return(gitlab_employee?) - end - - it { is_expected.to eql(expected_result) } - end - end - context 'when after_commit :update_highest_role' do describe 'create user' do subject { create(:user) } @@ -4563,7 +4474,7 @@ describe User, :do_not_mock_admin_mode do where(:attributes) do [ { state: 'blocked' }, - { ghost: true }, + { user_type: :ghost }, { user_type: :alert_bot } ] end @@ -4606,7 +4517,7 @@ describe User, :do_not_mock_admin_mode do context 'when user is a ghost user' do before do - user.update(ghost: true) + user.update(user_type: :ghost) end it { is_expected.to be false } @@ -4645,7 +4556,7 @@ describe User, :do_not_mock_admin_mode do context 'when user is an internal user' do before do - user.update(ghost: true) + user.update(user_type: :ghost) end it { is_expected.to be User::LOGIN_FORBIDDEN } @@ -4685,4 +4596,20 @@ describe User, :do_not_mock_admin_mode do it_behaves_like 'does not require password to be present' end end + + describe '#migration_bot' do + it 'creates the user if it does not exist' do + expect do + described_class.migration_bot + end.to change { User.where(user_type: :migration_bot).count }.by(1) + end + + it 'does not create a new user if it already exists' do + described_class.migration_bot + + expect do + described_class.migration_bot + end.not_to change { User.count } + end + end end diff --git a/spec/models/user_type_enums_spec.rb b/spec/models/user_type_enums_spec.rb deleted file mode 100644 index 4f56e6ea96e..00000000000 --- a/spec/models/user_type_enums_spec.rb +++ /dev/null @@ -1,13 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -describe UserTypeEnums do - it '.types' do - expect(described_class.types.keys).to include('alert_bot', 'project_bot', 'human', 'ghost') - end - - it '.bots' do - expect(described_class.bots.keys).to include('alert_bot', 'project_bot') - end -end diff --git a/spec/models/wiki_page/meta_spec.rb b/spec/models/wiki_page/meta_spec.rb index f9bfc31ba64..0255dd802cf 100644 --- a/spec/models/wiki_page/meta_spec.rb +++ b/spec/models/wiki_page/meta_spec.rb @@ -3,7 +3,7 @@ require 'spec_helper' describe WikiPage::Meta do - let_it_be(:project) { create(:project) } + let_it_be(:project) { create(:project, :wiki_repo) } let_it_be(:other_project) { create(:project) } describe 'Associations' do @@ -169,8 +169,11 @@ describe WikiPage::Meta do described_class.find_or_create(last_known_slug, wiki_page) end - def create_previous_version(title = old_title, slug = last_known_slug) - create(:wiki_page_meta, title: title, project: project, canonical_slug: slug) + 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) end def create_context @@ -198,6 +201,8 @@ describe WikiPage::Meta do title: wiki_page.title, project: wiki_page.wiki.project ) + expect(meta.updated_at).to eq(wiki_page.version.commit.committed_date) + expect(meta.created_at).not_to be_after(meta.updated_at) expect(meta.slugs.where(slug: last_known_slug)).to exist expect(meta.slugs.canonical.where(slug: wiki_page.slug)).to exist end @@ -209,22 +214,32 @@ describe WikiPage::Meta do end end - context 'the slug is too long' do - let(:last_known_slug) { FFaker::Lorem.characters(2050) } + context 'there are problems' do + context 'the slug is too long' do + let(:last_known_slug) { FFaker::Lorem.characters(2050) } - it 'raises an error' do - expect { find_record }.to raise_error ActiveRecord::ValueTooLong + it 'raises an error' do + expect { find_record }.to raise_error ActiveRecord::ValueTooLong + end end - end - context 'a conflicting record exists' do - before do - create(:wiki_page_meta, project: project, canonical_slug: last_known_slug) - create(:wiki_page_meta, project: project, canonical_slug: current_slug) + context 'a conflicting record exists' do + before do + create(:wiki_page_meta, project: project, canonical_slug: last_known_slug) + create(:wiki_page_meta, project: project, canonical_slug: current_slug) + end + + it 'raises an error' do + expect { find_record }.to raise_error(ActiveRecord::RecordInvalid) + end end - it 'raises an error' do - expect { find_record }.to raise_error(ActiveRecord::RecordInvalid) + context 'the wiki page is not valid' do + let(:wiki_page) { build(:wiki_page, project: project, title: nil) } + + it 'raises an error' do + expect { find_record }.to raise_error(described_class::WikiPageInvalid) + end end end @@ -258,6 +273,17 @@ describe WikiPage::Meta do end end + context 'the commit happened a day ago' do + before do + allow(wiki_page.version.commit).to receive(:committed_date).and_return(1.day.ago) + end + + include_examples 'metadata examples' do + # Identical to the base case. + let(:query_limit) { 5 } + end + end + context 'the last_known_slug is the same as the current slug, as on creation' do let(:last_known_slug) { current_slug } @@ -292,6 +318,33 @@ describe WikiPage::Meta do end end + context 'a record exists in the DB, but we need to update timestamps' do + let(:last_known_slug) { current_slug } + let(:old_title) { title } + + before do + create_previous_version(date: 1.week.ago) + end + + include_examples 'metadata examples' do + # We need the query, and the update + # SAVEPOINT active_record_2 + # + # SELECT * FROM wiki_page_meta + # INNER JOIN wiki_page_slugs + # ON wiki_page_slugs.wiki_page_meta_id = wiki_page_meta.id + # WHERE wiki_page_meta.project_id = ? + # AND wiki_page_slugs.canonical = TRUE + # AND wiki_page_slugs.slug = ? + # LIMIT 2 + # + # UPDATE wiki_page_meta SET updated_at = ?date WHERE id = ?id + # + # RELEASE SAVEPOINT active_record_2 + let(:query_limit) { 4 } + end + end + context 'we need to update the slug, but not the title' do let(:old_title) { title } @@ -359,14 +412,14 @@ describe WikiPage::Meta do end context 'we want to change the slug back to a previous version' do - let(:slug_1) { 'foo' } - let(:slug_2) { 'bar' } + let(:slug_1) { generate(:sluggified_title) } + let(:slug_2) { generate(:sluggified_title) } let(:wiki_page) { create(:wiki_page, title: slug_1, project: project) } let(:last_known_slug) { slug_2 } before do - meta = create_previous_version(title, slug_1) + meta = create_previous_version(title: title, slug: slug_1) meta.canonical_slug = slug_2 end diff --git a/spec/models/wiki_page_spec.rb b/spec/models/wiki_page_spec.rb index 718b386b3fd..201dc85daf8 100644 --- a/spec/models/wiki_page_spec.rb +++ b/spec/models/wiki_page_spec.rb @@ -3,20 +3,11 @@ require "spec_helper" describe WikiPage do - let(:project) { create(:project, :wiki_repo) } - let(:user) { project.owner } - let(:wiki) { ProjectWiki.new(project, user) } - - let(:new_page) do - described_class.new(wiki).tap do |page| - page.attributes = { title: 'test page', content: 'test content' } - end - end - - let(:existing_page) do - create_page('test page', 'test content') - wiki.find_page('test page') - end + let_it_be(:user) { create(:user) } + let(:container) { create(:project, :wiki_repo) } + let(:wiki) { Wiki.for_container(container, user) } + let(:new_page) { build(:wiki_page, wiki: wiki, title: 'test page', content: 'test content') } + let(:existing_page) { create(:wiki_page, wiki: wiki, title: 'test page', content: 'test content', message: 'test commit') } subject { new_page } @@ -24,11 +15,8 @@ describe WikiPage do stub_feature_flags(Gitlab::WikiPages::FrontMatterParser::FEATURE_FLAG => false) end - def enable_front_matter_for_project - stub_feature_flags(Gitlab::WikiPages::FrontMatterParser::FEATURE_FLAG => { - thing: project, - enabled: true - }) + def enable_front_matter_for(thing) + stub_feature_flags(Gitlab::WikiPages::FrontMatterParser::FEATURE_FLAG => thing) end describe '.group_by_directory' do @@ -41,13 +29,13 @@ describe WikiPage do context 'when there are pages' do before do - create_page('dir_1/dir_1_1/page_3', 'content') - create_page('page_1', 'content') - create_page('dir_1/page_2', 'content') - create_page('dir_2', 'page with dir name') - create_page('dir_2/page_5', 'content') - create_page('page_6', 'content') - create_page('dir_2/page_4', 'content') + wiki.create_page('dir_1/dir_1_1/page_3', 'content') + wiki.create_page('page_1', 'content') + wiki.create_page('dir_1/page_2', 'content') + wiki.create_page('dir_2', 'page with dir name') + wiki.create_page('dir_2/page_5', 'content') + wiki.create_page('page_6', 'content') + wiki.create_page('dir_2/page_4', 'content') end let(:page_1) { wiki.find_page('page_1') } @@ -114,7 +102,8 @@ describe WikiPage do describe '#front_matter' do let_it_be(:project) { create(:project) } - let(:wiki_page) { create(:wiki_page, project: project, content: content) } + let(:container) { project } + let(:wiki_page) { create(:wiki_page, container: container, content: content) } shared_examples 'a page without front-matter' do it { expect(wiki_page).to have_attributes(front_matter: {}, content: content) } @@ -153,9 +142,9 @@ describe WikiPage do it_behaves_like 'a page without front-matter' - context 'but enabled for the project' do + context 'but enabled for the container' do before do - enable_front_matter_for_project + enable_front_matter_for(container) end it_behaves_like 'a page with front-matter' @@ -344,7 +333,7 @@ describe WikiPage do context 'with an existing page title exceeding the limit' do subject do title = 'a' * (max_title + 1) - create_page(title, 'content') + wiki.create_page(title, 'content') wiki.find_page(title) end @@ -388,6 +377,20 @@ describe WikiPage do expect(wiki.find_page("Index").message).to eq 'Custom Commit Message' end + + it 'if the title is preceded by a / it is removed' do + subject.create(attributes.merge(title: '/New Page')) + + expect(wiki.find_page('New Page')).not_to be_nil + end + end + + context "with invalid attributes" do + it 'does not create the page' do + subject.create(title: '') + + expect(wiki.find_page('New Page')).to be_nil + end end end @@ -410,14 +413,11 @@ describe WikiPage do end end - describe "#update" do - subject do - create_page(title, "content") - wiki.find_page(title) - end + describe '#update' do + subject { create(:wiki_page, wiki: wiki, title: title) } - it "updates the content of the page" do - subject.update(content: "new content") + it 'updates the content of the page' do + subject.update(content: 'new content') page = wiki.find_page(title) expect([subject.content, page.content]).to all(eq('new content')) @@ -429,24 +429,6 @@ describe WikiPage do end end - describe '#create' do - context 'with valid attributes' do - it 'raises an error if a page with the same path already exists' do - create_page('New Page', 'content') - create_page('foo/bar', 'content') - - expect { create_page('New Page', 'other content') }.to raise_error Gitlab::Git::Wiki::DuplicatePageError - expect { create_page('foo/bar', 'other content') }.to raise_error Gitlab::Git::Wiki::DuplicatePageError - end - - it 'if the title is preceded by a / it is removed' do - create_page('/New Page', 'content') - - expect(wiki.find_page('New Page')).not_to be_nil - end - end - end - describe "#update" do subject { existing_page } @@ -514,9 +496,9 @@ describe WikiPage do expect([subject, page]).to all(have_attributes(front_matter: be_empty, content: content)) end - context 'but it is enabled for the project' do + context 'but it is enabled for the container' do before do - enable_front_matter_for_project + enable_front_matter_for(container) end it_behaves_like 'able to update front-matter' @@ -556,7 +538,7 @@ describe WikiPage do context 'when renaming a page' do it 'raises an error if the page already exists' do - create_page('Existing Page', 'content') + wiki.create_page('Existing Page', 'content') expect { subject.update(title: 'Existing Page', content: 'new_content') }.to raise_error(WikiPage::PageRenameError) expect(subject.title).to eq 'test page' @@ -578,7 +560,7 @@ describe WikiPage do context 'when moving a page' do it 'raises an error if the page already exists' do - create_page('foo/Existing Page', 'content') + wiki.create_page('foo/Existing Page', 'content') expect { subject.update(title: 'foo/Existing Page', content: 'new_content') }.to raise_error(WikiPage::PageRenameError) expect(subject.title).to eq 'test page' @@ -598,10 +580,7 @@ describe WikiPage do end context 'in subdir' do - subject do - create_page('foo/Existing Page', 'content') - wiki.find_page('foo/Existing Page') - end + subject { create(:wiki_page, wiki: wiki, title: 'foo/Existing Page') } it 'moves the page to the root folder if the title is preceded by /' do expect(subject.slug).to eq 'foo/Existing-Page' @@ -639,7 +618,7 @@ describe WikiPage do end end - describe "#destroy" do + describe "#delete" do subject { existing_page } it "deletes the page" do @@ -671,10 +650,7 @@ describe WikiPage do using RSpec::Parameterized::TableSyntax let(:untitled_page) { described_class.new(wiki) } - let(:directory_page) do - create_page('parent directory/child page', 'test content') - wiki.find_page('parent directory/child page') - end + let(:directory_page) { create(:wiki_page, title: 'parent directory/child page') } where(:page, :title, :changed) do :untitled_page | nil | false @@ -737,10 +713,7 @@ describe WikiPage do end context 'when the page is inside an actual directory' do - subject do - create_page('dir_1/dir_1_1/file', 'content') - wiki.find_page('dir_1/dir_1_1/file') - end + subject { create(:wiki_page, title: 'dir_1/dir_1_1/file') } it 'returns the full directory hierarchy' do expect(subject.directory).to eq('dir_1/dir_1_1') @@ -787,6 +760,16 @@ describe WikiPage do end end + describe '#persisted?' do + it 'returns true for a persisted page' do + expect(existing_page).to be_persisted + end + + it 'returns false for an unpersisted page' do + expect(new_page).not_to be_persisted + end + end + describe '#to_partial_path' do it 'returns the relative path to the partial to be used' do expect(subject.to_partial_path).to eq('projects/wikis/wiki_page') @@ -812,23 +795,23 @@ describe WikiPage do other_page = create(:wiki_page) expect(subject.slug).not_to eq(other_page.slug) - expect(subject.project).not_to eq(other_page.project) + expect(subject.container).not_to eq(other_page.container) expect(subject).not_to eq(other_page) end - it 'returns false for page with different slug on same project' do - other_page = create(:wiki_page, project: subject.project) + it 'returns false for page with different slug on same container' do + other_page = create(:wiki_page, container: subject.container) expect(subject.slug).not_to eq(other_page.slug) - expect(subject.project).to eq(other_page.project) + expect(subject.container).to eq(other_page.container) expect(subject).not_to eq(other_page) end - it 'returns false for page with the same slug on a different project' do + it 'returns false for page with the same slug on a different container' do other_page = create(:wiki_page, title: existing_page.slug) expect(subject.slug).to eq(other_page.slug) - expect(subject.project).not_to eq(other_page.project) + expect(subject.container).not_to eq(other_page.container) expect(subject).not_to eq(other_page) end end @@ -858,19 +841,21 @@ describe WikiPage do end end - private - - def remove_temp_repo(path) - FileUtils.rm_rf path - end + describe '#version_commit_timestamp' do + context 'for a new page' do + it 'returns nil' do + expect(new_page.version_commit_timestamp).to be_nil + end + end - def commit_details - Gitlab::Git::Wiki::CommitDetails.new(user.id, user.username, user.name, user.email, "test commit") + context 'for page that exists' do + it 'returns the timestamp of the commit' do + expect(existing_page.version_commit_timestamp).to eq(existing_page.version.commit.committed_date) + end + end end - def create_page(name, content) - wiki.wiki.write_page(name, :markdown, content, commit_details) - end + private def get_slugs(page_or_dir) if page_or_dir.is_a? WikiPage diff --git a/spec/models/x509_commit_signature_spec.rb b/spec/models/x509_commit_signature_spec.rb index a2f72228a86..2efb77c96ad 100644 --- a/spec/models/x509_commit_signature_spec.rb +++ b/spec/models/x509_commit_signature_spec.rb @@ -9,6 +9,15 @@ RSpec.describe X509CommitSignature do let(:x509_certificate) { create(:x509_certificate) } let(:x509_signature) { create(:x509_commit_signature, commit_sha: commit_sha) } + let(:attributes) do + { + commit_sha: commit_sha, + project: project, + x509_certificate_id: x509_certificate.id, + verification_status: "verified" + } + end + it_behaves_like 'having unique enum values' describe 'validation' do @@ -23,15 +32,6 @@ RSpec.describe X509CommitSignature do end describe '.safe_create!' do - let(:attributes) do - { - commit_sha: commit_sha, - project: project, - x509_certificate_id: x509_certificate.id, - verification_status: "verified" - } - end - it 'finds a signature by commit sha if it existed' do x509_signature @@ -50,4 +50,18 @@ RSpec.describe X509CommitSignature do expect(signature.x509_certificate_id).to eq(x509_certificate.id) end end + + describe '#user' do + context 'if email is assigned to a user' do + let!(:user) { create(:user, email: X509Helpers::User1.certificate_email) } + + it 'returns user' do + expect(described_class.safe_create!(attributes).user).to eq(user) + end + end + + it 'if email is not assigned to a user, return nil' do + expect(described_class.safe_create!(attributes).user).to be_nil + end + end end |