diff options
author | Phil Hughes <me@iamphill.com> | 2018-04-09 15:00:59 +0100 |
---|---|---|
committer | Phil Hughes <me@iamphill.com> | 2018-04-09 15:00:59 +0100 |
commit | 3af57c3cbc986a622117e5b722b06db736fd0bf1 (patch) | |
tree | abde59d688e57b28771aa1b62255684ce41ba465 /spec | |
parent | 7c20403f28017da6a3e99b88629149478a620993 (diff) | |
parent | 2224aa3b7f28487d5d06f34cc5761103c1a682e3 (diff) | |
download | gitlab-ce-3af57c3cbc986a622117e5b722b06db736fd0bf1.tar.gz |
Merge branch 'master' into ide-staged-changes
Diffstat (limited to 'spec')
34 files changed, 2584 insertions, 12 deletions
diff --git a/spec/controllers/projects/repositories_controller_spec.rb b/spec/controllers/projects/repositories_controller_spec.rb index 31b1b52fdd1..c3b71458e38 100644 --- a/spec/controllers/projects/repositories_controller_spec.rb +++ b/spec/controllers/projects/repositories_controller_spec.rb @@ -34,6 +34,12 @@ describe Projects::RepositoriesController do expect(response.header[Gitlab::Workhorse::SEND_DATA_HEADER]).to start_with("git-archive:") end + it 'handles legacy queries with no ref' do + get :archive, namespace_id: project.namespace, project_id: project, format: "zip" + + expect(response.header[Gitlab::Workhorse::SEND_DATA_HEADER]).to start_with("git-archive:") + end + context "when the service raises an error" do before do allow(Gitlab::Workhorse).to receive(:send_git_archive).and_raise("Archive failed") diff --git a/spec/factories/deploy_tokens.rb b/spec/factories/deploy_tokens.rb new file mode 100644 index 00000000000..5fea4a9d5a6 --- /dev/null +++ b/spec/factories/deploy_tokens.rb @@ -0,0 +1,14 @@ +FactoryBot.define do + factory :deploy_token do + token { SecureRandom.hex(50) } + sequence(:name) { |n| "PDT #{n}" } + read_repository true + read_registry true + revoked false + expires_at { 5.days.from_now } + + trait :revoked do + revoked true + end + end +end diff --git a/spec/factories/project_deploy_tokens.rb b/spec/factories/project_deploy_tokens.rb new file mode 100644 index 00000000000..4866cb58d88 --- /dev/null +++ b/spec/factories/project_deploy_tokens.rb @@ -0,0 +1,6 @@ +FactoryBot.define do + factory :project_deploy_token do + project + deploy_token + end +end diff --git a/spec/features/groups/settings/group_badges_spec.rb b/spec/features/groups/settings/group_badges_spec.rb new file mode 100644 index 00000000000..92217294446 --- /dev/null +++ b/spec/features/groups/settings/group_badges_spec.rb @@ -0,0 +1,124 @@ +require 'spec_helper' + +feature 'Group Badges' do + include WaitForRequests + + let(:user) { create(:user) } + let(:group) { create(:group) } + let(:badge_link_url) { 'https://gitlab.com/gitlab-org/gitlab-ee/commits/master'} + let(:badge_image_url) { 'https://gitlab.com/gitlab-org/gitlab-ee/badges/master/build.svg'} + let!(:badge_1) { create(:group_badge, group: group) } + let!(:badge_2) { create(:group_badge, group: group) } + + before do + group.add_owner(user) + sign_in(user) + + visit(group_settings_badges_path(group)) + end + + it 'shows a list of badges', :js do + page.within '.badge-settings' do + wait_for_requests + + rows = all('.panel-body > div') + expect(rows.length).to eq 2 + expect(rows[0]).to have_content badge_1.link_url + expect(rows[1]).to have_content badge_2.link_url + end + end + + context 'adding a badge', :js do + it 'user can preview a badge' do + page.within '.badge-settings form' do + fill_in 'badge-link-url', with: badge_link_url + fill_in 'badge-image-url', with: badge_image_url + within '#badge-preview' do + expect(find('a')[:href]).to eq badge_link_url + expect(find('a img')[:src]).to eq badge_image_url + end + end + end + + it do + page.within '.badge-settings' do + fill_in 'badge-link-url', with: badge_link_url + fill_in 'badge-image-url', with: badge_image_url + + click_button 'Add badge' + wait_for_requests + + within '.panel-body' do + expect(find('a')[:href]).to eq badge_link_url + expect(find('a img')[:src]).to eq badge_image_url + end + end + end + end + + context 'editing a badge', :js do + it 'form is shown when clicking edit button in list' do + page.within '.badge-settings' do + wait_for_requests + rows = all('.panel-body > div') + expect(rows.length).to eq 2 + rows[1].find('[aria-label="Edit"]').click + + within 'form' do + expect(find('#badge-link-url').value).to eq badge_2.link_url + expect(find('#badge-image-url').value).to eq badge_2.image_url + end + end + end + + it 'updates a badge when submitting the edit form' do + page.within '.badge-settings' do + wait_for_requests + rows = all('.panel-body > div') + expect(rows.length).to eq 2 + rows[1].find('[aria-label="Edit"]').click + within 'form' do + fill_in 'badge-link-url', with: badge_link_url + fill_in 'badge-image-url', with: badge_image_url + + click_button 'Save changes' + wait_for_requests + end + + rows = all('.panel-body > div') + expect(rows.length).to eq 2 + expect(rows[1]).to have_content badge_link_url + end + end + end + + context 'deleting a badge', :js do + def click_delete_button(badge_row) + badge_row.find('[aria-label="Delete"]').click + end + + it 'shows a modal when deleting a badge' do + wait_for_requests + rows = all('.panel-body > div') + expect(rows.length).to eq 2 + + click_delete_button(rows[1]) + + expect(find('.modal .modal-title')).to have_content 'Delete badge?' + end + + it 'deletes a badge when confirming the modal' do + wait_for_requests + rows = all('.panel-body > div') + expect(rows.length).to eq 2 + click_delete_button(rows[1]) + + find('.modal .btn-danger').click + wait_for_requests + + rows = all('.panel-body > div') + expect(rows.length).to eq 1 + expect(rows[0]).to have_content badge_1.link_url + end + end +end diff --git a/spec/features/projects/files/template_selector_menu_spec.rb b/spec/features/projects/files/template_selector_menu_spec.rb new file mode 100644 index 00000000000..b549a69ddf3 --- /dev/null +++ b/spec/features/projects/files/template_selector_menu_spec.rb @@ -0,0 +1,66 @@ +require 'spec_helper' + +feature 'Template selector menu', :js do + let(:project) { create(:project, :repository) } + let(:user) { create(:user) } + + before do + project.add_master(user) + sign_in user + end + + context 'editing a non-matching file' do + before do + create_and_edit_file('README.md') + end + + scenario 'is not displayed' do + check_template_selector_menu_display(false) + end + + context 'user toggles preview' do + before do + click_link 'Preview' + end + + scenario 'template selector menu is not displayed' do + check_template_selector_menu_display(false) + click_link 'Write' + check_template_selector_menu_display(false) + end + end + end + + context 'editing a matching file' do + before do + visit project_edit_blob_path(project, File.join(project.default_branch, 'LICENSE')) + end + + scenario 'is displayed' do + check_template_selector_menu_display(true) + end + + context 'user toggles preview' do + before do + click_link 'Preview' + end + + scenario 'template selector menu is hidden and shown correctly' do + check_template_selector_menu_display(false) + click_link 'Write' + check_template_selector_menu_display(true) + end + end + end +end + +def check_template_selector_menu_display(is_visible) + count = is_visible ? 1 : 0 + expect(page).to have_css('.template-selectors-menu', count: count) +end + +def create_and_edit_file(file_name) + visit project_new_blob_path(project, 'master', file_name: file_name) + click_button "Commit changes" + visit project_edit_blob_path(project, File.join(project.default_branch, file_name)) +end diff --git a/spec/features/projects/settings/project_badges_spec.rb b/spec/features/projects/settings/project_badges_spec.rb new file mode 100644 index 00000000000..cc3551a4c21 --- /dev/null +++ b/spec/features/projects/settings/project_badges_spec.rb @@ -0,0 +1,125 @@ +require 'spec_helper' + +feature 'Project Badges' do + include WaitForRequests + + let(:user) { create(:user) } + let(:group) { create(:group) } + let(:project) { create(:project, namespace: group) } + let(:badge_link_url) { 'https://gitlab.com/gitlab-org/gitlab-ee/commits/master'} + let(:badge_image_url) { 'https://gitlab.com/gitlab-org/gitlab-ee/badges/master/build.svg'} + let!(:project_badge) { create(:project_badge, project: project) } + let!(:group_badge) { create(:group_badge, group: group) } + + before do + group.add_master(user) + sign_in(user) + + visit(project_settings_badges_path(project)) + end + + it 'shows a list of badges', :js do + page.within '.badge-settings' do + wait_for_requests + + rows = all('.panel-body > div') + expect(rows.length).to eq 2 + expect(rows[0]).to have_content group_badge.link_url + expect(rows[1]).to have_content project_badge.link_url + end + end + + context 'adding a badge', :js do + it 'user can preview a badge' do + page.within '.badge-settings form' do + fill_in 'badge-link-url', with: badge_link_url + fill_in 'badge-image-url', with: badge_image_url + within '#badge-preview' do + expect(find('a')[:href]).to eq badge_link_url + expect(find('a img')[:src]).to eq badge_image_url + end + end + end + + it do + page.within '.badge-settings' do + fill_in 'badge-link-url', with: badge_link_url + fill_in 'badge-image-url', with: badge_image_url + + click_button 'Add badge' + wait_for_requests + + within '.panel-body' do + expect(find('a')[:href]).to eq badge_link_url + expect(find('a img')[:src]).to eq badge_image_url + end + end + end + end + + context 'editing a badge', :js do + it 'form is shown when clicking edit button in list' do + page.within '.badge-settings' do + wait_for_requests + rows = all('.panel-body > div') + expect(rows.length).to eq 2 + rows[1].find('[aria-label="Edit"]').click + + within 'form' do + expect(find('#badge-link-url').value).to eq project_badge.link_url + expect(find('#badge-image-url').value).to eq project_badge.image_url + end + end + end + + it 'updates a badge when submitting the edit form' do + page.within '.badge-settings' do + wait_for_requests + rows = all('.panel-body > div') + expect(rows.length).to eq 2 + rows[1].find('[aria-label="Edit"]').click + within 'form' do + fill_in 'badge-link-url', with: badge_link_url + fill_in 'badge-image-url', with: badge_image_url + + click_button 'Save changes' + wait_for_requests + end + + rows = all('.panel-body > div') + expect(rows.length).to eq 2 + expect(rows[1]).to have_content badge_link_url + end + end + end + + context 'deleting a badge', :js do + def click_delete_button(badge_row) + badge_row.find('[aria-label="Delete"]').click + end + + it 'shows a modal when deleting a badge' do + wait_for_requests + rows = all('.panel-body > div') + expect(rows.length).to eq 2 + + click_delete_button(rows[1]) + + expect(find('.modal .modal-title')).to have_content 'Delete badge?' + end + + it 'deletes a badge when confirming the modal' do + wait_for_requests + rows = all('.panel-body > div') + expect(rows.length).to eq 2 + click_delete_button(rows[1]) + + find('.modal .btn-danger').click + wait_for_requests + + rows = all('.panel-body > div') + expect(rows.length).to eq 1 + expect(rows[0]).to have_content group_badge.link_url + end + end +end diff --git a/spec/features/projects/settings/repository_settings_spec.rb b/spec/features/projects/settings/repository_settings_spec.rb index 14670e91006..f2c371b7df5 100644 --- a/spec/features/projects/settings/repository_settings_spec.rb +++ b/spec/features/projects/settings/repository_settings_spec.rb @@ -88,5 +88,32 @@ feature 'Repository settings' do expect(page).not_to have_content(private_deploy_key.title) end end + + context 'Deploy tokens' do + let!(:deploy_token) { create(:deploy_token, projects: [project]) } + + before do + stub_container_registry_config(enabled: true) + visit project_settings_repository_path(project) + end + + scenario 'view deploy tokens' do + within('.deploy-tokens') do + expect(page).to have_content(deploy_token.name) + expect(page).to have_content('read_repository') + expect(page).to have_content('read_registry') + end + end + + scenario 'add a new deploy token' do + fill_in 'deploy_token_name', with: 'new_deploy_key' + fill_in 'deploy_token_expires_at', with: (Date.today + 1.month).to_s + check 'deploy_token_read_repository' + check 'deploy_token_read_registry' + click_button 'Create deploy token' + + expect(page).to have_content('Your new project deploy token has been created') + end + end end end diff --git a/spec/javascripts/badges/components/badge_form_spec.js b/spec/javascripts/badges/components/badge_form_spec.js new file mode 100644 index 00000000000..dd21ec279cb --- /dev/null +++ b/spec/javascripts/badges/components/badge_form_spec.js @@ -0,0 +1,171 @@ +import Vue from 'vue'; +import store from '~/badges/store'; +import BadgeForm from '~/badges/components/badge_form.vue'; +import { mountComponentWithStore } from 'spec/helpers/vue_mount_component_helper'; +import { createDummyBadge } from '../dummy_badge'; + +describe('BadgeForm component', () => { + const Component = Vue.extend(BadgeForm); + let vm; + + beforeEach(() => { + setFixtures(` + <div id="dummy-element"></div> + `); + }); + + afterEach(() => { + vm.$destroy(); + }); + + describe('methods', () => { + beforeEach(() => { + vm = mountComponentWithStore(Component, { + el: '#dummy-element', + store, + props: { + isEditing: false, + }, + }); + }); + + describe('onCancel', () => { + it('calls stopEditing', () => { + spyOn(vm, 'stopEditing'); + + vm.onCancel(); + + expect(vm.stopEditing).toHaveBeenCalled(); + }); + }); + + describe('onSubmit', () => { + describe('if isEditing is true', () => { + beforeEach(() => { + spyOn(vm, 'saveBadge').and.returnValue(Promise.resolve()); + store.replaceState({ + ...store.state, + isSaving: false, + badgeInEditForm: createDummyBadge(), + }); + vm.isEditing = true; + }); + + it('returns immediately if imageUrl is empty', () => { + store.state.badgeInEditForm.imageUrl = ''; + + vm.onSubmit(); + + expect(vm.saveBadge).not.toHaveBeenCalled(); + }); + + it('returns immediately if linkUrl is empty', () => { + store.state.badgeInEditForm.linkUrl = ''; + + vm.onSubmit(); + + expect(vm.saveBadge).not.toHaveBeenCalled(); + }); + + it('returns immediately if isSaving is true', () => { + store.state.isSaving = true; + + vm.onSubmit(); + + expect(vm.saveBadge).not.toHaveBeenCalled(); + }); + + it('calls saveBadge', () => { + vm.onSubmit(); + + expect(vm.saveBadge).toHaveBeenCalled(); + }); + }); + + describe('if isEditing is false', () => { + beforeEach(() => { + spyOn(vm, 'addBadge').and.returnValue(Promise.resolve()); + store.replaceState({ + ...store.state, + isSaving: false, + badgeInAddForm: createDummyBadge(), + }); + vm.isEditing = false; + }); + + it('returns immediately if imageUrl is empty', () => { + store.state.badgeInAddForm.imageUrl = ''; + + vm.onSubmit(); + + expect(vm.addBadge).not.toHaveBeenCalled(); + }); + + it('returns immediately if linkUrl is empty', () => { + store.state.badgeInAddForm.linkUrl = ''; + + vm.onSubmit(); + + expect(vm.addBadge).not.toHaveBeenCalled(); + }); + + it('returns immediately if isSaving is true', () => { + store.state.isSaving = true; + + vm.onSubmit(); + + expect(vm.addBadge).not.toHaveBeenCalled(); + }); + + it('calls addBadge', () => { + vm.onSubmit(); + + expect(vm.addBadge).toHaveBeenCalled(); + }); + }); + }); + }); + + describe('if isEditing is false', () => { + beforeEach(() => { + vm = mountComponentWithStore(Component, { + el: '#dummy-element', + store, + props: { + isEditing: false, + }, + }); + }); + + it('renders one button', () => { + const buttons = vm.$el.querySelectorAll('.row-content-block button'); + expect(buttons.length).toBe(1); + const buttonAddElement = buttons[0]; + expect(buttonAddElement).toBeVisible(); + expect(buttonAddElement).toHaveText('Add badge'); + }); + }); + + describe('if isEditing is true', () => { + beforeEach(() => { + vm = mountComponentWithStore(Component, { + el: '#dummy-element', + store, + props: { + isEditing: true, + }, + }); + }); + + it('renders two buttons', () => { + const buttons = vm.$el.querySelectorAll('.row-content-block button'); + expect(buttons.length).toBe(2); + const buttonSaveElement = buttons[0]; + expect(buttonSaveElement).toBeVisible(); + expect(buttonSaveElement).toHaveText('Save changes'); + const buttonCancelElement = buttons[1]; + expect(buttonCancelElement).toBeVisible(); + expect(buttonCancelElement).toHaveText('Cancel'); + }); + }); +}); diff --git a/spec/javascripts/badges/components/badge_list_row_spec.js b/spec/javascripts/badges/components/badge_list_row_spec.js new file mode 100644 index 00000000000..21bd00d82f0 --- /dev/null +++ b/spec/javascripts/badges/components/badge_list_row_spec.js @@ -0,0 +1,97 @@ +import $ from 'jquery'; +import Vue from 'vue'; +import { GROUP_BADGE, PROJECT_BADGE } from '~/badges/constants'; +import store from '~/badges/store'; +import BadgeListRow from '~/badges/components/badge_list_row.vue'; +import { mountComponentWithStore } from 'spec/helpers/vue_mount_component_helper'; +import { createDummyBadge } from '../dummy_badge'; + +describe('BadgeListRow component', () => { + const Component = Vue.extend(BadgeListRow); + let badge; + let vm; + + beforeEach(() => { + setFixtures(` + <div id="delete-badge-modal" class="modal"></div> + <div id="dummy-element"></div> + `); + store.replaceState({ + ...store.state, + kind: PROJECT_BADGE, + }); + badge = createDummyBadge(); + vm = mountComponentWithStore(Component, { + el: '#dummy-element', + store, + props: { badge }, + }); + }); + + afterEach(() => { + vm.$destroy(); + }); + + it('renders the badge', () => { + const badgeElement = vm.$el.querySelector('.project-badge'); + expect(badgeElement).not.toBeNull(); + expect(badgeElement.getAttribute('src')).toBe(badge.renderedImageUrl); + }); + + it('renders the badge link', () => { + expect(vm.$el).toContainText(badge.linkUrl); + }); + + it('renders the badge kind', () => { + expect(vm.$el).toContainText('Project Badge'); + }); + + it('shows edit and delete buttons', () => { + const buttons = vm.$el.querySelectorAll('.table-button-footer button'); + expect(buttons).toHaveLength(2); + const buttonEditElement = buttons[0]; + expect(buttonEditElement).toBeVisible(); + expect(buttonEditElement).toHaveSpriteIcon('pencil'); + const buttonDeleteElement = buttons[1]; + expect(buttonDeleteElement).toBeVisible(); + expect(buttonDeleteElement).toHaveSpriteIcon('remove'); + }); + + it('calls editBadge when clicking then edit button', () => { + spyOn(vm, 'editBadge'); + + const editButton = vm.$el.querySelector('.table-button-footer button:first-of-type'); + editButton.click(); + + expect(vm.editBadge).toHaveBeenCalled(); + }); + + it('calls updateBadgeInModal and shows modal when clicking then delete button', done => { + spyOn(vm, 'updateBadgeInModal'); + $('#delete-badge-modal').on('shown.bs.modal', () => done()); + + const deleteButton = vm.$el.querySelector('.table-button-footer button:last-of-type'); + deleteButton.click(); + + expect(vm.updateBadgeInModal).toHaveBeenCalled(); + }); + + describe('for a group badge', () => { + beforeEach(done => { + badge.kind = GROUP_BADGE; + + Vue.nextTick() + .then(done) + .catch(done.fail); + }); + + it('renders the badge kind', () => { + expect(vm.$el).toContainText('Group Badge'); + }); + + it('hides edit and delete buttons', () => { + const buttons = vm.$el.querySelectorAll('.table-button-footer button'); + expect(buttons).toHaveLength(0); + }); + }); +}); diff --git a/spec/javascripts/badges/components/badge_list_spec.js b/spec/javascripts/badges/components/badge_list_spec.js new file mode 100644 index 00000000000..9439c578973 --- /dev/null +++ b/spec/javascripts/badges/components/badge_list_spec.js @@ -0,0 +1,88 @@ +import Vue from 'vue'; +import { GROUP_BADGE, PROJECT_BADGE } from '~/badges/constants'; +import store from '~/badges/store'; +import BadgeList from '~/badges/components/badge_list.vue'; +import { mountComponentWithStore } from 'spec/helpers/vue_mount_component_helper'; +import { createDummyBadge } from '../dummy_badge'; + +describe('BadgeList component', () => { + const Component = Vue.extend(BadgeList); + const numberOfDummyBadges = 3; + let vm; + + beforeEach(() => { + setFixtures('<div id="dummy-element"></div>'); + const badges = []; + for (let id = 0; id < numberOfDummyBadges; id += 1) { + badges.push({ id, ...createDummyBadge() }); + } + store.replaceState({ + ...store.state, + badges, + kind: PROJECT_BADGE, + isLoading: false, + }); + vm = mountComponentWithStore(Component, { + el: '#dummy-element', + store, + }); + }); + + afterEach(() => { + vm.$destroy(); + }); + + it('renders a header with the badge count', () => { + const header = vm.$el.querySelector('.panel-heading'); + expect(header).toHaveText(new RegExp(`Your badges\\s+${numberOfDummyBadges}`)); + }); + + it('renders a row for each badge', () => { + const rows = vm.$el.querySelectorAll('.gl-responsive-table-row'); + expect(rows).toHaveLength(numberOfDummyBadges); + }); + + it('renders a message if no badges exist', done => { + store.state.badges = []; + + Vue.nextTick() + .then(() => { + expect(vm.$el).toContainText('This project has no badges'); + }) + .then(done) + .catch(done.fail); + }); + + it('shows a loading icon when loading', done => { + store.state.isLoading = true; + + Vue.nextTick() + .then(() => { + const loadingIcon = vm.$el.querySelector('.fa-spinner'); + expect(loadingIcon).toBeVisible(); + }) + .then(done) + .catch(done.fail); + }); + + describe('for group badges', () => { + beforeEach(done => { + store.state.kind = GROUP_BADGE; + + Vue.nextTick() + .then(done) + .catch(done.fail); + }); + + it('renders a message if no badges exist', done => { + store.state.badges = []; + + Vue.nextTick() + .then(() => { + expect(vm.$el).toContainText('This group has no badges'); + }) + .then(done) + .catch(done.fail); + }); + }); +}); diff --git a/spec/javascripts/badges/components/badge_settings_spec.js b/spec/javascripts/badges/components/badge_settings_spec.js new file mode 100644 index 00000000000..3db02982ad4 --- /dev/null +++ b/spec/javascripts/badges/components/badge_settings_spec.js @@ -0,0 +1,109 @@ +import $ from 'jquery'; +import Vue from 'vue'; +import store from '~/badges/store'; +import BadgeSettings from '~/badges/components/badge_settings.vue'; +import { mountComponentWithStore } from 'spec/helpers/vue_mount_component_helper'; +import { createDummyBadge } from '../dummy_badge'; + +describe('BadgeSettings component', () => { + const Component = Vue.extend(BadgeSettings); + let vm; + + beforeEach(() => { + setFixtures(` + <div id="dummy-element"></div> + <button + id="dummy-modal-button" + type="button" + data-toggle="modal" + data-target="#delete-badge-modal" + >Show modal</button> + `); + vm = mountComponentWithStore(Component, { + el: '#dummy-element', + store, + }); + }); + + afterEach(() => { + vm.$destroy(); + }); + + it('displays modal if button is clicked', done => { + const badge = createDummyBadge(); + store.state.badgeInModal = badge; + const modal = vm.$el.querySelector('#delete-badge-modal'); + const button = document.getElementById('dummy-modal-button'); + + $(modal).on('shown.bs.modal', () => { + expect(modal).toContainText('Delete badge?'); + const badgeElement = modal.querySelector('img.project-badge'); + expect(badgeElement).not.toBe(null); + expect(badgeElement.getAttribute('src')).toBe(badge.renderedImageUrl); + + done(); + }); + + Vue.nextTick() + .then(() => { + button.click(); + }) + .catch(done.fail); + }); + + it('displays a form to add a badge', () => { + const form = vm.$el.querySelector('form:nth-of-type(2)'); + expect(form).not.toBe(null); + const button = form.querySelector('.btn-success'); + expect(button).not.toBe(null); + expect(button).toHaveText(/Add badge/); + }); + + it('displays badge list', () => { + const badgeListElement = vm.$el.querySelector('.panel'); + expect(badgeListElement).not.toBe(null); + expect(badgeListElement).toBeVisible(); + expect(badgeListElement).toContainText('Your badges'); + }); + + describe('when editing', () => { + beforeEach(done => { + store.state.isEditing = true; + + Vue.nextTick() + .then(done) + .catch(done.fail); + }); + + it('displays a form to edit a badge', () => { + const form = vm.$el.querySelector('form:nth-of-type(1)'); + expect(form).not.toBe(null); + const submitButton = form.querySelector('.btn-success'); + expect(submitButton).not.toBe(null); + expect(submitButton).toHaveText(/Save changes/); + const cancelButton = form.querySelector('.btn-cancel'); + expect(cancelButton).not.toBe(null); + expect(cancelButton).toHaveText(/Cancel/); + }); + + it('displays no badge list', () => { + const badgeListElement = vm.$el.querySelector('.panel'); + expect(badgeListElement).toBeHidden(); + }); + }); + + describe('methods', () => { + describe('onSubmitModal', () => { + it('triggers ', () => { + spyOn(vm, 'deleteBadge').and.callFake(() => Promise.resolve()); + const modal = vm.$el.querySelector('#delete-badge-modal'); + const deleteButton = modal.querySelector('.btn-danger'); + + deleteButton.click(); + + const badge = store.state.badgeInModal; + expect(vm.deleteBadge).toHaveBeenCalledWith(badge); + }); + }); + }); +}); diff --git a/spec/javascripts/badges/components/badge_spec.js b/spec/javascripts/badges/components/badge_spec.js new file mode 100644 index 00000000000..fd1ecc9cdd8 --- /dev/null +++ b/spec/javascripts/badges/components/badge_spec.js @@ -0,0 +1,147 @@ +import Vue from 'vue'; +import Badge from '~/badges/components/badge.vue'; +import mountComponent from 'spec/helpers/vue_mount_component_helper'; +import { DUMMY_IMAGE_URL, TEST_HOST } from 'spec/test_constants'; + +describe('Badge component', () => { + const Component = Vue.extend(Badge); + const dummyProps = { + imageUrl: DUMMY_IMAGE_URL, + linkUrl: `${TEST_HOST}/badge/link/url`, + }; + let vm; + + const findElements = () => { + const buttons = vm.$el.querySelectorAll('button'); + return { + badgeImage: vm.$el.querySelector('img.project-badge'), + loadingIcon: vm.$el.querySelector('.fa-spinner'), + reloadButton: buttons[buttons.length - 1], + }; + }; + + const createComponent = (props, el = null) => { + vm = mountComponent(Component, props, el); + const { badgeImage } = findElements(); + return new Promise(resolve => badgeImage.addEventListener('load', resolve)).then(() => + Vue.nextTick(), + ); + }; + + afterEach(() => { + vm.$destroy(); + }); + + describe('watchers', () => { + describe('imageUrl', () => { + it('sets isLoading and resets numRetries and hasError', done => { + const props = { ...dummyProps }; + createComponent(props) + .then(() => { + expect(vm.isLoading).toBe(false); + vm.hasError = true; + vm.numRetries = 42; + + vm.imageUrl = `${props.imageUrl}#something/else`; + + return Vue.nextTick(); + }) + .then(() => { + expect(vm.isLoading).toBe(true); + expect(vm.numRetries).toBe(0); + expect(vm.hasError).toBe(false); + }) + .then(done) + .catch(done.fail); + }); + }); + }); + + describe('methods', () => { + beforeEach(done => { + createComponent({ ...dummyProps }) + .then(done) + .catch(done.fail); + }); + + it('onError resets isLoading and sets hasError', () => { + vm.hasError = false; + vm.isLoading = true; + + vm.onError(); + + expect(vm.hasError).toBe(true); + expect(vm.isLoading).toBe(false); + }); + + it('onLoad sets isLoading', () => { + vm.isLoading = true; + + vm.onLoad(); + + expect(vm.isLoading).toBe(false); + }); + + it('reloadImage resets isLoading and hasError and increases numRetries', () => { + vm.hasError = true; + vm.isLoading = false; + vm.numRetries = 0; + + vm.reloadImage(); + + expect(vm.hasError).toBe(false); + expect(vm.isLoading).toBe(true); + expect(vm.numRetries).toBe(1); + }); + }); + + describe('behavior', () => { + beforeEach(done => { + setFixtures('<div id="dummy-element"></div>'); + createComponent({ ...dummyProps }, '#dummy-element') + .then(done) + .catch(done.fail); + }); + + it('shows a badge image after loading', () => { + expect(vm.isLoading).toBe(false); + expect(vm.hasError).toBe(false); + const { badgeImage, loadingIcon, reloadButton } = findElements(); + expect(badgeImage).toBeVisible(); + expect(loadingIcon).toBeHidden(); + expect(reloadButton).toBeHidden(); + expect(vm.$el.innerText).toBe(''); + }); + + it('shows a loading icon when loading', done => { + vm.isLoading = true; + + Vue.nextTick() + .then(() => { + const { badgeImage, loadingIcon, reloadButton } = findElements(); + expect(badgeImage).toBeHidden(); + expect(loadingIcon).toBeVisible(); + expect(reloadButton).toBeHidden(); + expect(vm.$el.innerText).toBe(''); + }) + .then(done) + .catch(done.fail); + }); + + it('shows an error and reload button if loading failed', done => { + vm.hasError = true; + + Vue.nextTick() + .then(() => { + const { badgeImage, loadingIcon, reloadButton } = findElements(); + expect(badgeImage).toBeHidden(); + expect(loadingIcon).toBeHidden(); + expect(reloadButton).toBeVisible(); + expect(reloadButton).toHaveSpriteIcon('retry'); + expect(vm.$el.innerText.trim()).toBe('No badge image'); + }) + .then(done) + .catch(done.fail); + }); + }); +}); diff --git a/spec/javascripts/badges/dummy_badge.js b/spec/javascripts/badges/dummy_badge.js new file mode 100644 index 00000000000..6aaff21c503 --- /dev/null +++ b/spec/javascripts/badges/dummy_badge.js @@ -0,0 +1,23 @@ +import { PROJECT_BADGE } from '~/badges/constants'; +import { DUMMY_IMAGE_URL, TEST_HOST } from 'spec/test_constants'; + +export const createDummyBadge = () => { + const id = Math.floor(1000 * Math.random()); + return { + id, + imageUrl: `${TEST_HOST}/badges/${id}/image/url`, + isDeleting: false, + linkUrl: `${TEST_HOST}/badges/${id}/link/url`, + kind: PROJECT_BADGE, + renderedImageUrl: `${DUMMY_IMAGE_URL}?id=${id}`, + renderedLinkUrl: `${TEST_HOST}/badges/${id}/rendered/link/url`, + }; +}; + +export const createDummyBadgeResponse = () => ({ + image_url: `${TEST_HOST}/badge/image/url`, + link_url: `${TEST_HOST}/badge/link/url`, + kind: PROJECT_BADGE, + rendered_image_url: DUMMY_IMAGE_URL, + rendered_link_url: `${TEST_HOST}/rendered/badge/link/url`, +}); diff --git a/spec/javascripts/badges/store/actions_spec.js b/spec/javascripts/badges/store/actions_spec.js new file mode 100644 index 00000000000..bb6263c6de4 --- /dev/null +++ b/spec/javascripts/badges/store/actions_spec.js @@ -0,0 +1,607 @@ +import axios from '~/lib/utils/axios_utils'; +import MockAdapter from 'axios-mock-adapter'; +import actions, { transformBackendBadge } from '~/badges/store/actions'; +import mutationTypes from '~/badges/store/mutation_types'; +import createState from '~/badges/store/state'; +import { TEST_HOST } from 'spec/test_constants'; +import testAction from 'spec/helpers/vuex_action_helper'; +import { createDummyBadge, createDummyBadgeResponse } from '../dummy_badge'; + +describe('Badges store actions', () => { + const dummyEndpointUrl = `${TEST_HOST}/badges/endpoint`; + const dummyBadges = [{ ...createDummyBadge(), id: 5 }, { ...createDummyBadge(), id: 6 }]; + + let axiosMock; + let badgeId; + let state; + + beforeEach(() => { + axiosMock = new MockAdapter(axios); + state = { + ...createState(), + apiEndpointUrl: dummyEndpointUrl, + badges: dummyBadges, + }; + badgeId = state.badges[0].id; + }); + + afterEach(() => { + axiosMock.restore(); + }); + + describe('requestNewBadge', () => { + it('commits REQUEST_NEW_BADGE', done => { + testAction( + actions.requestNewBadge, + null, + state, + [{ type: mutationTypes.REQUEST_NEW_BADGE }], + [], + done, + ); + }); + }); + + describe('receiveNewBadge', () => { + it('commits RECEIVE_NEW_BADGE', done => { + const newBadge = createDummyBadge(); + testAction( + actions.receiveNewBadge, + newBadge, + state, + [{ type: mutationTypes.RECEIVE_NEW_BADGE, payload: newBadge }], + [], + done, + ); + }); + }); + + describe('receiveNewBadgeError', () => { + it('commits RECEIVE_NEW_BADGE_ERROR', done => { + testAction( + actions.receiveNewBadgeError, + null, + state, + [{ type: mutationTypes.RECEIVE_NEW_BADGE_ERROR }], + [], + done, + ); + }); + }); + + describe('addBadge', () => { + let badgeInAddForm; + let dispatch; + let endpointMock; + + beforeEach(() => { + endpointMock = axiosMock.onPost(dummyEndpointUrl); + dispatch = jasmine.createSpy('dispatch'); + badgeInAddForm = createDummyBadge(); + state = { + ...state, + badgeInAddForm, + }; + }); + + it('dispatches requestNewBadge and receiveNewBadge for successful response', done => { + const dummyResponse = createDummyBadgeResponse(); + + endpointMock.replyOnce(req => { + expect(req.data).toBe( + JSON.stringify({ + image_url: badgeInAddForm.imageUrl, + link_url: badgeInAddForm.linkUrl, + }), + ); + expect(dispatch.calls.allArgs()).toEqual([['requestNewBadge']]); + dispatch.calls.reset(); + return [200, dummyResponse]; + }); + + const dummyBadge = transformBackendBadge(dummyResponse); + actions + .addBadge({ state, dispatch }) + .then(() => { + expect(dispatch.calls.allArgs()).toEqual([['receiveNewBadge', dummyBadge]]); + }) + .then(done) + .catch(done.fail); + }); + + it('dispatches requestNewBadge and receiveNewBadgeError for error response', done => { + endpointMock.replyOnce(req => { + expect(req.data).toBe( + JSON.stringify({ + image_url: badgeInAddForm.imageUrl, + link_url: badgeInAddForm.linkUrl, + }), + ); + expect(dispatch.calls.allArgs()).toEqual([['requestNewBadge']]); + dispatch.calls.reset(); + return [500, '']; + }); + + actions + .addBadge({ state, dispatch }) + .then(() => done.fail('Expected Ajax call to fail!')) + .catch(() => { + expect(dispatch.calls.allArgs()).toEqual([['receiveNewBadgeError']]); + }) + .then(done) + .catch(done.fail); + }); + }); + + describe('requestDeleteBadge', () => { + it('commits REQUEST_DELETE_BADGE', done => { + testAction( + actions.requestDeleteBadge, + badgeId, + state, + [{ type: mutationTypes.REQUEST_DELETE_BADGE, payload: badgeId }], + [], + done, + ); + }); + }); + + describe('receiveDeleteBadge', () => { + it('commits RECEIVE_DELETE_BADGE', done => { + testAction( + actions.receiveDeleteBadge, + badgeId, + state, + [{ type: mutationTypes.RECEIVE_DELETE_BADGE, payload: badgeId }], + [], + done, + ); + }); + }); + + describe('receiveDeleteBadgeError', () => { + it('commits RECEIVE_DELETE_BADGE_ERROR', done => { + testAction( + actions.receiveDeleteBadgeError, + badgeId, + state, + [{ type: mutationTypes.RECEIVE_DELETE_BADGE_ERROR, payload: badgeId }], + [], + done, + ); + }); + }); + + describe('deleteBadge', () => { + let dispatch; + let endpointMock; + + beforeEach(() => { + endpointMock = axiosMock.onDelete(`${dummyEndpointUrl}/${badgeId}`); + dispatch = jasmine.createSpy('dispatch'); + }); + + it('dispatches requestDeleteBadge and receiveDeleteBadge for successful response', done => { + endpointMock.replyOnce(() => { + expect(dispatch.calls.allArgs()).toEqual([['requestDeleteBadge', badgeId]]); + dispatch.calls.reset(); + return [200, '']; + }); + + actions + .deleteBadge({ state, dispatch }, { id: badgeId }) + .then(() => { + expect(dispatch.calls.allArgs()).toEqual([['receiveDeleteBadge', badgeId]]); + }) + .then(done) + .catch(done.fail); + }); + + it('dispatches requestDeleteBadge and receiveDeleteBadgeError for error response', done => { + endpointMock.replyOnce(() => { + expect(dispatch.calls.allArgs()).toEqual([['requestDeleteBadge', badgeId]]); + dispatch.calls.reset(); + return [500, '']; + }); + + actions + .deleteBadge({ state, dispatch }, { id: badgeId }) + .then(() => done.fail('Expected Ajax call to fail!')) + .catch(() => { + expect(dispatch.calls.allArgs()).toEqual([['receiveDeleteBadgeError', badgeId]]); + }) + .then(done) + .catch(done.fail); + }); + }); + + describe('editBadge', () => { + it('commits START_EDITING', done => { + const dummyBadge = createDummyBadge(); + testAction( + actions.editBadge, + dummyBadge, + state, + [{ type: mutationTypes.START_EDITING, payload: dummyBadge }], + [], + done, + ); + }); + }); + + describe('requestLoadBadges', () => { + it('commits REQUEST_LOAD_BADGES', done => { + const dummyData = 'this is not real data'; + testAction( + actions.requestLoadBadges, + dummyData, + state, + [{ type: mutationTypes.REQUEST_LOAD_BADGES, payload: dummyData }], + [], + done, + ); + }); + }); + + describe('receiveLoadBadges', () => { + it('commits RECEIVE_LOAD_BADGES', done => { + const badges = dummyBadges; + testAction( + actions.receiveLoadBadges, + badges, + state, + [{ type: mutationTypes.RECEIVE_LOAD_BADGES, payload: badges }], + [], + done, + ); + }); + }); + + describe('receiveLoadBadgesError', () => { + it('commits RECEIVE_LOAD_BADGES_ERROR', done => { + testAction( + actions.receiveLoadBadgesError, + null, + state, + [{ type: mutationTypes.RECEIVE_LOAD_BADGES_ERROR }], + [], + done, + ); + }); + }); + + describe('loadBadges', () => { + let dispatch; + let endpointMock; + + beforeEach(() => { + endpointMock = axiosMock.onGet(dummyEndpointUrl); + dispatch = jasmine.createSpy('dispatch'); + }); + + it('dispatches requestLoadBadges and receiveLoadBadges for successful response', done => { + const dummyData = 'this is just some data'; + const dummyReponse = [ + createDummyBadgeResponse(), + createDummyBadgeResponse(), + createDummyBadgeResponse(), + ]; + endpointMock.replyOnce(() => { + expect(dispatch.calls.allArgs()).toEqual([['requestLoadBadges', dummyData]]); + dispatch.calls.reset(); + return [200, dummyReponse]; + }); + + actions + .loadBadges({ state, dispatch }, dummyData) + .then(() => { + const badges = dummyReponse.map(transformBackendBadge); + expect(dispatch.calls.allArgs()).toEqual([['receiveLoadBadges', badges]]); + }) + .then(done) + .catch(done.fail); + }); + + it('dispatches requestLoadBadges and receiveLoadBadgesError for error response', done => { + const dummyData = 'this is just some data'; + endpointMock.replyOnce(() => { + expect(dispatch.calls.allArgs()).toEqual([['requestLoadBadges', dummyData]]); + dispatch.calls.reset(); + return [500, '']; + }); + + actions + .loadBadges({ state, dispatch }, dummyData) + .then(() => done.fail('Expected Ajax call to fail!')) + .catch(() => { + expect(dispatch.calls.allArgs()).toEqual([['receiveLoadBadgesError']]); + }) + .then(done) + .catch(done.fail); + }); + }); + + describe('requestRenderedBadge', () => { + it('commits REQUEST_RENDERED_BADGE', done => { + testAction( + actions.requestRenderedBadge, + null, + state, + [{ type: mutationTypes.REQUEST_RENDERED_BADGE }], + [], + done, + ); + }); + }); + + describe('receiveRenderedBadge', () => { + it('commits RECEIVE_RENDERED_BADGE', done => { + const dummyBadge = createDummyBadge(); + testAction( + actions.receiveRenderedBadge, + dummyBadge, + state, + [{ type: mutationTypes.RECEIVE_RENDERED_BADGE, payload: dummyBadge }], + [], + done, + ); + }); + }); + + describe('receiveRenderedBadgeError', () => { + it('commits RECEIVE_RENDERED_BADGE_ERROR', done => { + testAction( + actions.receiveRenderedBadgeError, + null, + state, + [{ type: mutationTypes.RECEIVE_RENDERED_BADGE_ERROR }], + [], + done, + ); + }); + }); + + describe('renderBadge', () => { + let dispatch; + let endpointMock; + let badgeInForm; + + beforeEach(() => { + badgeInForm = createDummyBadge(); + state = { + ...state, + badgeInAddForm: badgeInForm, + }; + const urlParameters = [ + `link_url=${encodeURIComponent(badgeInForm.linkUrl)}`, + `image_url=${encodeURIComponent(badgeInForm.imageUrl)}`, + ].join('&'); + endpointMock = axiosMock.onGet(`${dummyEndpointUrl}/render?${urlParameters}`); + dispatch = jasmine.createSpy('dispatch'); + }); + + it('returns immediately if imageUrl is empty', done => { + spyOn(axios, 'get'); + badgeInForm.imageUrl = ''; + + actions + .renderBadge({ state, dispatch }) + .then(() => { + expect(axios.get).not.toHaveBeenCalled(); + }) + .then(done) + .catch(done.fail); + }); + + it('returns immediately if linkUrl is empty', done => { + spyOn(axios, 'get'); + badgeInForm.linkUrl = ''; + + actions + .renderBadge({ state, dispatch }) + .then(() => { + expect(axios.get).not.toHaveBeenCalled(); + }) + .then(done) + .catch(done.fail); + }); + + it('escapes user input', done => { + spyOn(axios, 'get').and.callFake(() => Promise.resolve({ data: createDummyBadgeResponse() })); + badgeInForm.imageUrl = '&make-sandwhich=true'; + badgeInForm.linkUrl = '<script>I am dangerous!</script>'; + + actions + .renderBadge({ state, dispatch }) + .then(() => { + expect(axios.get.calls.count()).toBe(1); + const url = axios.get.calls.argsFor(0)[0]; + expect(url).toMatch(`^${dummyEndpointUrl}/render?`); + expect(url).toMatch('\\?link_url=%3Cscript%3EI%20am%20dangerous!%3C%2Fscript%3E&'); + expect(url).toMatch('&image_url=%26make-sandwhich%3Dtrue$'); + }) + .then(done) + .catch(done.fail); + }); + + it('dispatches requestRenderedBadge and receiveRenderedBadge for successful response', done => { + const dummyReponse = createDummyBadgeResponse(); + endpointMock.replyOnce(() => { + expect(dispatch.calls.allArgs()).toEqual([['requestRenderedBadge']]); + dispatch.calls.reset(); + return [200, dummyReponse]; + }); + + actions + .renderBadge({ state, dispatch }) + .then(() => { + const renderedBadge = transformBackendBadge(dummyReponse); + expect(dispatch.calls.allArgs()).toEqual([['receiveRenderedBadge', renderedBadge]]); + }) + .then(done) + .catch(done.fail); + }); + + it('dispatches requestRenderedBadge and receiveRenderedBadgeError for error response', done => { + endpointMock.replyOnce(() => { + expect(dispatch.calls.allArgs()).toEqual([['requestRenderedBadge']]); + dispatch.calls.reset(); + return [500, '']; + }); + + actions + .renderBadge({ state, dispatch }) + .then(() => done.fail('Expected Ajax call to fail!')) + .catch(() => { + expect(dispatch.calls.allArgs()).toEqual([['receiveRenderedBadgeError']]); + }) + .then(done) + .catch(done.fail); + }); + }); + + describe('requestUpdatedBadge', () => { + it('commits REQUEST_UPDATED_BADGE', done => { + testAction( + actions.requestUpdatedBadge, + null, + state, + [{ type: mutationTypes.REQUEST_UPDATED_BADGE }], + [], + done, + ); + }); + }); + + describe('receiveUpdatedBadge', () => { + it('commits RECEIVE_UPDATED_BADGE', done => { + const updatedBadge = createDummyBadge(); + testAction( + actions.receiveUpdatedBadge, + updatedBadge, + state, + [{ type: mutationTypes.RECEIVE_UPDATED_BADGE, payload: updatedBadge }], + [], + done, + ); + }); + }); + + describe('receiveUpdatedBadgeError', () => { + it('commits RECEIVE_UPDATED_BADGE_ERROR', done => { + testAction( + actions.receiveUpdatedBadgeError, + null, + state, + [{ type: mutationTypes.RECEIVE_UPDATED_BADGE_ERROR }], + [], + done, + ); + }); + }); + + describe('saveBadge', () => { + let badgeInEditForm; + let dispatch; + let endpointMock; + + beforeEach(() => { + badgeInEditForm = createDummyBadge(); + state = { + ...state, + badgeInEditForm, + }; + endpointMock = axiosMock.onPut(`${dummyEndpointUrl}/${badgeInEditForm.id}`); + dispatch = jasmine.createSpy('dispatch'); + }); + + it('dispatches requestUpdatedBadge and receiveUpdatedBadge for successful response', done => { + const dummyResponse = createDummyBadgeResponse(); + + endpointMock.replyOnce(req => { + expect(req.data).toBe( + JSON.stringify({ + image_url: badgeInEditForm.imageUrl, + link_url: badgeInEditForm.linkUrl, + }), + ); + expect(dispatch.calls.allArgs()).toEqual([['requestUpdatedBadge']]); + dispatch.calls.reset(); + return [200, dummyResponse]; + }); + + const updatedBadge = transformBackendBadge(dummyResponse); + actions + .saveBadge({ state, dispatch }) + .then(() => { + expect(dispatch.calls.allArgs()).toEqual([['receiveUpdatedBadge', updatedBadge]]); + }) + .then(done) + .catch(done.fail); + }); + + it('dispatches requestUpdatedBadge and receiveUpdatedBadgeError for error response', done => { + endpointMock.replyOnce(req => { + expect(req.data).toBe( + JSON.stringify({ + image_url: badgeInEditForm.imageUrl, + link_url: badgeInEditForm.linkUrl, + }), + ); + expect(dispatch.calls.allArgs()).toEqual([['requestUpdatedBadge']]); + dispatch.calls.reset(); + return [500, '']; + }); + + actions + .saveBadge({ state, dispatch }) + .then(() => done.fail('Expected Ajax call to fail!')) + .catch(() => { + expect(dispatch.calls.allArgs()).toEqual([['receiveUpdatedBadgeError']]); + }) + .then(done) + .catch(done.fail); + }); + }); + + describe('stopEditing', () => { + it('commits STOP_EDITING', done => { + testAction( + actions.stopEditing, + null, + state, + [{ type: mutationTypes.STOP_EDITING }], + [], + done, + ); + }); + }); + + describe('updateBadgeInForm', () => { + it('commits UPDATE_BADGE_IN_FORM', done => { + const dummyBadge = createDummyBadge(); + testAction( + actions.updateBadgeInForm, + dummyBadge, + state, + [{ type: mutationTypes.UPDATE_BADGE_IN_FORM, payload: dummyBadge }], + [], + done, + ); + }); + + describe('updateBadgeInModal', () => { + it('commits UPDATE_BADGE_IN_MODAL', done => { + const dummyBadge = createDummyBadge(); + testAction( + actions.updateBadgeInModal, + dummyBadge, + state, + [{ type: mutationTypes.UPDATE_BADGE_IN_MODAL, payload: dummyBadge }], + [], + done, + ); + }); + }); + }); +}); diff --git a/spec/javascripts/badges/store/mutations_spec.js b/spec/javascripts/badges/store/mutations_spec.js new file mode 100644 index 00000000000..8d26f83339d --- /dev/null +++ b/spec/javascripts/badges/store/mutations_spec.js @@ -0,0 +1,418 @@ +import { GROUP_BADGE, PROJECT_BADGE } from '~/badges/constants'; +import store from '~/badges/store'; +import types from '~/badges/store/mutation_types'; +import createState from '~/badges/store/state'; +import { createDummyBadge } from '../dummy_badge'; + +describe('Badges store mutations', () => { + let dummyBadge; + + beforeEach(() => { + dummyBadge = createDummyBadge(); + store.replaceState(createState()); + }); + + describe('RECEIVE_DELETE_BADGE', () => { + beforeEach(() => { + const badges = [ + { ...dummyBadge, id: dummyBadge.id - 1 }, + dummyBadge, + { ...dummyBadge, id: dummyBadge.id + 1 }, + ]; + + store.replaceState({ + ...store.state, + badges, + }); + }); + + it('removes deleted badge', () => { + const badgeCount = store.state.badges.length; + + store.commit(types.RECEIVE_DELETE_BADGE, dummyBadge.id); + + expect(store.state.badges.length).toBe(badgeCount - 1); + expect(store.state.badges.indexOf(dummyBadge)).toBe(-1); + }); + }); + + describe('RECEIVE_DELETE_BADGE_ERROR', () => { + beforeEach(() => { + const badges = [ + { ...dummyBadge, id: dummyBadge.id - 1, isDeleting: false }, + { ...dummyBadge, isDeleting: true }, + { ...dummyBadge, id: dummyBadge.id + 1, isDeleting: true }, + ]; + + store.replaceState({ + ...store.state, + badges, + }); + }); + + it('sets isDeleting to false', () => { + const badgeCount = store.state.badges.length; + + store.commit(types.RECEIVE_DELETE_BADGE_ERROR, dummyBadge.id); + + expect(store.state.badges.length).toBe(badgeCount); + expect(store.state.badges[0].isDeleting).toBe(false); + expect(store.state.badges[1].isDeleting).toBe(false); + expect(store.state.badges[2].isDeleting).toBe(true); + }); + }); + + describe('RECEIVE_LOAD_BADGES', () => { + beforeEach(() => { + store.replaceState({ + ...store.state, + isLoading: 'not false', + }); + }); + + it('sets badges and isLoading to false', () => { + const badges = [createDummyBadge()]; + store.commit(types.RECEIVE_LOAD_BADGES, badges); + + expect(store.state.isLoading).toBe(false); + expect(store.state.badges).toBe(badges); + }); + }); + + describe('RECEIVE_LOAD_BADGES_ERROR', () => { + beforeEach(() => { + store.replaceState({ + ...store.state, + isLoading: 'not false', + }); + }); + + it('sets isLoading to false', () => { + store.commit(types.RECEIVE_LOAD_BADGES_ERROR); + + expect(store.state.isLoading).toBe(false); + }); + }); + + describe('RECEIVE_NEW_BADGE', () => { + beforeEach(() => { + const badges = [ + { ...dummyBadge, id: dummyBadge.id - 1, kind: GROUP_BADGE }, + { ...dummyBadge, id: dummyBadge.id + 1, kind: GROUP_BADGE }, + { ...dummyBadge, id: dummyBadge.id - 1, kind: PROJECT_BADGE }, + { ...dummyBadge, id: dummyBadge.id + 1, kind: PROJECT_BADGE }, + ]; + store.replaceState({ + ...store.state, + badgeInAddForm: createDummyBadge(), + badges, + isSaving: 'dummy value', + renderedBadge: createDummyBadge(), + }); + }); + + it('resets the add form', () => { + store.commit(types.RECEIVE_NEW_BADGE, dummyBadge); + + expect(store.state.badgeInAddForm).toBe(null); + expect(store.state.isSaving).toBe(false); + expect(store.state.renderedBadge).toBe(null); + }); + + it('inserts group badge at correct position', () => { + const badgeCount = store.state.badges.length; + dummyBadge = { ...dummyBadge, kind: GROUP_BADGE }; + + store.commit(types.RECEIVE_NEW_BADGE, dummyBadge); + + expect(store.state.badges.length).toBe(badgeCount + 1); + expect(store.state.badges.indexOf(dummyBadge)).toBe(1); + }); + + it('inserts project badge at correct position', () => { + const badgeCount = store.state.badges.length; + dummyBadge = { ...dummyBadge, kind: PROJECT_BADGE }; + + store.commit(types.RECEIVE_NEW_BADGE, dummyBadge); + + expect(store.state.badges.length).toBe(badgeCount + 1); + expect(store.state.badges.indexOf(dummyBadge)).toBe(3); + }); + }); + + describe('RECEIVE_NEW_BADGE_ERROR', () => { + beforeEach(() => { + store.replaceState({ + ...store.state, + isSaving: 'dummy value', + }); + }); + + it('sets isSaving to false', () => { + store.commit(types.RECEIVE_NEW_BADGE_ERROR); + + expect(store.state.isSaving).toBe(false); + }); + }); + + describe('RECEIVE_RENDERED_BADGE', () => { + beforeEach(() => { + store.replaceState({ + ...store.state, + isRendering: 'dummy value', + renderedBadge: 'dummy value', + }); + }); + + it('sets renderedBadge', () => { + store.commit(types.RECEIVE_RENDERED_BADGE, dummyBadge); + + expect(store.state.isRendering).toBe(false); + expect(store.state.renderedBadge).toBe(dummyBadge); + }); + }); + + describe('RECEIVE_RENDERED_BADGE_ERROR', () => { + beforeEach(() => { + store.replaceState({ + ...store.state, + isRendering: 'dummy value', + }); + }); + + it('sets isRendering to false', () => { + store.commit(types.RECEIVE_RENDERED_BADGE_ERROR); + + expect(store.state.isRendering).toBe(false); + }); + }); + + describe('RECEIVE_UPDATED_BADGE', () => { + beforeEach(() => { + const badges = [ + { ...dummyBadge, id: dummyBadge.id - 1 }, + dummyBadge, + { ...dummyBadge, id: dummyBadge.id + 1 }, + ]; + store.replaceState({ + ...store.state, + badgeInEditForm: createDummyBadge(), + badges, + isEditing: 'dummy value', + isSaving: 'dummy value', + renderedBadge: createDummyBadge(), + }); + }); + + it('resets the edit form', () => { + store.commit(types.RECEIVE_UPDATED_BADGE, dummyBadge); + + expect(store.state.badgeInAddForm).toBe(null); + expect(store.state.isSaving).toBe(false); + expect(store.state.renderedBadge).toBe(null); + }); + + it('replaces the updated badge', () => { + const badgeCount = store.state.badges.length; + const badgeIndex = store.state.badges.indexOf(dummyBadge); + const newBadge = { id: dummyBadge.id, dummy: 'value' }; + + store.commit(types.RECEIVE_UPDATED_BADGE, newBadge); + + expect(store.state.badges.length).toBe(badgeCount); + expect(store.state.badges[badgeIndex]).toBe(newBadge); + }); + }); + + describe('RECEIVE_UPDATED_BADGE_ERROR', () => { + beforeEach(() => { + store.replaceState({ + ...store.state, + isSaving: 'dummy value', + }); + }); + + it('sets isSaving to false', () => { + store.commit(types.RECEIVE_NEW_BADGE_ERROR); + + expect(store.state.isSaving).toBe(false); + }); + }); + + describe('REQUEST_DELETE_BADGE', () => { + beforeEach(() => { + const badges = [ + { ...dummyBadge, id: dummyBadge.id - 1, isDeleting: false }, + { ...dummyBadge, isDeleting: false }, + { ...dummyBadge, id: dummyBadge.id + 1, isDeleting: true }, + ]; + + store.replaceState({ + ...store.state, + badges, + }); + }); + + it('sets isDeleting to true', () => { + const badgeCount = store.state.badges.length; + + store.commit(types.REQUEST_DELETE_BADGE, dummyBadge.id); + + expect(store.state.badges.length).toBe(badgeCount); + expect(store.state.badges[0].isDeleting).toBe(false); + expect(store.state.badges[1].isDeleting).toBe(true); + expect(store.state.badges[2].isDeleting).toBe(true); + }); + }); + + describe('REQUEST_LOAD_BADGES', () => { + beforeEach(() => { + store.replaceState({ + ...store.state, + apiEndpointUrl: 'some endpoint', + docsUrl: 'some url', + isLoading: 'dummy value', + kind: 'some kind', + }); + }); + + it('sets isLoading to true and initializes the store', () => { + const dummyData = { + apiEndpointUrl: 'dummy endpoint', + docsUrl: 'dummy url', + kind: 'dummy kind', + }; + + store.commit(types.REQUEST_LOAD_BADGES, dummyData); + + expect(store.state.isLoading).toBe(true); + expect(store.state.apiEndpointUrl).toBe(dummyData.apiEndpointUrl); + expect(store.state.docsUrl).toBe(dummyData.docsUrl); + expect(store.state.kind).toBe(dummyData.kind); + }); + }); + + describe('REQUEST_NEW_BADGE', () => { + beforeEach(() => { + store.replaceState({ + ...store.state, + isSaving: 'dummy value', + }); + }); + + it('sets isSaving to true', () => { + store.commit(types.REQUEST_NEW_BADGE); + + expect(store.state.isSaving).toBe(true); + }); + }); + + describe('REQUEST_RENDERED_BADGE', () => { + beforeEach(() => { + store.replaceState({ + ...store.state, + isRendering: 'dummy value', + }); + }); + + it('sets isRendering to true', () => { + store.commit(types.REQUEST_RENDERED_BADGE); + + expect(store.state.isRendering).toBe(true); + }); + }); + + describe('REQUEST_UPDATED_BADGE', () => { + beforeEach(() => { + store.replaceState({ + ...store.state, + isSaving: 'dummy value', + }); + }); + + it('sets isSaving to true', () => { + store.commit(types.REQUEST_NEW_BADGE); + + expect(store.state.isSaving).toBe(true); + }); + }); + + describe('START_EDITING', () => { + beforeEach(() => { + store.replaceState({ + ...store.state, + badgeInEditForm: 'dummy value', + isEditing: 'dummy value', + renderedBadge: 'dummy value', + }); + }); + + it('initializes the edit form', () => { + store.commit(types.START_EDITING, dummyBadge); + + expect(store.state.isEditing).toBe(true); + expect(store.state.badgeInEditForm).toEqual(dummyBadge); + expect(store.state.renderedBadge).toEqual(dummyBadge); + }); + }); + + describe('STOP_EDITING', () => { + beforeEach(() => { + store.replaceState({ + ...store.state, + badgeInEditForm: 'dummy value', + isEditing: 'dummy value', + renderedBadge: 'dummy value', + }); + }); + + it('resets the edit form', () => { + store.commit(types.STOP_EDITING); + + expect(store.state.isEditing).toBe(false); + expect(store.state.badgeInEditForm).toBe(null); + expect(store.state.renderedBadge).toBe(null); + }); + }); + + describe('UPDATE_BADGE_IN_FORM', () => { + beforeEach(() => { + store.replaceState({ + ...store.state, + badgeInAddForm: 'dummy value', + badgeInEditForm: 'dummy value', + }); + }); + + it('sets badgeInEditForm if isEditing is true', () => { + store.state.isEditing = true; + + store.commit(types.UPDATE_BADGE_IN_FORM, dummyBadge); + + expect(store.state.badgeInEditForm).toBe(dummyBadge); + }); + + it('sets badgeInAddForm if isEditing is false', () => { + store.state.isEditing = false; + + store.commit(types.UPDATE_BADGE_IN_FORM, dummyBadge); + + expect(store.state.badgeInAddForm).toBe(dummyBadge); + }); + }); + + describe('UPDATE_BADGE_IN_MODAL', () => { + beforeEach(() => { + store.replaceState({ + ...store.state, + badgeInModal: 'dummy value', + }); + }); + + it('sets badgeInModal', () => { + store.commit(types.UPDATE_BADGE_IN_MODAL, dummyBadge); + + expect(store.state.badgeInModal).toBe(dummyBadge); + }); + }); +}); diff --git a/spec/javascripts/boards/board_blank_state_spec.js b/spec/javascripts/boards/board_blank_state_spec.js index f757dadfada..664ea202e93 100644 --- a/spec/javascripts/boards/board_blank_state_spec.js +++ b/spec/javascripts/boards/board_blank_state_spec.js @@ -1,7 +1,7 @@ /* global BoardService */ import Vue from 'vue'; import '~/boards/stores/boards_store'; -import boardBlankState from '~/boards/components/board_blank_state'; +import BoardBlankState from '~/boards/components/board_blank_state.vue'; import { mockBoardService } from './mock_data'; describe('Boards blank state', () => { @@ -9,7 +9,7 @@ describe('Boards blank state', () => { let fail = false; beforeEach((done) => { - const Comp = Vue.extend(boardBlankState); + const Comp = Vue.extend(BoardBlankState); gl.issueBoards.BoardsStore.create(); gl.boardService = mockBoardService(); diff --git a/spec/javascripts/boards/modal_store_spec.js b/spec/javascripts/boards/modal_store_spec.js index e9d77f035e3..797693a21aa 100644 --- a/spec/javascripts/boards/modal_store_spec.js +++ b/spec/javascripts/boards/modal_store_spec.js @@ -4,12 +4,11 @@ import '~/vue_shared/models/label'; import '~/boards/models/issue'; import '~/boards/models/list'; import '~/boards/models/assignee'; -import '~/boards/stores/modal_store'; +import Store from '~/boards/stores/modal_store'; describe('Modal store', () => { let issue; let issue2; - const Store = gl.issueBoards.ModalStore; beforeEach(() => { // Setup default state diff --git a/spec/javascripts/fixtures/one_white_pixel.png b/spec/javascripts/fixtures/one_white_pixel.png Binary files differnew file mode 100644 index 00000000000..073fcf40a18 --- /dev/null +++ b/spec/javascripts/fixtures/one_white_pixel.png diff --git a/spec/javascripts/helpers/vue_mount_component_helper.js b/spec/javascripts/helpers/vue_mount_component_helper.js index 34acdfbfba9..effacbcff4e 100644 --- a/spec/javascripts/helpers/vue_mount_component_helper.js +++ b/spec/javascripts/helpers/vue_mount_component_helper.js @@ -3,6 +3,12 @@ export const createComponentWithStore = (Component, store, propsData = {}) => ne propsData, }); +export const mountComponentWithStore = (Component, { el, props, store }) => + new Component({ + store, + propsData: props || { }, + }).$mount(el); + export default (Component, props = {}, el = null) => new Component({ propsData: props, }).$mount(el); diff --git a/spec/javascripts/matchers.js b/spec/javascripts/matchers.js new file mode 100644 index 00000000000..7cc5e753c22 --- /dev/null +++ b/spec/javascripts/matchers.js @@ -0,0 +1,35 @@ +export default { + toHaveSpriteIcon: () => ({ + compare(element, iconName) { + if (!iconName) { + throw new Error('toHaveSpriteIcon is missing iconName argument!'); + } + + if (!(element instanceof HTMLElement)) { + throw new Error(`${element} is not a DOM element!`); + } + + const iconReferences = [].slice.apply(element.querySelectorAll('svg use')); + const matchingIcon = iconReferences.find(reference => reference.getAttribute('xlink:href').endsWith(`#${iconName}`)); + const result = { + pass: !!matchingIcon, + }; + + if (result.pass) { + result.message = `${element.outerHTML} contains the sprite icon "${iconName}"!`; + } else { + result.message = `${element.outerHTML} does not contain the sprite icon "${iconName}"!`; + + const existingIcons = iconReferences.map((reference) => { + const iconUrl = reference.getAttribute('xlink:href'); + return `"${iconUrl.replace(/^.+#/, '')}"`; + }); + if (existingIcons.length > 0) { + result.message += ` (only found ${existingIcons.join(',')})`; + } + } + + return result; + }, + }), +}; diff --git a/spec/javascripts/test_bundle.js b/spec/javascripts/test_bundle.js index 1bcfdfe72b6..d158786e484 100644 --- a/spec/javascripts/test_bundle.js +++ b/spec/javascripts/test_bundle.js @@ -7,6 +7,9 @@ import Vue from 'vue'; import VueResource from 'vue-resource'; import { getDefaultAdapter } from '~/lib/utils/axios_utils'; +import { FIXTURES_PATH, TEST_HOST } from './test_constants'; + +import customMatchers from './matchers'; const isHeadlessChrome = /\bHeadlessChrome\//.test(navigator.userAgent); Vue.config.devtools = !isHeadlessChrome; @@ -27,15 +30,17 @@ Vue.config.errorHandler = function (err) { Vue.use(VueResource); // enable test fixtures -jasmine.getFixtures().fixturesPath = '/base/spec/javascripts/fixtures'; -jasmine.getJSONFixtures().fixturesPath = '/base/spec/javascripts/fixtures'; +jasmine.getFixtures().fixturesPath = FIXTURES_PATH; +jasmine.getJSONFixtures().fixturesPath = FIXTURES_PATH; + +beforeAll(() => jasmine.addMatchers(customMatchers)); // globalize common libraries window.$ = window.jQuery = $; // stub expected globals window.gl = window.gl || {}; -window.gl.TEST_HOST = 'http://test.host'; +window.gl.TEST_HOST = TEST_HOST; window.gon = window.gon || {}; window.gon.test_env = true; diff --git a/spec/javascripts/test_constants.js b/spec/javascripts/test_constants.js new file mode 100644 index 00000000000..df59195e9f6 --- /dev/null +++ b/spec/javascripts/test_constants.js @@ -0,0 +1,4 @@ +export const FIXTURES_PATH = '/base/spec/javascripts/fixtures'; +export const TEST_HOST = 'http://test.host'; + +export const DUMMY_IMAGE_URL = `${FIXTURES_PATH}/one_white_pixel.png`; diff --git a/spec/lib/forever_spec.rb b/spec/lib/forever_spec.rb new file mode 100644 index 00000000000..cf40c467c72 --- /dev/null +++ b/spec/lib/forever_spec.rb @@ -0,0 +1,21 @@ +require 'spec_helper' + +describe Forever do + describe '.date' do + subject { described_class.date } + + context 'when using PostgreSQL' do + it 'should return Postgresql future date' do + allow(Gitlab::Database).to receive(:postgresql?).and_return(true) + expect(subject).to eq(described_class::POSTGRESQL_DATE) + end + end + + context 'when using MySQL' do + it 'should return MySQL future date' do + allow(Gitlab::Database).to receive(:postgresql?).and_return(false) + expect(subject).to eq(described_class::MYSQL_DATE) + end + end + end +end diff --git a/spec/lib/gitlab/auth_spec.rb b/spec/lib/gitlab/auth_spec.rb index 18cef8ec996..9ccd0b206cc 100644 --- a/spec/lib/gitlab/auth_spec.rb +++ b/spec/lib/gitlab/auth_spec.rb @@ -5,7 +5,7 @@ describe Gitlab::Auth do describe 'constants' do it 'API_SCOPES contains all scopes for API access' do - expect(subject::API_SCOPES).to eq %i[api read_user sudo] + expect(subject::API_SCOPES).to eq %i[api read_user sudo read_repository] end it 'OPENID_SCOPES contains all scopes for OpenID Connect' do @@ -19,7 +19,7 @@ describe Gitlab::Auth do it 'optional_scopes contains all non-default scopes' do stub_container_registry_config(enabled: true) - expect(subject.optional_scopes).to eq %i[read_user sudo read_registry openid] + expect(subject.optional_scopes).to eq %i[read_user sudo read_repository read_registry openid] end context 'registry_scopes' do @@ -231,7 +231,7 @@ describe Gitlab::Auth do .to eq(Gitlab::Auth::Result.new(user, nil, :gitlab_or_ldap, full_authentication_abilities)) end - it 'falls through oauth authentication when the username is oauth2' do + it 'fails through oauth authentication when the username is oauth2' do user = create( :user, username: 'oauth2', @@ -255,6 +255,122 @@ describe Gitlab::Auth do expect { gl_auth.find_for_git_client('foo', 'bar', project: nil, ip: 'ip') }.to raise_error(Gitlab::Auth::MissingPersonalAccessTokenError) end + + context 'while using deploy tokens' do + let(:project) { create(:project) } + let(:auth_failure) { Gitlab::Auth::Result.new(nil, nil) } + + context 'when the deploy token has read_repository as scope' do + let(:deploy_token) { create(:deploy_token, read_registry: false, projects: [project]) } + let(:login) { deploy_token.username } + + it 'succeeds when login and token are valid' do + auth_success = Gitlab::Auth::Result.new(deploy_token, project, :deploy_token, [:download_code]) + + expect(gl_auth).to receive(:rate_limit!).with('ip', success: true, login: login) + expect(gl_auth.find_for_git_client(login, deploy_token.token, project: project, ip: 'ip')) + .to eq(auth_success) + end + + it 'fails when login is not valid' do + expect(gl_auth).to receive(:rate_limit!).with('ip', success: false, login: 'random_login') + expect(gl_auth.find_for_git_client('random_login', deploy_token.token, project: project, ip: 'ip')) + .to eq(auth_failure) + end + + it 'fails when token is not valid' do + expect(gl_auth).to receive(:rate_limit!).with('ip', success: false, login: login) + expect(gl_auth.find_for_git_client(login, '123123', project: project, ip: 'ip')) + .to eq(auth_failure) + end + + it 'fails if token is nil' do + expect(gl_auth).to receive(:rate_limit!).with('ip', success: false, login: login) + expect(gl_auth.find_for_git_client(login, nil, project: project, ip: 'ip')) + .to eq(auth_failure) + end + + it 'fails if token is not related to project' do + another_deploy_token = create(:deploy_token) + expect(gl_auth).to receive(:rate_limit!).with('ip', success: false, login: login) + expect(gl_auth.find_for_git_client(login, another_deploy_token.token, project: project, ip: 'ip')) + .to eq(auth_failure) + end + + it 'fails if token has been revoked' do + deploy_token.revoke! + + expect(deploy_token.revoked?).to be_truthy + expect(gl_auth).to receive(:rate_limit!).with('ip', success: false, login: 'deploy-token') + expect(gl_auth.find_for_git_client('deploy-token', deploy_token.token, project: project, ip: 'ip')) + .to eq(auth_failure) + end + end + + context 'when the deploy token has read_registry as a scope' do + let(:deploy_token) { create(:deploy_token, read_repository: false, projects: [project]) } + let(:login) { deploy_token.username } + + context 'when registry enabled' do + before do + stub_container_registry_config(enabled: true) + end + + it 'succeeds when login and token are valid' do + auth_success = Gitlab::Auth::Result.new(deploy_token, project, :deploy_token, [:read_container_image]) + + expect(gl_auth).to receive(:rate_limit!).with('ip', success: true, login: login) + expect(gl_auth.find_for_git_client(login, deploy_token.token, project: nil, ip: 'ip')) + .to eq(auth_success) + end + + it 'fails when login is not valid' do + expect(gl_auth).to receive(:rate_limit!).with('ip', success: false, login: 'random_login') + expect(gl_auth.find_for_git_client('random_login', deploy_token.token, project: project, ip: 'ip')) + .to eq(auth_failure) + end + + it 'fails when token is not valid' do + expect(gl_auth).to receive(:rate_limit!).with('ip', success: false, login: login) + expect(gl_auth.find_for_git_client(login, '123123', project: project, ip: 'ip')) + .to eq(auth_failure) + end + + it 'fails if token is nil' do + expect(gl_auth).to receive(:rate_limit!).with('ip', success: false, login: login) + expect(gl_auth.find_for_git_client(login, nil, project: nil, ip: 'ip')) + .to eq(auth_failure) + end + + it 'fails if token is not related to project' do + expect(gl_auth).to receive(:rate_limit!).with('ip', success: false, login: login) + expect(gl_auth.find_for_git_client(login, 'abcdef', project: nil, ip: 'ip')) + .to eq(auth_failure) + end + + it 'fails if token has been revoked' do + deploy_token.revoke! + + expect(deploy_token.revoked?).to be_truthy + expect(gl_auth).to receive(:rate_limit!).with('ip', success: false, login: 'deploy-token') + expect(gl_auth.find_for_git_client('deploy-token', deploy_token.token, project: nil, ip: 'ip')) + .to eq(auth_failure) + end + end + + context 'when registry disabled' do + before do + stub_container_registry_config(enabled: false) + end + + it 'fails when login and token are valid' do + expect(gl_auth).to receive(:rate_limit!).with('ip', success: false, login: login) + expect(gl_auth.find_for_git_client(login, deploy_token.token, project: nil, ip: 'ip')) + .to eq(auth_failure) + end + end + end + end end describe 'find_with_user_password' do diff --git a/spec/lib/gitlab/email/handler_spec.rb b/spec/lib/gitlab/email/handler_spec.rb index 650b01c4df4..386d73e6115 100644 --- a/spec/lib/gitlab/email/handler_spec.rb +++ b/spec/lib/gitlab/email/handler_spec.rb @@ -14,4 +14,28 @@ describe Gitlab::Email::Handler do expect(described_class.for('email', '')).to be_nil end end + + describe 'regexps are set properly' do + let(:addresses) do + %W(sent_notification_key#{Gitlab::IncomingEmail::UNSUBSCRIBE_SUFFIX} sent_notification_key path/to/project+merge-request+user_email_token path/to/project+user_email_token) + end + + it 'picks each handler at least once' do + matched_handlers = addresses.map do |address| + described_class.for('email', address).class + end + + expect(matched_handlers.uniq).to match_array(Gitlab::Email::Handler::HANDLERS) + end + + it 'can pick exactly one handler for each address' do + addresses.each do |address| + matched_handlers = Gitlab::Email::Handler::HANDLERS.select do |handler| + handler.new('email', address).can_handle? + end + + expect(matched_handlers.count).to eq(1), "#{address} matches #{matched_handlers.count} handlers: #{matched_handlers}" + end + end + end end diff --git a/spec/lib/gitlab/git_access_spec.rb b/spec/lib/gitlab/git_access_spec.rb index c870997e274..6c625596605 100644 --- a/spec/lib/gitlab/git_access_spec.rb +++ b/spec/lib/gitlab/git_access_spec.rb @@ -145,6 +145,33 @@ describe Gitlab::GitAccess do expect { push_access_check }.to raise_unauthorized(described_class::ERROR_MESSAGES[:auth_upload]) end end + + context 'when actor is DeployToken' do + let(:actor) { create(:deploy_token, projects: [project]) } + + context 'when DeployToken is active and belongs to project' do + it 'allows pull access' do + expect { pull_access_check }.not_to raise_error + end + + it 'blocks the push' do + expect { push_access_check }.to raise_unauthorized(described_class::ERROR_MESSAGES[:upload]) + end + end + + context 'when DeployToken does not belong to project' do + let(:another_project) { create(:project) } + let(:actor) { create(:deploy_token, projects: [another_project]) } + + it 'blocks pull access' do + expect { pull_access_check }.to raise_not_found + end + + it 'blocks the push' do + expect { push_access_check }.to raise_not_found + end + end + end end context 'when actor is nil' do @@ -594,6 +621,41 @@ describe Gitlab::GitAccess do end end + describe 'deploy token permissions' do + let(:deploy_token) { create(:deploy_token) } + let(:actor) { deploy_token } + + context 'pull code' do + context 'when project is authorized' do + before do + deploy_token.projects << project + end + + it { expect { pull_access_check }.not_to raise_error } + end + + context 'when unauthorized' do + context 'from public project' do + let(:project) { create(:project, :public, :repository) } + + it { expect { pull_access_check }.not_to raise_error } + end + + context 'from internal project' do + let(:project) { create(:project, :internal, :repository) } + + it { expect { pull_access_check }.to raise_not_found } + end + + context 'from private project' do + let(:project) { create(:project, :private, :repository) } + + it { expect { pull_access_check }.to raise_not_found } + end + end + end + end + describe 'build authentication_abilities permissions' do let(:authentication_abilities) { build_authentication_abilities } diff --git a/spec/lib/gitlab/import_export/all_models.yml b/spec/lib/gitlab/import_export/all_models.yml index b675d5dc031..897a5984782 100644 --- a/spec/lib/gitlab/import_export/all_models.yml +++ b/spec/lib/gitlab/import_export/all_models.yml @@ -145,6 +145,9 @@ pipeline_schedule: - pipelines pipeline_schedule_variables: - pipeline_schedule +deploy_tokens: +- project_deploy_tokens +- projects deploy_keys: - user - deploy_keys_projects @@ -281,6 +284,8 @@ project: - project_badges - source_of_merge_requests - internal_ids +- project_deploy_tokens +- deploy_tokens award_emoji: - awardable - user diff --git a/spec/mailers/notify_spec.rb b/spec/mailers/notify_spec.rb index 971a88e9ee9..43e419cd7de 100644 --- a/spec/mailers/notify_spec.rb +++ b/spec/mailers/notify_spec.rb @@ -390,11 +390,11 @@ describe Notify do end end - describe 'that have new commits' do + shared_examples 'a push to an existing merge request' do let(:push_user) { create(:user) } subject do - described_class.push_to_merge_request_email(recipient.id, merge_request.id, push_user.id, new_commits: merge_request.commits) + described_class.push_to_merge_request_email(recipient.id, merge_request.id, push_user.id, new_commits: merge_request.commits, existing_commits: existing_commits) end it_behaves_like 'a multiple recipients email' @@ -419,6 +419,18 @@ describe Notify do end end end + + describe 'that have new commits' do + let(:existing_commits) { [] } + + it_behaves_like 'a push to an existing merge request' + end + + describe 'that have new commits on top of an existing one' do + let(:existing_commits) { [merge_request.commits.first] } + + it_behaves_like 'a push to an existing merge request' + end end context 'for issue notes' do diff --git a/spec/models/deploy_token_spec.rb b/spec/models/deploy_token_spec.rb new file mode 100644 index 00000000000..5a15c23def4 --- /dev/null +++ b/spec/models/deploy_token_spec.rb @@ -0,0 +1,134 @@ +require 'spec_helper' + +describe DeployToken do + subject(:deploy_token) { create(:deploy_token) } + + it { is_expected.to have_many :project_deploy_tokens } + it { is_expected.to have_many(:projects).through(:project_deploy_tokens) } + + describe '#ensure_token' do + it 'should ensure a token' do + deploy_token.token = nil + deploy_token.save + + expect(deploy_token.token).not_to be_empty + end + end + + describe '#ensure_at_least_one_scope' do + context 'with at least one scope' do + it 'should be valid' do + is_expected.to be_valid + end + end + + context 'with no scopes' do + it 'should be invalid' do + deploy_token = build(:deploy_token, read_repository: false, read_registry: false) + + expect(deploy_token).not_to be_valid + expect(deploy_token.errors[:base].first).to eq("Scopes can't be blank") + end + end + end + + describe '#scopes' do + context 'with all the scopes' do + it 'should return scopes assigned to DeployToken' do + expect(deploy_token.scopes).to eq([:read_repository, :read_registry]) + end + end + + context 'with only one scope' do + it 'should return scopes assigned to DeployToken' do + deploy_token = create(:deploy_token, read_registry: false) + expect(deploy_token.scopes).to eq([:read_repository]) + end + end + end + + describe '#revoke!' do + it 'should update revoke attribute' do + deploy_token.revoke! + expect(deploy_token.revoked?).to be_truthy + end + end + + describe "#active?" do + context "when it has been revoked" do + it 'should return false' do + deploy_token.revoke! + expect(deploy_token.active?).to be_falsy + end + end + + context "when it hasn't been revoked" do + it 'should return true' do + expect(deploy_token.active?).to be_truthy + end + end + end + + describe '#username' do + it 'returns a harcoded username' do + expect(deploy_token.username).to eq("gitlab+deploy-token-#{deploy_token.id}") + end + end + + describe '#has_access_to?' do + let(:project) { create(:project) } + + subject(:deploy_token) { create(:deploy_token, projects: [project]) } + + context 'when the deploy token has access to the project' do + it 'should return true' do + expect(deploy_token.has_access_to?(project)).to be_truthy + end + end + + context 'when the deploy token does not have access to the project' do + it 'should return false' do + another_project = create(:project) + expect(deploy_token.has_access_to?(another_project)).to be_falsy + end + end + end + + describe '#expires_at' do + context 'when using Forever.date' do + let(:deploy_token) { create(:deploy_token, expires_at: nil) } + + it 'should return nil' do + expect(deploy_token.expires_at).to be_nil + end + end + + context 'when using a personalized date' do + let(:expires_at) { Date.today + 5.months } + let(:deploy_token) { create(:deploy_token, expires_at: expires_at) } + + it 'should return the personalized date' do + expect(deploy_token.expires_at).to eq(expires_at) + end + end + end + + describe '#expires_at=' do + context 'when passing nil' do + let(:deploy_token) { create(:deploy_token, expires_at: nil) } + + it 'should assign Forever.date' do + expect(deploy_token.read_attribute(:expires_at)).to eq(Forever.date) + end + end + + context 'when passign a value' do + let(:expires_at) { Date.today + 5.months } + let(:deploy_token) { create(:deploy_token, expires_at: expires_at) } + + it 'should respect the value' do + expect(deploy_token.read_attribute(:expires_at)).to eq(expires_at) + end + end + end +end diff --git a/spec/models/project_deploy_token_spec.rb b/spec/models/project_deploy_token_spec.rb new file mode 100644 index 00000000000..9e2e40c2e8f --- /dev/null +++ b/spec/models/project_deploy_token_spec.rb @@ -0,0 +1,14 @@ +require 'rails_helper' + +RSpec.describe ProjectDeployToken, type: :model do + let(:project) { create(:project) } + let(:deploy_token) { create(:deploy_token) } + subject(:project_deploy_token) { create(:project_deploy_token, project: project, deploy_token: deploy_token) } + + it { is_expected.to belong_to :project } + it { is_expected.to belong_to :deploy_token } + + it { is_expected.to validate_presence_of :deploy_token } + it { is_expected.to validate_presence_of :project } + it { is_expected.to validate_uniqueness_of(:deploy_token_id).scoped_to(:project_id) } +end diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb index 7007f78e702..2675c2f52c1 100644 --- a/spec/models/project_spec.rb +++ b/spec/models/project_spec.rb @@ -84,6 +84,8 @@ describe Project do it { is_expected.to have_many(:custom_attributes).class_name('ProjectCustomAttribute') } it { is_expected.to have_many(:project_badges).class_name('ProjectBadge') } it { is_expected.to have_many(:lfs_file_locks) } + it { is_expected.to have_many(:project_deploy_tokens) } + it { is_expected.to have_many(:deploy_tokens).through(:project_deploy_tokens) } context 'after initialized' do it "has a project_feature" do diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 73266c0085f..35db7616efb 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -1850,6 +1850,21 @@ describe User do it_behaves_like :member end + + context 'with subgroup with different owner for project runner', :nested_groups do + let(:group) { create(:group) } + let(:another_user) { create(:user) } + let(:subgroup) { create(:group, parent: group) } + let(:project) { create(:project, group: subgroup) } + + def add_user(access) + group.add_user(user, access) + group.add_user(another_user, :owner) + subgroup.add_user(another_user, :owner) + end + + it_behaves_like :member + end end describe '#projects_with_reporter_access_limited_to' do diff --git a/spec/policies/deploy_token_policy_spec.rb b/spec/policies/deploy_token_policy_spec.rb new file mode 100644 index 00000000000..eea287d895e --- /dev/null +++ b/spec/policies/deploy_token_policy_spec.rb @@ -0,0 +1,45 @@ +require 'spec_helper' + +describe DeployTokenPolicy do + let(:current_user) { create(:user) } + let(:project) { create(:project) } + let(:deploy_token) { create(:deploy_token, projects: [project]) } + + subject { described_class.new(current_user, deploy_token) } + + describe 'creating a deploy key' do + context 'when user is master' do + before do + project.add_master(current_user) + end + + it { is_expected.to be_allowed(:create_deploy_token) } + end + + context 'when user is not master' do + before do + project.add_developer(current_user) + end + + it { is_expected.to be_disallowed(:create_deploy_token) } + end + end + + describe 'updating a deploy key' do + context 'when user is master' do + before do + project.add_master(current_user) + end + + it { is_expected.to be_allowed(:update_deploy_token) } + end + + context 'when user is not master' do + before do + project.add_developer(current_user) + end + + it { is_expected.to be_disallowed(:update_deploy_token) } + end + end +end diff --git a/spec/services/deploy_tokens/create_service_spec.rb b/spec/services/deploy_tokens/create_service_spec.rb new file mode 100644 index 00000000000..3a2bbf1ecd1 --- /dev/null +++ b/spec/services/deploy_tokens/create_service_spec.rb @@ -0,0 +1,45 @@ +require 'spec_helper' + +describe DeployTokens::CreateService do + let(:project) { create(:project) } + let(:user) { create(:user) } + let(:deploy_token_params) { attributes_for(:deploy_token) } + + describe '#execute' do + subject { described_class.new(project, user, deploy_token_params).execute } + + context 'when the deploy token is valid' do + it 'should create a new DeployToken' do + expect { subject }.to change { DeployToken.count }.by(1) + end + + it 'should create a new ProjectDeployToken' do + expect { subject }.to change { ProjectDeployToken.count }.by(1) + end + + it 'returns a DeployToken' do + expect(subject).to be_an_instance_of DeployToken + end + end + + context 'when expires at date is not passed' do + let(:deploy_token_params) { attributes_for(:deploy_token, expires_at: '') } + + it 'should set Forever.date' do + expect(subject.read_attribute(:expires_at)).to eq(Forever.date) + end + end + + context 'when the deploy token is invalid' do + let(:deploy_token_params) { attributes_for(:deploy_token, read_repository: false, read_registry: false) } + + it 'should not create a new DeployToken' do + expect { subject }.not_to change { DeployToken.count } + end + + it 'should not create a new ProjectDeployToken' do + expect { subject }.not_to change { ProjectDeployToken.count } + end + end + end +end |