diff options
33 files changed, 1093 insertions, 37 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md index e90f599ced1..e1a6a014c57 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ documentation](doc/development/changelog.md) for instructions on adding your own entry. +## 11.0.4 (2018-07-17) + +### Security (1 change) + +- Fix symlink vulnerability in project import. + + ## 11.0.3 (2018-07-05) ### Fixed (14 changes, 1 of them is from the community) @@ -295,6 +302,14 @@ entry. - Workhorse to send raw diff and patch for commits. +## 10.8.6 (2018-07-17) + +### Security (2 changes) + +- Fix symlink vulnerability in project import. +- Merge branch 'fix-mr-widget-border' into 'master'. + + ## 10.8.5 (2018-06-21) ### Security (5 changes) @@ -524,6 +539,13 @@ entry. - Gitaly handles repository forks by default. +## 10.7.7 (2018-07-17) + +### Security (1 change) + +- Fix symlink vulnerability in project import. + + ## 10.7.6 (2018-06-21) ### Security (6 changes) diff --git a/app/assets/javascripts/vue_shared/components/reports/help_popover.vue b/app/assets/javascripts/vue_shared/components/reports/help_popover.vue new file mode 100644 index 00000000000..c5faa29fd2a --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/reports/help_popover.vue @@ -0,0 +1,48 @@ +<script> +import $ from 'jquery'; +import Icon from '~/vue_shared/components/icon.vue'; +import { inserted } from '~/feature_highlight/feature_highlight_helper'; +import { mouseenter, debouncedMouseleave, togglePopover } from '~/shared/popover'; + +export default { + name: 'ReportsHelpPopover', + components: { + Icon, + }, + props: { + options: { + type: Object, + required: true, + }, + }, + mounted() { + const $el = $(this.$el); + + $el + .popover({ + html: true, + trigger: 'focus', + container: 'body', + placement: 'top', + template: + '<div class="popover" role="tooltip"><div class="arrow"></div><p class="popover-header"></p><div class="popover-body"></div></div>', + ...this.options, + }) + .on('mouseenter', mouseenter) + .on('mouseleave', debouncedMouseleave(300)) + .on('inserted.bs.popover', inserted) + .on('show.bs.popover', () => { + window.addEventListener('scroll', togglePopover.bind($el, false), { once: true }); + }); + }, +}; +</script> +<template> + <button + type="button" + class="btn btn-blank btn-transparent btn-help" + tabindex="0" + > + <icon name="question" /> + </button> +</template> diff --git a/app/assets/javascripts/vue_shared/components/reports/issues_list.vue b/app/assets/javascripts/vue_shared/components/reports/issues_list.vue new file mode 100644 index 00000000000..e1e03e39ee0 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/reports/issues_list.vue @@ -0,0 +1,99 @@ +<script> +import IssuesBlock from './report_issues.vue'; + +/** + * Renders block of issues + */ + +export default { + components: { + IssuesBlock, + }, + props: { + unresolvedIssues: { + type: Array, + required: false, + default: () => [], + }, + resolvedIssues: { + type: Array, + required: false, + default: () => [], + }, + neutralIssues: { + type: Array, + required: false, + default: () => [], + }, + allIssues: { + type: Array, + required: false, + default: () => [], + }, + type: { + type: String, + required: true, + }, + }, + data() { + return { + isFullReportVisible: false, + }; + }, + computed: { + unresolvedIssuesStatus() { + return this.type === 'license' ? 'neutral' : 'failed'; + }, + }, + methods: { + openFullReport() { + this.isFullReportVisible = true; + }, + }, +}; +</script> +<template> + <div class="report-block-container"> + + <issues-block + v-if="unresolvedIssues.length" + :type="type" + :status="unresolvedIssuesStatus" + :issues="unresolvedIssues" + class="js-mr-code-new-issues" + /> + + <issues-block + v-if="isFullReportVisible" + :type="type" + :issues="allIssues" + class="js-mr-code-all-issues" + status="failed" + /> + + <issues-block + v-if="neutralIssues.length" + :type="type" + :issues="neutralIssues" + class="js-mr-code-non-issues" + status="neutral" + /> + + <issues-block + v-if="resolvedIssues.length" + :type="type" + :issues="resolvedIssues" + class="js-mr-code-resolved-issues" + status="success" + /> + + <button + v-if="allIssues.length && !isFullReportVisible" + type="button" + class="btn-link btn-blank prepend-left-10 js-expand-full-list break-link" + @click="openFullReport" + > + {{ s__("ciReport|Show complete code vulnerabilities report") }} + </button> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/components/reports/modal_open_name.vue b/app/assets/javascripts/vue_shared/components/reports/modal_open_name.vue new file mode 100644 index 00000000000..4f81cee2a38 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/reports/modal_open_name.vue @@ -0,0 +1,33 @@ +<script> +import { mapActions } from 'vuex'; + +export default { + props: { + issue: { + type: Object, + required: true, + }, + // failed || success + status: { + type: String, + required: true, + }, + }, + methods: { + ...mapActions(['openModal']), + handleIssueClick() { + const { issue, status, openModal } = this; + openModal({ issue, status }); + }, + }, +}; +</script> +<template> + <button + type="button" + class="btn-link btn-blank text-left break-link vulnerability-name-button" + @click="handleIssueClick()" + > + {{ issue.title }} + </button> +</template> diff --git a/app/assets/javascripts/vue_shared/components/reports/report_issues.vue b/app/assets/javascripts/vue_shared/components/reports/report_issues.vue new file mode 100644 index 00000000000..ecffb02a3a0 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/reports/report_issues.vue @@ -0,0 +1,72 @@ +<script> +import Icon from '~/vue_shared/components/icon.vue'; + +export default { + name: 'ReportIssues', + components: { + Icon, + }, + props: { + issues: { + type: Array, + required: true, + }, + type: { + type: String, + required: true, + }, + // failed || success + status: { + type: String, + required: true, + }, + }, + computed: { + iconName() { + if (this.isStatusFailed) { + return 'status_failed_borderless'; + } else if (this.isStatusSuccess) { + return 'status_success_borderless'; + } + + return 'status_created_borderless'; + }, + isStatusFailed() { + return this.status === 'failed'; + }, + isStatusSuccess() { + return this.status === 'success'; + }, + isStatusNeutral() { + return this.status === 'neutral'; + }, + }, +}; +</script> +<template> + <div> + <ul class="report-block-list"> + <li + v-for="(issue, index) in issues" + :class="{ 'is-dismissed': issue.isDismissed }" + :key="index" + class="report-block-list-issue" + > + <div + :class="{ + failed: isStatusFailed, + success: isStatusSuccess, + neutral: isStatusNeutral, + }" + class="report-block-list-icon append-right-5" + > + <icon + :name="iconName" + :size="32" + /> + </div> + + </li> + </ul> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/components/reports/report_link.vue b/app/assets/javascripts/vue_shared/components/reports/report_link.vue new file mode 100644 index 00000000000..74d68f9f439 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/reports/report_link.vue @@ -0,0 +1,29 @@ +<script> +export default { + name: 'ReportIssueLink', + props: { + issue: { + type: Object, + required: true, + }, + }, +}; +</script> +<template> + <div class="report-block-list-issue-description-link"> + in + + <a + v-if="issue.urlPath" + :href="issue.urlPath" + target="_blank" + rel="noopener noreferrer nofollow" + class="break-link" + > + {{ issue.path }}<template v-if="issue.line">:{{ issue.line }}</template> + </a> + <template v-else> + {{ issue.path }}<template v-if="issue.line">:{{ issue.line }}</template> + </template> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/components/reports/report_section.vue b/app/assets/javascripts/vue_shared/components/reports/report_section.vue new file mode 100644 index 00000000000..d383ed99a0c --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/reports/report_section.vue @@ -0,0 +1,192 @@ +<script> +import { __ } from '~/locale'; +import StatusIcon from '~/vue_merge_request_widget/components/mr_widget_status_icon.vue'; +import IssuesList from './issues_list.vue'; +import Popover from './help_popover.vue'; + +const LOADING = 'LOADING'; +const ERROR = 'ERROR'; +const SUCCESS = 'SUCCESS'; + +export default { + name: 'ReportSection', + components: { + IssuesList, + StatusIcon, + Popover, + }, + props: { + alwaysOpen: { + type: Boolean, + required: false, + default: false, + }, + type: { + type: String, + required: false, + default: '', + }, + status: { + type: String, + required: true, + }, + loadingText: { + type: String, + required: false, + default: '', + }, + errorText: { + type: String, + required: false, + default: '', + }, + successText: { + type: String, + required: true, + }, + unresolvedIssues: { + type: Array, + required: false, + default: () => [], + }, + resolvedIssues: { + type: Array, + required: false, + default: () => [], + }, + neutralIssues: { + type: Array, + required: false, + default: () => [], + }, + allIssues: { + type: Array, + required: false, + default: () => [], + }, + infoText: { + type: [String, Boolean], + required: false, + default: false, + }, + hasIssues: { + type: Boolean, + required: true, + }, + popoverOptions: { + type: Object, + default: () => ({}), + required: false, + }, + }, + + data() { + return { + isCollapsed: true, + }; + }, + + computed: { + collapseText() { + return this.isCollapsed ? __('Expand') : __('Collapse'); + }, + isLoading() { + return this.status === LOADING; + }, + loadingFailed() { + return this.status === ERROR; + }, + isSuccess() { + return this.status === SUCCESS; + }, + isCollapsible() { + return !this.alwaysOpen && this.hasIssues; + }, + isExpanded() { + return this.alwaysOpen || !this.isCollapsed; + }, + statusIconName() { + if (this.isLoading) { + return 'loading'; + } + if (this.loadingFailed || this.unresolvedIssues.length || this.neutralIssues.length) { + return 'warning'; + } + return 'success'; + }, + headerText() { + if (this.isLoading) { + return this.loadingText; + } + + if (this.isSuccess) { + return this.successText; + } + + if (this.loadingFailed) { + return this.errorText; + } + + return ''; + }, + hasPopover() { + return Object.keys(this.popoverOptions).length > 0; + }, + }, + methods: { + toggleCollapsed() { + this.isCollapsed = !this.isCollapsed; + }, + }, +}; +</script> +<template> + <section class="media-section"> + <div + class="media" + > + <status-icon + :status="statusIconName" + /> + <div + class="media-body space-children d-flex" + > + <span + class="js-code-text code-text" + > + {{ headerText }} + + <popover + v-if="hasPopover" + :options="popoverOptions" + class="prepend-left-5" + /> + </span> + + <button + v-if="isCollapsible" + type="button" + class="js-collapse-btn btn bt-default float-right btn-sm" + @click="toggleCollapsed" + > + {{ collapseText }} + </button> + </div> + </div> + + <div + v-if="hasIssues" + v-show="isExpanded" + class="js-report-section-container" + > + <slot name="body"> + <issues-list + :unresolved-issues="unresolvedIssues" + :resolved-issues="resolvedIssues" + :all-issues="allIssues" + :type="type" + /> + </slot> + </div> + </section> +</template> diff --git a/app/assets/javascripts/vue_shared/components/reports/summary_row.vue b/app/assets/javascripts/vue_shared/components/reports/summary_row.vue new file mode 100644 index 00000000000..997bad960e2 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/reports/summary_row.vue @@ -0,0 +1,66 @@ +<script> +import CiIcon from '~/vue_shared/components/ci_icon.vue'; +import LoadingIcon from '~/vue_shared/components/loading_icon.vue'; +import Popover from './help_popover.vue'; + +/** + * Renders the summary row for each report + * + * Used both in MR widget and Pipeline's view for: + * - Unit tests reports + * - Security reports + */ + +export default { + name: 'ReportSummaryRow', + components: { + CiIcon, + LoadingIcon, + Popover, + }, + props: { + summary: { + type: String, + required: true, + }, + statusIcon: { + type: String, + required: true, + }, + popoverOptions: { + type: Object, + required: true, + }, + }, + computed: { + iconStatus() { + return { + group: this.statusIcon, + icon: `status_${this.statusIcon}`, + }; + }, + }, +}; +</script> +<template> + <div class="report-block-list-issue report-block-list-issue-parent"> + <div class="report-block-list-icon append-right-10 prepend-left-5"> + <loading-icon + v-if="statusIcon === 'loading'" + css-class="report-block-list-loading-icon" + /> + <ci-icon + v-else + :status="iconStatus" + /> + </div> + + <div class="report-block-list-issue-description"> + <div class="report-block-list-issue-description-text"> + {{ summary }} + </div> + + <popover :options="popoverOptions" /> + </div> + </div> +</template> diff --git a/app/models/merge_request_diff.rb b/app/models/merge_request_diff.rb index 3d72c447b4b..a073bbfad20 100644 --- a/app/models/merge_request_diff.rb +++ b/app/models/merge_request_diff.rb @@ -182,7 +182,7 @@ class MergeRequestDiff < ActiveRecord::Base end def diffs(diff_options = nil) - if without_files? && comparison = diff_refs.compare_in(project) + if without_files? && comparison = diff_refs&.compare_in(project) # It should fetch the repository when diffs are cleaned by the system. # We don't keep these for storage overload purposes. # See https://gitlab.com/gitlab-org/gitlab-ce/issues/37639 diff --git a/app/views/admin/identities/edit.html.haml b/app/views/admin/identities/edit.html.haml index 1ad6ce969cb..fa09138c502 100644 --- a/app/views/admin/identities/edit.html.haml +++ b/app/views/admin/identities/edit.html.haml @@ -1,3 +1,6 @@ +- add_to_breadcrumbs "Users", admin_users_path +- add_to_breadcrumbs @user.name, admin_user_identities_path(@user) +- breadcrumb_title "Edit Identity" - page_title _("Edit"), @identity.provider, _("Identities"), @user.name, _("Users") %h3.page-title = _('Edit identity for %{user_name}') % { user_name: @user.name } diff --git a/app/views/admin/identities/index.html.haml b/app/views/admin/identities/index.html.haml index 59373ee6752..df3df159947 100644 --- a/app/views/admin/identities/index.html.haml +++ b/app/views/admin/identities/index.html.haml @@ -1,3 +1,5 @@ +- add_to_breadcrumbs "Users", admin_users_path +- breadcrumb_title @user.name - page_title _("Identities"), @user.name, _("Users") = render 'admin/users/head' diff --git a/app/views/admin/identities/new.html.haml b/app/views/admin/identities/new.html.haml index ee743b0fd3c..c28d22625b5 100644 --- a/app/views/admin/identities/new.html.haml +++ b/app/views/admin/identities/new.html.haml @@ -1,3 +1,6 @@ +- add_to_breadcrumbs "Users", admin_users_path +- add_to_breadcrumbs @user.name, admin_user_identities_path(@user) +- breadcrumb_title "New Identity" - page_title _("New Identity") %h3.page-title= _('New identity') %hr diff --git a/app/views/admin/impersonation_tokens/index.html.haml b/app/views/admin/impersonation_tokens/index.html.haml index 1378dde52ab..9e490713ef3 100644 --- a/app/views/admin/impersonation_tokens/index.html.haml +++ b/app/views/admin/impersonation_tokens/index.html.haml @@ -1,3 +1,5 @@ +- add_to_breadcrumbs "Users", admin_users_path +- breadcrumb_title @user.name - page_title "Impersonation Tokens", @user.name, "Users" = render 'admin/users/head' diff --git a/app/views/admin/users/keys.html.haml b/app/views/admin/users/keys.html.haml index 0f644121e62..103bbb3b063 100644 --- a/app/views/admin/users/keys.html.haml +++ b/app/views/admin/users/keys.html.haml @@ -1,3 +1,5 @@ +- add_to_breadcrumbs "Users", admin_users_path +- breadcrumb_title @user.name - page_title "SSH Keys", @user.name, "Users" = render 'admin/users/head' = render 'profiles/keys/key_table', admin: true diff --git a/app/views/admin/users/projects.html.haml b/app/views/admin/users/projects.html.haml index cf50d45f755..3d39c1da408 100644 --- a/app/views/admin/users/projects.html.haml +++ b/app/views/admin/users/projects.html.haml @@ -1,3 +1,5 @@ +- add_to_breadcrumbs "Users", admin_users_path +- breadcrumb_title @user.name - page_title "Groups and projects", @user.name, "Users" = render 'admin/users/head' diff --git a/app/views/projects/_home_panel.html.haml b/app/views/projects/_home_panel.html.haml index 89940512bc6..74ab8cf8250 100644 --- a/app/views/projects/_home_panel.html.haml +++ b/app/views/projects/_home_panel.html.haml @@ -12,6 +12,9 @@ .project-home-desc - if @project.description.present? = markdown_field(@project, :description) + - if can?(current_user, :read_project, @project) + .text-secondary.prepend-top-8 + = s_('ProjectPage|Project ID: %{project_id}') % { project_id: @project.id } - if @project.forked? %p diff --git a/changelogs/unreleased/47419-Fix-breadcrumbs.yml b/changelogs/unreleased/47419-Fix-breadcrumbs.yml new file mode 100644 index 00000000000..1a7f8196683 --- /dev/null +++ b/changelogs/unreleased/47419-Fix-breadcrumbs.yml @@ -0,0 +1,5 @@ +--- +title: Fix breadcrumbs in Admin/User interface. +merge_request: 19608 +author: Robin Naundorf +type: fixed diff --git a/changelogs/unreleased/features-show-project-id-on-home-panel.yml b/changelogs/unreleased/features-show-project-id-on-home-panel.yml new file mode 100644 index 00000000000..f592be07a52 --- /dev/null +++ b/changelogs/unreleased/features-show-project-id-on-home-panel.yml @@ -0,0 +1,5 @@ +--- +title: Show Project ID on project home panel +merge_request: 20305 +author: Tuğçe Nur Taş +type: added diff --git a/changelogs/unreleased/osw-fallback-to-collection-when-no-diff-refs.yml b/changelogs/unreleased/osw-fallback-to-collection-when-no-diff-refs.yml new file mode 100644 index 00000000000..71a2d94fc55 --- /dev/null +++ b/changelogs/unreleased/osw-fallback-to-collection-when-no-diff-refs.yml @@ -0,0 +1,5 @@ +--- +title: Render MR page when diffs cannot be fetched from the database or the git repository +merge_request: 20680 +author: +type: fixed diff --git a/changelogs/unreleased/sh-fix-issue-49133.yml b/changelogs/unreleased/sh-fix-issue-49133.yml new file mode 100644 index 00000000000..847220d88b2 --- /dev/null +++ b/changelogs/unreleased/sh-fix-issue-49133.yml @@ -0,0 +1,5 @@ +--- +title: Fix symlink vulnerability in project import +merge_request: +author: +type: security diff --git a/doc/user/gitlab_com/index.md b/doc/user/gitlab_com/index.md index d054561d5f3..20886faf418 100644 --- a/doc/user/gitlab_com/index.md +++ b/doc/user/gitlab_com/index.md @@ -64,7 +64,8 @@ Below are the current settings regarding [GitLab CI/CD](../../ci/README.md). ## Repository size limit -The maximum size your Git repository is allowed to be including LFS. +The maximum size your Git repository is allowed to be, including LFS. If you are near +or over the size limit, you can [reduce your repository size with Git](../project/repository/reducing_the_repo_size_using_git.md). | Setting | GitLab.com | Default | | ----------- | ----------------- | ------------- | diff --git a/lib/gitlab/import_export/file_importer.rb b/lib/gitlab/import_export/file_importer.rb index 0f4c3498036..4c411f4847e 100644 --- a/lib/gitlab/import_export/file_importer.rb +++ b/lib/gitlab/import_export/file_importer.rb @@ -4,6 +4,7 @@ module Gitlab include Gitlab::ImportExport::CommandLineUtil MAX_RETRIES = 8 + IGNORED_FILENAMES = %w(. ..).freeze def self.import(*args) new(*args).import @@ -59,7 +60,7 @@ module Gitlab end def extracted_files - Dir.glob("#{@shared.export_path}/**/*", File::FNM_DOTMATCH).reject { |f| f =~ %r{.*/\.{1,2}$} } + Dir.glob("#{@shared.export_path}/**/*", File::FNM_DOTMATCH).reject { |f| IGNORED_FILENAMES.include?(File.basename(f)) } end end end diff --git a/locale/gitlab.pot b/locale/gitlab.pot index a5f97e9548a..562760552e0 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -4057,6 +4057,9 @@ msgstr "" msgid "ProjectLifecycle|Stage" msgstr "" +msgid "ProjectPage|Project ID: %{project_id}" +msgstr "" + msgid "Projects" msgstr "" @@ -5857,6 +5860,9 @@ msgstr "" msgid "branch name" msgstr "" +msgid "ciReport|Show complete code vulnerabilities report" +msgstr "" + msgid "command line instructions" msgstr "" diff --git a/spec/controllers/projects/merge_requests_controller_spec.rb b/spec/controllers/projects/merge_requests_controller_spec.rb index 444415011a9..1692f299552 100644 --- a/spec/controllers/projects/merge_requests_controller_spec.rb +++ b/spec/controllers/projects/merge_requests_controller_spec.rb @@ -53,6 +53,23 @@ describe Projects::MergeRequestsController do it_behaves_like "loads labels", :show describe 'as html' do + context 'when diff files were cleaned' do + render_views + + it 'renders page when diff size is not persisted and diff_refs does not exist' do + diff = merge_request.merge_request_diff + + diff.clean! + diff.update!(real_size: nil, + start_commit_sha: nil, + base_commit_sha: nil) + + go(format: :html) + + expect(response).to be_success + end + end + it "renders merge request page" do go(format: :html) diff --git a/spec/features/admin/admin_users_spec.rb b/spec/features/admin/admin_users_spec.rb index 9e3221577c7..6c194c9a646 100644 --- a/spec/features/admin/admin_users_spec.rb +++ b/spec/features/admin/admin_users_spec.rb @@ -315,6 +315,40 @@ describe "Admin::Users" do end end + describe 'show breadcrumbs' do + it do + visit admin_user_path(user) + + check_breadcrumb(user.name) + + visit projects_admin_user_path(user) + + check_breadcrumb(user.name) + + visit keys_admin_user_path(user) + + check_breadcrumb(user.name) + + visit admin_user_impersonation_tokens_path(user) + + check_breadcrumb(user.name) + + visit admin_user_identities_path(user) + + check_breadcrumb(user.name) + + visit new_admin_user_identity_path(user) + + check_breadcrumb("New Identity") + + visit admin_user_identities_path(user) + + find('.table').find(:link, 'Edit').click + + check_breadcrumb("Edit Identity") + end + end + describe 'show user attributes' do it do visit admin_users_path @@ -409,4 +443,8 @@ describe "Admin::Users" do expect(page).not_to have_content('twitter') end end + + def check_breadcrumb(content) + expect(find('.breadcrumbs-sub-title')).to have_content(content) + end end diff --git a/spec/javascripts/vue_shared/components/reports/modal_open_name_spec.js b/spec/javascripts/vue_shared/components/reports/modal_open_name_spec.js new file mode 100644 index 00000000000..8635203c413 --- /dev/null +++ b/spec/javascripts/vue_shared/components/reports/modal_open_name_spec.js @@ -0,0 +1,45 @@ +import Vue from 'vue'; +import Vuex from 'vuex'; +import component from '~/vue_shared/components/reports/modal_open_name.vue'; +import { mountComponentWithStore } from 'spec/helpers/vue_mount_component_helper'; + +describe('Modal open name', () => { + const Component = Vue.extend(component); + let vm; + + const store = new Vuex.Store({ + actions: { + openModal: () => {}, + }, + state: {}, + mutations: {}, + }); + + beforeEach(() => { + vm = mountComponentWithStore(Component, { + store, + props: { + issue: { + title: 'Issue', + }, + status: 'failed', + }, + }); + }); + + afterEach(() => { + vm.$destroy(); + }); + + it('renders the issue name', () => { + expect(vm.$el.textContent.trim()).toEqual('Issue'); + }); + + it('calls openModal actions when button is clicked', () => { + spyOn(vm, 'openModal'); + + vm.$el.click(); + + expect(vm.openModal).toHaveBeenCalled(); + }); +}); diff --git a/spec/javascripts/vue_shared/components/reports/report_issues_spec.js b/spec/javascripts/vue_shared/components/reports/report_issues_spec.js new file mode 100644 index 00000000000..e69de29bb2d --- /dev/null +++ b/spec/javascripts/vue_shared/components/reports/report_issues_spec.js diff --git a/spec/javascripts/vue_shared/components/reports/report_link_spec.js b/spec/javascripts/vue_shared/components/reports/report_link_spec.js new file mode 100644 index 00000000000..a4691f3712f --- /dev/null +++ b/spec/javascripts/vue_shared/components/reports/report_link_spec.js @@ -0,0 +1,71 @@ +import Vue from 'vue'; +import component from '~/vue_shared/components/reports/report_link.vue'; +import mountComponent from '../../../helpers/vue_mount_component_helper'; + +describe('report link', () => { + let vm; + + const Component = Vue.extend(component); + + afterEach(() => { + vm.$destroy(); + }); + + describe('With url', () => { + it('renders link', () => { + vm = mountComponent(Component, { + issue: { + path: 'Gemfile.lock', + urlPath: '/Gemfile.lock', + }, + }); + + expect(vm.$el.textContent.trim()).toContain('in'); + expect(vm.$el.querySelector('a').getAttribute('href')).toEqual('/Gemfile.lock'); + expect(vm.$el.querySelector('a').textContent.trim()).toEqual('Gemfile.lock'); + }); + }); + + describe('Without url', () => { + it('does not render link', () => { + vm = mountComponent(Component, { + issue: { + path: 'Gemfile.lock', + }, + }); + + expect(vm.$el.querySelector('a')).toBeNull(); + expect(vm.$el.textContent.trim()).toContain('in'); + expect(vm.$el.textContent.trim()).toContain('Gemfile.lock'); + }); + }); + + describe('with line', () => { + it('renders line number', () => { + vm = mountComponent(Component, { + issue: { + path: 'Gemfile.lock', + urlPath: + 'https://groups.google.com/forum/#!topic/rubyonrails-security/335P1DcLG00', + line: 22, + }, + }); + + expect(vm.$el.querySelector('a').textContent.trim()).toContain('Gemfile.lock:22'); + }); + }); + + describe('without line', () => { + it('does not render line number', () => { + vm = mountComponent(Component, { + issue: { + path: 'Gemfile.lock', + urlPath: + 'https://groups.google.com/forum/#!topic/rubyonrails-security/335P1DcLG00', + }, + }); + + expect(vm.$el.querySelector('a').textContent.trim()).not.toContain(':22'); + }); + }); +}); diff --git a/spec/javascripts/vue_shared/components/reports/report_section_spec.js b/spec/javascripts/vue_shared/components/reports/report_section_spec.js new file mode 100644 index 00000000000..07401181ffd --- /dev/null +++ b/spec/javascripts/vue_shared/components/reports/report_section_spec.js @@ -0,0 +1,174 @@ +import Vue from 'vue'; +import reportSection from '~/vue_shared/components/reports/report_section.vue'; +import mountComponent from 'spec/helpers/vue_mount_component_helper'; + +describe('Report section', () => { + let vm; + const ReportSection = Vue.extend(reportSection); + + const resolvedIssues = [ + { + name: 'Insecure Dependency', + fingerprint: 'ca2e59451e98ae60ba2f54e3857c50e5', + path: 'Gemfile.lock', + line: 12, + urlPath: 'foo/Gemfile.lock', + }, + ]; + + afterEach(() => { + vm.$destroy(); + }); + + describe('computed', () => { + beforeEach(() => { + vm = mountComponent(ReportSection, { + type: 'codequality', + status: 'SUCCESS', + loadingText: 'Loading codeclimate report', + errorText: 'foo', + successText: 'Code quality improved on 1 point and degraded on 1 point', + resolvedIssues, + hasIssues: false, + alwaysOpen: false, + }); + }); + + describe('isCollapsible', () => { + const testMatrix = [ + { hasIssues: false, alwaysOpen: false, isCollapsible: false }, + { hasIssues: false, alwaysOpen: true, isCollapsible: false }, + { hasIssues: true, alwaysOpen: false, isCollapsible: true }, + { hasIssues: true, alwaysOpen: true, isCollapsible: false }, + ]; + + testMatrix.forEach(({ hasIssues, alwaysOpen, isCollapsible }) => { + const issues = hasIssues ? 'has issues' : 'has no issues'; + const open = alwaysOpen ? 'is always open' : 'is not always open'; + + it(`is ${isCollapsible}, if the report ${issues} and ${open}`, done => { + vm.hasIssues = hasIssues; + vm.alwaysOpen = alwaysOpen; + + Vue.nextTick() + .then(() => { + expect(vm.isCollapsible).toBe(isCollapsible); + }) + .then(done) + .catch(done.fail); + }); + }); + }); + + describe('isExpanded', () => { + const testMatrix = [ + { isCollapsed: false, alwaysOpen: false, isExpanded: true }, + { isCollapsed: false, alwaysOpen: true, isExpanded: true }, + { isCollapsed: true, alwaysOpen: false, isExpanded: false }, + { isCollapsed: true, alwaysOpen: true, isExpanded: true }, + ]; + + testMatrix.forEach(({ isCollapsed, alwaysOpen, isExpanded }) => { + const issues = isCollapsed ? 'is collapsed' : 'is not collapsed'; + const open = alwaysOpen ? 'is always open' : 'is not always open'; + + it(`is ${isExpanded}, if the report ${issues} and ${open}`, done => { + vm.isCollapsed = isCollapsed; + vm.alwaysOpen = alwaysOpen; + + Vue.nextTick() + .then(() => { + expect(vm.isExpanded).toBe(isExpanded); + }) + .then(done) + .catch(done.fail); + }); + }); + }); + }); + describe('when it is loading', () => { + it('should render loading indicator', () => { + vm = mountComponent(ReportSection, { + type: 'codequality', + status: 'LOADING', + loadingText: 'Loading codeclimate report', + errorText: 'foo', + successText: 'Code quality improved on 1 point and degraded on 1 point', + hasIssues: false, + }); + expect(vm.$el.textContent.trim()).toEqual('Loading codeclimate report'); + }); + }); + + describe('with success status', () => { + beforeEach(() => { + vm = mountComponent(ReportSection, { + type: 'codequality', + status: 'SUCCESS', + loadingText: 'Loading codeclimate report', + errorText: 'foo', + successText: 'Code quality improved on 1 point and degraded on 1 point', + resolvedIssues, + hasIssues: true, + }); + }); + + it('should render provided data', () => { + expect(vm.$el.querySelector('.js-code-text').textContent.trim()).toEqual( + 'Code quality improved on 1 point and degraded on 1 point', + ); + + expect(vm.$el.querySelectorAll('.js-mr-code-resolved-issues li').length).toEqual( + resolvedIssues.length, + ); + }); + + describe('toggleCollapsed', () => { + const hiddenCss = { display: 'none' }; + + it('toggles issues', done => { + vm.$el.querySelector('button').click(); + + Vue.nextTick() + .then(() => { + expect(vm.$el.querySelector('.js-report-section-container')).not.toHaveCss(hiddenCss); + expect(vm.$el.querySelector('button').textContent.trim()).toEqual('Collapse'); + + vm.$el.querySelector('button').click(); + }) + .then(Vue.nextTick) + .then(() => { + expect(vm.$el.querySelector('.js-report-section-container')).toHaveCss(hiddenCss); + expect(vm.$el.querySelector('button').textContent.trim()).toEqual('Expand'); + }) + .then(done) + .catch(done.fail); + }); + + it('is always expanded, if always-open is set to true', done => { + vm.alwaysOpen = true; + Vue.nextTick() + .then(() => { + expect(vm.$el.querySelector('.js-report-section-container')).not.toHaveCss(hiddenCss); + expect(vm.$el.querySelector('button')).toBeNull(); + }) + .then(done) + .catch(done.fail); + }); + }); + }); + + describe('with failed request', () => { + it('should render error indicator', () => { + vm = mountComponent(ReportSection, { + type: 'codequality', + status: 'ERROR', + loadingText: 'Loading codeclimate report', + errorText: 'Failed to load codeclimate report', + successText: 'Code quality improved on 1 point and degraded on 1 point', + hasIssues: false, + }); + expect(vm.$el.textContent.trim()).toEqual('Failed to load codeclimate report'); + }); + }); +}); diff --git a/spec/javascripts/vue_shared/components/reports/summary_row_spec.js b/spec/javascripts/vue_shared/components/reports/summary_row_spec.js new file mode 100644 index 00000000000..ac076f05bc0 --- /dev/null +++ b/spec/javascripts/vue_shared/components/reports/summary_row_spec.js @@ -0,0 +1,37 @@ +import Vue from 'vue'; +import component from '~/vue_shared/components/reports/summary_row.vue'; +import mountComponent from 'spec/helpers/vue_mount_component_helper'; + +describe('Summary row', () => { + const Component = Vue.extend(component); + let vm; + + const props = { + summary: 'SAST detected 1 new vulnerability and 1 fixed vulnerability', + popoverOptions: { + title: 'Static Application Security Testing (SAST)', + content: '<a>Learn more about SAST</a>', + }, + statusIcon: 'warning', + }; + + beforeEach(() => { + vm = mountComponent(Component, props); + }); + + afterEach(() => { + vm.$destroy(); + }); + + it('renders provided summary', () => { + expect( + vm.$el.querySelector('.report-block-list-issue-description-text').textContent.trim(), + ).toEqual(props.summary); + }); + + it('renders provided icon', () => { + expect(vm.$el.querySelector('.report-block-list-icon span').classList).toContain( + 'js-ci-status-icon-warning', + ); + }); +}); diff --git a/spec/lib/gitlab/import_export/file_importer_spec.rb b/spec/lib/gitlab/import_export/file_importer_spec.rb index 58b9fb06cc5..265937f899e 100644 --- a/spec/lib/gitlab/import_export/file_importer_spec.rb +++ b/spec/lib/gitlab/import_export/file_importer_spec.rb @@ -7,6 +7,7 @@ describe Gitlab::ImportExport::FileImporter do let(:symlink_file) { "#{shared.export_path}/invalid.json" } let(:hidden_symlink_file) { "#{shared.export_path}/.hidden" } let(:subfolder_symlink_file) { "#{shared.export_path}/subfolder/invalid.json" } + let(:evil_symlink_file) { "#{shared.export_path}/.\nevil" } before do stub_const('Gitlab::ImportExport::FileImporter::MAX_RETRIES', 0) @@ -34,6 +35,10 @@ describe Gitlab::ImportExport::FileImporter do expect(File.exist?(hidden_symlink_file)).to be false end + it 'removes evil symlinks in root folder' do + expect(File.exist?(evil_symlink_file)).to be false + end + it 'removes symlinks in subfolders' do expect(File.exist?(subfolder_symlink_file)).to be false end @@ -75,5 +80,7 @@ describe Gitlab::ImportExport::FileImporter do FileUtils.touch(valid_file) FileUtils.ln_s(valid_file, symlink_file) FileUtils.ln_s(valid_file, subfolder_symlink_file) + FileUtils.ln_s(valid_file, hidden_symlink_file) + FileUtils.ln_s(valid_file, evil_symlink_file) end end diff --git a/spec/models/merge_request_diff_spec.rb b/spec/models/merge_request_diff_spec.rb index ccc3ff861c5..0aee78ac12d 100644 --- a/spec/models/merge_request_diff_spec.rb +++ b/spec/models/merge_request_diff_spec.rb @@ -82,6 +82,14 @@ describe MergeRequestDiff do diff.diffs end + + it 'returns persisted diffs if diff refs does not exist' do + expect(diff).to receive(:load_diffs) + + diff.update!(start_commit_sha: nil, base_commit_sha: nil) + + diff.diffs + end end end diff --git a/spec/views/projects/_home_panel.html.haml_spec.rb b/spec/views/projects/_home_panel.html.haml_spec.rb index 15fce65979b..b56940a9613 100644 --- a/spec/views/projects/_home_panel.html.haml_spec.rb +++ b/spec/views/projects/_home_panel.html.haml_spec.rb @@ -1,55 +1,48 @@ require 'spec_helper' describe 'projects/_home_panel' do - let(:group) { create(:group) } - let(:project) { create(:project, :public, namespace: group) } + context 'notifications' do + let(:project) { create(:project) } - let(:notification_settings) do - user&.notification_settings_for(project) - end + before do + assign(:project, project) - before do - assign(:project, project) - assign(:notification_setting, notification_settings) + allow(view).to receive(:current_user).and_return(user) + allow(view).to receive(:can?).with(user, :read_project, project).and_return(false) + end - allow(view).to receive(:current_user).and_return(user) - allow(view).to receive(:can?).and_return(false) - end + context 'when user is signed in' do + let(:user) { create(:user) } - context 'when user is signed in' do - let(:user) { create(:user) } + before do + notification_settings = user.notification_settings_for(project) + assign(:notification_setting, notification_settings) + end - it 'makes it possible to set notification level' do - render + it 'makes it possible to set notification level' do + render - expect(view).to render_template('shared/notifications/_button') - expect(rendered).to have_selector('.notification-dropdown') + expect(view).to render_template('shared/notifications/_button') + expect(rendered).to have_selector('.notification-dropdown') + end end - end - - context 'when user is signed out' do - let(:user) { nil } - it 'is not possible to set notification level' do - render + context 'when user is signed out' do + let(:user) { nil } - expect(rendered).not_to have_selector('.notification_dropdown') - end - end - - context 'when project' do - let!(:user) { create(:user) } - let(:badges) { project.badges } + before do + assign(:notification_setting, nil) + end - context 'has no badges' do - it 'should not render any badge' do + it 'is not possible to set notification level' do render - expect(rendered).to have_selector('.project-badges') - expect(rendered).not_to have_selector('.project-badges > a') + expect(rendered).not_to have_selector('.notification_dropdown') end end + end + context 'badges' do shared_examples 'show badges' do it 'should render the all badges' do render @@ -62,7 +55,31 @@ describe 'projects/_home_panel' do end end + let(:user) { create(:user) } + let(:badges) { project.badges } + + before do + assign(:project, project) + + allow(view).to receive(:current_user).and_return(user) + allow(view).to receive(:can?).with(user, :read_project, project).and_return(false) + end + + context 'has no badges' do + let(:project) { create(:project) } + + it 'should not render any badge' do + render + + expect(rendered).to have_selector('.project-badges') + expect(rendered).not_to have_selector('.project-badges > a') + end + end + context 'only has group badges' do + let(:group) { create(:group) } + let(:project) { create(:project, namespace: group) } + before do create(:group_badge, group: project.group) end @@ -71,6 +88,8 @@ describe 'projects/_home_panel' do end context 'only has project badges' do + let(:project) { create(:project) } + before do create(:project_badge, project: project) end @@ -79,6 +98,9 @@ describe 'projects/_home_panel' do end context 'has both group and project badges' do + let(:group) { create(:group) } + let(:project) { create(:project, namespace: group) } + before do create(:project_badge, project: project) create(:group_badge, group: project.group) @@ -87,4 +109,35 @@ describe 'projects/_home_panel' do it_behaves_like 'show badges' end end + + context 'project id' do + let(:project) { create(:project) } + let(:user) { create(:user) } + + before do + assign(:project, project) + + allow(view).to receive(:current_user).and_return(user) + end + + context 'user can read project' do + it 'is shown' do + allow(view).to receive(:can?).with(user, :read_project, project).and_return(true) + + render + + expect(rendered).to have_content("Project ID: #{project.id}") + end + end + + context 'user cannot read project' do + it 'is not shown' do + allow(view).to receive(:can?).with(user, :read_project, project).and_return(false) + + render + + expect(rendered).not_to have_content("Project ID: #{project.id}") + end + end + end end |