Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2020-10-12 21:08:56 +00:00
parent 884a65481f
commit f5dcc7ae73
45 changed files with 955 additions and 212 deletions

View File

@ -54,6 +54,7 @@ const Api = {
releaseLinkPath: '/api/:version/projects/:id/releases/:tag_name/assets/links/:link_id',
mergeRequestsPipeline: '/api/:version/projects/:id/merge_requests/:merge_request_iid/pipelines',
adminStatisticsPath: '/api/:version/application/statistics',
pipelineJobsPath: '/api/:version/projects/:id/pipelines/:pipeline_id/jobs',
pipelineSinglePath: '/api/:version/projects/:id/pipelines/:pipeline_id',
pipelinesPath: '/api/:version/projects/:id/pipelines/',
createPipelinePath: '/api/:version/projects/:id/pipeline',
@ -599,6 +600,14 @@ const Api = {
return axios.get(url);
},
pipelineJobs(projectId, pipelineId) {
const url = Api.buildUrl(this.pipelineJobsPath)
.replace(':id', encodeURIComponent(projectId))
.replace(':pipeline_id', encodeURIComponent(pipelineId));
return axios.get(url);
},
// Return all pipelines for a project or filter by query params
pipelines(id, options = {}) {
const url = Api.buildUrl(this.pipelinesPath).replace(':id', encodeURIComponent(id));

View File

@ -71,14 +71,6 @@ export default {
resolvedCommentsToggleIcon() {
return this.resolvedDiscussionsExpanded ? 'chevron-down' : 'chevron-right';
},
showTodoButton() {
return this.glFeatures.designManagementTodoButton;
},
sidebarWrapperClass() {
return {
'gl-pt-0': this.showTodoButton,
};
},
},
watch: {
isResolvedCommentsPopoverHidden(newVal) {
@ -121,9 +113,8 @@ export default {
</script>
<template>
<div class="image-notes" :class="sidebarWrapperClass" @click="handleSidebarClick">
<div class="image-notes gl-pt-0" @click="handleSidebarClick">
<div
v-if="showTodoButton"
class="gl-py-4 gl-mb-4 gl-display-flex gl-justify-content-space-between gl-align-items-center gl-border-b-1 gl-border-b-solid gl-border-b-gray-100"
>
<span>{{ __('To Do') }}</span>

View File

@ -86,6 +86,7 @@ export default {
TerraformPlan,
GroupedAccessibilityReportsApp,
MrWidgetApprovals,
SecurityReportsApp: () => import('~/vue_shared/security_reports/security_reports_app.vue'),
},
apollo: {
state: {
@ -179,6 +180,9 @@ export default {
this.mr.mergePipelinesEnabled && this.mr.sourceProjectId !== this.mr.targetProjectId,
);
},
shouldRenderSecurityReport() {
return Boolean(window.gon?.features?.coreSecurityMrWidget && this.mr.pipeline.id);
},
mergeError() {
let { mergeError } = this.mr;
@ -456,6 +460,13 @@ export default {
:codequality-help-path="mr.codequalityHelpPath"
/>
<security-reports-app
v-if="shouldRenderSecurityReport"
:pipeline-id="mr.pipeline.id"
:project-id="mr.targetProjectId"
:security-reports-docs-path="mr.securityReportsDocsPath"
/>
<grouped-test-reports-app
v-if="mr.testResultsPath"
class="js-reports-container"

View File

@ -232,6 +232,7 @@ export default class MergeRequestStore {
this.userCalloutsPath = data.user_callouts_path;
this.suggestPipelineFeatureId = data.suggest_pipeline_feature_id;
this.isDismissedSuggestPipeline = data.is_dismissed_suggest_pipeline;
this.securityReportsDocsPath = data.security_reports_docs_path;
// codeclimate
const blobPath = data.blob_path || {};

View File

@ -1,11 +1,12 @@
<script>
import ActionButtonGroup from './action_button_group.vue';
import RemoveMemberButton from './remove_member_button.vue';
import ApproveAccessRequestButton from './approve_access_request_button.vue';
import { s__, sprintf } from '~/locale';
export default {
name: 'AccessRequestActionButtons',
components: { ActionButtonGroup, RemoveMemberButton },
components: { ActionButtonGroup, RemoveMemberButton, ApproveAccessRequestButton },
props: {
member: {
type: Object,
@ -42,7 +43,9 @@ export default {
<template>
<action-button-group>
<!-- Approve button will go here -->
<div v-if="permissions.canUpdate" class="gl-px-1">
<approve-access-request-button :member-id="member.id" />
</div>
<div v-if="permissions.canRemove" class="gl-px-1">
<remove-member-button
:member-id="member.id"

View File

@ -0,0 +1,42 @@
<script>
import { mapState } from 'vuex';
import { GlButton, GlForm, GlTooltipDirective } from '@gitlab/ui';
import csrf from '~/lib/utils/csrf';
import { __ } from '~/locale';
export default {
name: 'ApproveAccessRequestButton',
csrf,
title: __('Grant access'),
components: { GlButton, GlForm },
directives: {
GlTooltip: GlTooltipDirective,
},
props: {
memberId: {
type: Number,
required: true,
},
},
computed: {
...mapState(['memberPath']),
approvePath() {
return this.memberPath.replace(/:id$/, `${this.memberId}/approve_access_request`);
},
},
};
</script>
<template>
<gl-form :action="approvePath" method="post">
<input :value="$options.csrf.token" type="hidden" name="authenticity_token" />
<gl-button
v-gl-tooltip.hover
:title="$options.title"
:aria-label="$options.title"
icon="check"
variant="success"
type="submit"
/>
</gl-form>
</template>

View File

@ -0,0 +1,107 @@
<script>
import { GlIcon, GlLink, GlSprintf } from '@gitlab/ui';
import ReportSection from '~/reports/components/report_section.vue';
import { status } from '~/reports/constants';
import { s__ } from '~/locale';
import Flash from '~/flash';
import Api from '~/api';
export default {
components: {
GlIcon,
GlLink,
GlSprintf,
ReportSection,
},
props: {
pipelineId: {
type: Number,
required: true,
},
projectId: {
type: Number,
required: true,
},
securityReportsDocsPath: {
type: String,
required: true,
},
},
data() {
return {
hasSecurityReports: false,
// Error state is shown even when successfully loaded, since success
// state suggests that the security scans detected no security problems,
// which is not necessarily the case. A future iteration will actually
// check whether problems were found and display the appropriate status.
status: status.ERROR,
};
},
created() {
this.checkHasSecurityReports(this.$options.reportTypes)
.then(hasSecurityReports => {
this.hasSecurityReports = hasSecurityReports;
})
.catch(error => {
Flash({
message: this.$options.i18n.apiError,
captureError: true,
error,
});
});
},
methods: {
checkHasSecurityReports(reportTypes) {
return Api.pipelineJobs(this.projectId, this.pipelineId).then(({ data: jobs }) =>
jobs.some(({ artifacts = [] }) =>
artifacts.some(({ file_type }) => reportTypes.includes(file_type)),
),
);
},
activatePipelinesTab() {
if (window.mrTabs) {
window.mrTabs.tabShown('pipelines');
}
},
},
reportTypes: ['sast', 'secret_detection'],
i18n: {
apiError: s__(
'SecurityReports|Failed to get security report information. Please reload the page or try again later.',
),
scansHaveRun: s__(
'SecurityReports|Security scans have run. Go to the %{linkStart}pipelines tab%{linkEnd} to download the security reports',
),
securityReportsHelp: s__('SecurityReports|Security reports help page link'),
},
};
</script>
<template>
<report-section
v-if="hasSecurityReports"
:status="status"
:has-issues="false"
class="mr-widget-border-top mr-report"
data-testid="security-mr-widget"
>
<template #error>
<gl-sprintf :message="$options.i18n.scansHaveRun">
<template #link="{ content }">
<gl-link data-testid="show-pipelines" @click="activatePipelinesTab">{{
content
}}</gl-link>
</template>
</gl-sprintf>
<gl-link
target="_blank"
data-testid="help"
:href="securityReportsDocsPath"
:aria-label="$options.i18n.securityReportsHelp"
>
<gl-icon name="question" />
</gl-link>
</template>
</report-section>
</template>

View File

@ -8,7 +8,6 @@
@import './pages/commits';
@import './pages/deploy_keys';
@import './pages/detail_page';
@import './pages/dev_ops_report';
@import './pages/diff';
@import './pages/editor';
@import './pages/environment_logs';

View File

@ -1,3 +1,5 @@
@import 'mixins_and_variables_and_functions';
$space-between-cards: 8px;
.devops-empty svg {
@ -21,7 +23,7 @@ $space-between-cards: 8px;
.devops-header-subtitle {
font-size: 22px;
line-height: 1;
color: $gl-text-color-secondary;
color: var(--gl-text-color-secondary, $gl-text-color-secondary);
margin-left: 8px;
font-weight: $gl-font-weight-normal;
@ -31,10 +33,10 @@ $space-between-cards: 8px;
a {
font-size: 18px;
color: $gl-text-color-secondary;
color: var(--gl-text-color-secondary, $gl-text-color-secondary);
&:hover {
color: $blue-500;
color: var(--blue-500, $blue-500);
}
}
}
@ -52,7 +54,7 @@ $space-between-cards: 8px;
align-items: stretch;
text-align: center;
width: 50%;
border-color: $border-color;
border-color: var(--border-color, $border-color);
margin: 0 0 32px;
padding: $space-between-cards / 2;
position: relative;
@ -75,7 +77,7 @@ $space-between-cards: 8px;
}
.devops-card {
border: solid 1px $border-color;
border: solid 1px var(--border-color, $border-color);
border-radius: 3px;
border-top-width: 3px;
display: flex;
@ -84,26 +86,26 @@ $space-between-cards: 8px;
}
.devops-card-low {
border-top-color: $red-400;
border-top-color: var(--red-400, $red-400);
.board-card-score-big {
background-color: $red-50;
background-color: var(--red-50, $red-50);
}
}
.devops-card-average {
border-top-color: $orange-200;
border-top-color: var(--orange-200, $orange-200);
.board-card-score-big {
background-color: $orange-50;
background-color: var(--orange-50, $orange-50);
}
}
.devops-card-high {
border-top-color: $green-400;
border-top-color: var(--green-400, $green-400);
.board-card-score-big {
background-color: $green-50;
background-color: var(--green-50, $green-50);
}
}
@ -119,7 +121,7 @@ $space-between-cards: 8px;
.light-text {
font-size: 13px;
line-height: 1.25;
color: $gl-text-color-secondary;
color: var(--gl-text-color-secondary, $gl-text-color-secondary);
}
}
@ -132,7 +134,7 @@ $space-between-cards: 8px;
}
.board-card-score {
color: $gl-text-color-secondary;
color: var(--gl-text-color-secondary, $gl-text-color-secondary);
.board-card-score-name {
font-size: 13px;
@ -142,13 +144,13 @@ $space-between-cards: 8px;
.board-card-score-value {
font-size: 16px;
color: $gl-text-color;
color: var(--gl-text-color, $gl-text-color);
font-weight: $gl-font-weight-normal;
}
.board-card-score-big {
border-top: 2px solid $border-color;
border-bottom: 1px solid $border-color;
border-top: 2px solid var(--border-color, $border-color);
border-bottom: 1px solid var(--border-color, $border-color);
font-size: 22px;
padding: 10px 0;
font-weight: $gl-font-weight-normal;
@ -159,17 +161,17 @@ $space-between-cards: 8px;
> * {
font-size: 16px;
color: $gl-text-color-secondary;
color: var(--gl-text-color-secondary, $gl-text-color-secondary);
padding: 10px;
flex-grow: 1;
&:hover {
background-color: $border-color;
color: $gl-text-color;
background-color: var(--border-color, $border-color);
color: var(--border-color, $border-color);
}
+ * {
border-left: solid 1px $border-color;
border-left: solid 1px var(--border-color, $border-color);
}
}
}
@ -180,7 +182,7 @@ $space-between-cards: 8px;
min-width: 100%;
justify-content: space-around;
position: relative;
background: $border-color;
background: var(--border-color, $border-color);
}
.devops-step {
@ -202,12 +204,12 @@ $space-between-cards: 8px;
display: flex;
flex-direction: column;
align-items: center;
border: solid 1px $border-color;
background: $white;
border: solid 1px var(--border-color, $border-color);
background: var(--white, $white);
transform: translate(-50%, -50%);
color: $gl-text-color-secondary;
fill: $gl-text-color-secondary;
box-shadow: 0 2px 4px $dropdown-shadow-color;
color: var(--gl-text-color-secondary, $gl-text-color-secondary);
fill: var(--gl-text-color-secondary, $gl-text-color-secondary);
box-shadow: 0 2px 4px var(--dropdown-shadow-color, $dropdown-shadow-color);
&:hover {
padding: 8px 10px;
@ -247,13 +249,13 @@ $space-between-cards: 8px;
}
.devops-high-score {
color: $green-400;
color: var(--green-400, $green-400);
}
.devops-average-score {
color: $orange-500;
color: var(--orange-500, $orange-500);
}
.devops-low-score {
color: $red-400;
color: var(--red-400, $red-400);
}

View File

@ -426,3 +426,17 @@
flex: 1;
}
}
.codequality-report {
.media {
padding: $gl-padding;
}
.media-body {
flex-direction: row;
}
.report-block-container {
height: auto !important;
}
}

View File

@ -506,20 +506,6 @@ button.mini-pipeline-graph-dropdown-toggle {
}
}
.codequality-report {
.media {
padding: $gl-padding;
}
.media-body {
flex-direction: row;
}
.report-block-container {
height: auto !important;
}
}
.progress-bar.bg-primary {
background-color: $blue-500 !important;
}

View File

@ -44,7 +44,6 @@ class Projects::IssuesController < Projects::ApplicationController
push_frontend_feature_flag(:vue_issuable_sidebar, project.group)
push_frontend_feature_flag(:tribute_autocomplete, @project)
push_frontend_feature_flag(:vue_issuables_list, project)
push_frontend_feature_flag(:design_management_todo_button, project, default_enabled: true)
push_frontend_feature_flag(:vue_sidebar_labels, @project)
end

View File

@ -39,6 +39,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
push_frontend_feature_flag(:unified_diff_lines, @project, default_enabled: true)
push_frontend_feature_flag(:highlight_current_diff_row, @project)
push_frontend_feature_flag(:default_merge_ref_for_diffs, @project)
push_frontend_feature_flag(:core_security_mr_widget, @project)
end
before_action do

View File

@ -124,6 +124,10 @@ class MergeRequestWidgetEntity < Grape::Entity
end
end
expose :security_reports_docs_path do |merge_request|
help_page_path('user/application_security/sast/index.md', anchor: 'reports-json-format')
end
private
delegate :current_user, to: :request

View File

@ -1,5 +1,6 @@
- page_title _('DevOps Report')
- usage_ping_enabled = Gitlab::CurrentSettings.usage_ping_enabled
- add_page_specific_style 'page_bundles/dev_ops_report'
.container
- if usage_ping_enabled && show_callout?('dev_ops_report_intro_callout_dismissed')

View File

@ -20,7 +20,7 @@
= link_to _('Manage two-factor authentication'), profile_two_factor_auth_path, class: 'btn btn-info'
- else
.gl-mb-3
= link_to _('Enable two-factor authentication'), profile_two_factor_auth_path, class: 'btn btn-success'
= link_to _('Enable two-factor authentication'), profile_two_factor_auth_path, class: 'btn btn-success', data: { qa_selector: 'enable_2fa_button' }
%hr
- if display_providers_on_profile?

View File

@ -175,6 +175,7 @@ module Gitlab
config.assets.precompile << "page_bundles/_mixins_and_variables_and_functions.css"
config.assets.precompile << "page_bundles/boards.css"
config.assets.precompile << "page_bundles/cycle_analytics.css"
config.assets.precompile << "page_bundles/dev_ops_report.css"
config.assets.precompile << "page_bundles/environments.css"
config.assets.precompile << "page_bundles/error_tracking_details.css"
config.assets.precompile << "page_bundles/ide.css"

View File

@ -1,7 +1,7 @@
---
name: design_management_todo_button
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/39935
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/245074
group: group::knowledge
name: core_security_mr_widget
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/44639
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/249543
type: development
default_enabled: true
group: group::static analysis
default_enabled: false

View File

@ -226,37 +226,12 @@ available in the **Resolved Comment** area at the bottom of the right sidebar.
## Add to dos for designs
> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/198439) in GitLab 13.4.
> - It's [deployed behind a feature flag](../../feature_flags.md), enabled by default.
> - It's enabled on GitLab.com.
> - It's recommended for production use.
> - For GitLab self-managed instances, GitLab administrators can opt to [disable it](#enable-or-disable-the-design-to-do-button). **(CORE ONLY)**
CAUTION: **Warning:**
This feature might not be available to you. Check the **version history** note above for details.
> - [Feature flag removed](https://gitlab.com/gitlab-org/gitlab/-/issues/245074) in **GitLab 13.5**
Add a to do for a design by clicking **Add a To-Do** on the design sidebar:
![To-Do button](img/design_todo_button_v13_4.png)
### Enable or disable the design to-do button **(CORE ONLY)**
The **Add a To-Do** button for Designs is under development but ready for production use. It is
deployed behind a feature flag that is **enabled by default**.
[GitLab administrators with access to the GitLab Rails console](../../../administration/feature_flags.md)
can enable it.
To enable it:
```ruby
Feature.enable(:design_management_todo_button)
```
To disable it:
```ruby
Feature.disable(:design_management_todo_button)
```
## Referring to designs in Markdown
> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/217160) in **GitLab 13.1**.

View File

@ -23006,6 +23006,9 @@ msgstr ""
msgid "SecurityReports|Error fetching the vulnerability list. Please check your network connection and try again."
msgstr ""
msgid "SecurityReports|Failed to get security report information. Please reload the page or try again later."
msgstr ""
msgid "SecurityReports|False positive"
msgstr ""
@ -23069,6 +23072,12 @@ msgstr ""
msgid "SecurityReports|Security reports can only be accessed by authorized users."
msgstr ""
msgid "SecurityReports|Security reports help page link"
msgstr ""
msgid "SecurityReports|Security scans have run. Go to the %{linkStart}pipelines tab%{linkEnd} to download the security reports"
msgstr ""
msgid "SecurityReports|Select a project to add by using the project search field above."
msgstr ""

View File

@ -593,10 +593,12 @@ module QA
autoload :Api, 'qa/support/api'
autoload :Dates, 'qa/support/dates'
autoload :Repeater, 'qa/support/repeater'
autoload :Run, 'qa/support/run'
autoload :Retrier, 'qa/support/retrier'
autoload :Waiter, 'qa/support/waiter'
autoload :WaitForRequests, 'qa/support/wait_for_requests'
autoload :OTP, 'qa/support/otp'
autoload :SSH, 'qa/support/ssh'
end
end

View File

@ -2,10 +2,8 @@
require 'cgi'
require 'uri'
require 'open3'
require 'fileutils'
require 'tmpdir'
require 'tempfile'
require 'securerandom'
module QA
@ -13,8 +11,7 @@ module QA
class Repository
include Scenario::Actable
include Support::Repeater
RepositoryCommandError = Class.new(StandardError)
include Support::Run
attr_writer :use_lfs, :gpg_key_id
attr_accessor :env_vars
@ -64,7 +61,7 @@ module QA
end
def clone(opts = '')
clone_result = run("git clone #{opts} #{uri} ./", max_attempts: 3)
clone_result = run_git("git clone #{opts} #{uri} ./", max_attempts: 3)
return clone_result.response unless clone_result.success?
enable_lfs_result = enable_lfs if use_lfs?
@ -74,7 +71,7 @@ module QA
def checkout(branch_name, new_branch: false)
opts = new_branch ? '-b' : ''
run(%Q{git checkout #{opts} "#{branch_name}"}).to_s
run_git(%Q{git checkout #{opts} "#{branch_name}"}).to_s
end
def shallow_clone
@ -82,8 +79,8 @@ module QA
end
def configure_identity(name, email)
run(%Q{git config user.name "#{name}"})
run(%Q{git config user.email #{email}})
run_git(%Q{git config user.name "#{name}"})
run_git(%Q{git config user.email #{email}})
end
def commit_file(name, contents, message)
@ -97,33 +94,33 @@ module QA
::File.write(name, contents)
if use_lfs?
git_lfs_track_result = run(%Q{git lfs track #{name} --lockable})
git_lfs_track_result = run_git(%Q{git lfs track #{name} --lockable})
return git_lfs_track_result.response unless git_lfs_track_result.success?
end
git_add_result = run(%Q{git add #{name}})
git_add_result = run_git(%Q{git add #{name}})
git_lfs_track_result.to_s + git_add_result.to_s
end
def add_tag(tag_name)
run("git tag #{tag_name}").to_s
run_git("git tag #{tag_name}").to_s
end
def delete_tag(tag_name)
run(%Q{git push origin --delete #{tag_name}}, max_attempts: 3).to_s
run_git(%Q{git push origin --delete #{tag_name}}, max_attempts: 3).to_s
end
def commit(message)
run(%Q{git commit -m "#{message}"}, max_attempts: 3).to_s
run_git(%Q{git commit -m "#{message}"}, max_attempts: 3).to_s
end
def commit_with_gpg(message)
run(%Q{git config user.signingkey #{@gpg_key_id} && git config gpg.program $(command -v gpg) && git commit -S -m "#{message}"}).to_s
run_git(%Q{git config user.signingkey #{@gpg_key_id} && git config gpg.program $(command -v gpg) && git commit -S -m "#{message}"}).to_s
end
def current_branch
run("git rev-parse --abbrev-ref HEAD").to_s
run_git("git rev-parse --abbrev-ref HEAD").to_s
end
def push_changes(branch = 'master', push_options: nil)
@ -131,53 +128,48 @@ module QA
cmd << push_options_hash_to_string(push_options)
cmd << uri
cmd << branch
run(cmd.compact.join(' '), max_attempts: 3).to_s
run_git(cmd.compact.join(' '), max_attempts: 3).to_s
end
def push_all_branches
run("git push --all").to_s
run_git("git push --all").to_s
end
def push_tags_and_branches(branches)
run("git push --tags origin #{branches.join(' ')}").to_s
run_git("git push --tags origin #{branches.join(' ')}").to_s
end
def merge(branch)
run("git merge #{branch}")
run_git("git merge #{branch}")
end
def init_repository
run("git init")
run_git("git init")
end
def pull(repository = nil, branch = nil)
run(['git', 'pull', repository, branch].compact.join(' '))
run_git(['git', 'pull', repository, branch].compact.join(' '))
end
def commits
run('git log --oneline').to_s.split("\n")
run_git('git log --oneline').to_s.split("\n")
end
def use_ssh_key(key)
@private_key_file = Tempfile.new("id_#{SecureRandom.hex(8)}")
File.binwrite(private_key_file, key.private_key)
File.chmod(0700, private_key_file)
@ssh = Support::SSH.perform do |ssh|
ssh.key = key
ssh.uri = uri
ssh.setup(env: self.env_vars)
ssh
end
@known_hosts_file = Tempfile.new("known_hosts_#{SecureRandom.hex(8)}")
keyscan_params = ['-H']
keyscan_params << "-p #{uri.port}" if uri.port
keyscan_params << uri.host
res = run("ssh-keyscan #{keyscan_params.join(' ')} >> #{known_hosts_file.path}")
return res.response unless res.success?
self.env_vars << %Q{GIT_SSH_COMMAND="ssh -i #{private_key_file.path} -o UserKnownHostsFile=#{known_hosts_file.path}"}
self.env_vars << %Q{GIT_SSH_COMMAND="ssh -i #{ssh.private_key_file.path} -o UserKnownHostsFile=#{ssh.known_hosts_file.path}"}
end
def delete_ssh_key
return unless ssh_key_set?
private_key_file.close(true)
known_hosts_file.close(true)
ssh.delete
end
def push_with_git_protocol(version, file_name, file_content, commit_message = 'Initial commit')
@ -192,13 +184,13 @@ module QA
def git_protocol=(value)
raise ArgumentError, "Please specify the protocol you would like to use: 0, 1, or 2" unless %w[0 1 2].include?(value.to_s)
run("git config protocol.version #{value}")
run_git("git config protocol.version #{value}")
end
def fetch_supported_git_protocol
# ls-remote is one command known to respond to Git protocol v2 so we use
# it to get output including the version reported via Git tracing
result = run("git ls-remote #{uri}", env: "GIT_TRACE_PACKET=1", max_attempts: 3)
result = run_git("git ls-remote #{uri}", max_attempts: 3, env: [*self.env_vars, "GIT_TRACE_PACKET=1"])
result.response[/git< version (\d+)/, 1] || 'unknown'
end
@ -219,19 +211,10 @@ module QA
private
attr_reader :uri, :username, :password, :known_hosts_file,
:private_key_file, :use_lfs
attr_reader :uri, :username, :password, :ssh, :use_lfs
alias_method :use_lfs?, :use_lfs
Result = Struct.new(:command, :exitstatus, :response) do
alias_method :to_s, :response
def success?
exitstatus == 0 && !response.include?('Error encountered')
end
end
def add_credentials?
return false if !username || !password
return true unless ssh_key_set?
@ -240,7 +223,7 @@ module QA
end
def ssh_key_set?
!private_key_file.nil?
ssh && !ssh.private_key_file.nil?
end
def enable_lfs
@ -249,33 +232,11 @@ module QA
touch_gitconfig_result = run("touch #{tmp_home_dir}/.gitconfig")
return touch_gitconfig_result.response unless touch_gitconfig_result.success?
git_lfs_install_result = run('git lfs install')
git_lfs_install_result = run_git('git lfs install')
touch_gitconfig_result.to_s + git_lfs_install_result.to_s
end
def run(command_str, env: [], max_attempts: 1)
command = [env_vars, *env, command_str, '2>&1'].compact.join(' ')
result = nil
repeat_until(max_attempts: max_attempts, raise_on_failure: false) do
Runtime::Logger.debug "Git: pwd=[#{Dir.pwd}], command=[#{command}]"
output, status = Open3.capture2e(command)
output.chomp!
Runtime::Logger.debug "Git: output=[#{output}], exitstatus=[#{status.exitstatus}]"
result = Result.new(command, status.exitstatus, output)
result.success?
end
unless result.success?
raise RepositoryCommandError, "The command #{result.command} failed (#{result.exitstatus}) with the following output:\n#{result.response}"
end
result
end
def default_credentials
if ::QA::Runtime::User.ldap_user?
[Runtime::User.ldap_username, Runtime::User.ldap_password]
@ -333,6 +294,10 @@ module QA
def netrc_already_contains_content?
read_netrc_content.grep(/^#{Regexp.escape(netrc_content)}$/).any?
end
def run_git(command_str, env: self.env_vars, max_attempts: 1)
run(command_str, env: env, max_attempts: max_attempts, log_prefix: 'Git: ')
end
end
end
end

View File

@ -7,6 +7,7 @@ module QA
class Show < Page::Base
view 'app/views/profiles/accounts/show.html.haml' do
element :delete_account_button, required: true
element :enable_2fa_button
end
view 'app/assets/javascripts/profile/account/components/delete_account_modal.vue' do
@ -14,6 +15,10 @@ module QA
element :confirm_delete_account_button
end
def click_enable_2fa_button
click_element(:enable_2fa_button)
end
def delete_account(password)
click_element(:delete_account_button)

View File

@ -0,0 +1,65 @@
# frozen_string_literal: true
module QA
context 'Manage', :requires_admin, :skip_live_env do
describe '2FA' do
let!(:user) { Resource::User.fabricate_via_api! }
let!(:user_api_client) { Runtime::API::Client.new(:gitlab, user: user) }
let(:address) { QA::Runtime::Scenario.gitlab_address }
let(:uri) { URI.parse(address) }
let(:ssh_port) { uri.port == 80 ? '' : '2222' }
let!(:ssh_key) do
Resource::SSHKey.fabricate_via_api! do |resource|
resource.title = "key for ssh tests #{Time.now.to_f}"
resource.api_client = user_api_client
end
end
before do
enable_2fa_for_user(user)
end
it 'allows 2FA code recovery via ssh' do
recovery_code = Support::SSH.perform do |ssh|
ssh.key = ssh_key
ssh.uri = address.gsub(uri.port.to_s, ssh_port)
ssh.setup
output = ssh.reset_2fa_codes
output.scan(/([A-Za-z0-9]{16})\n/).flatten.first
end
Flow::Login.sign_in(as: user, skip_page_validation: true)
Page::Main::TwoFactorAuth.perform do |two_fa_auth|
two_fa_auth.set_2fa_code(recovery_code)
two_fa_auth.click_verify_code_button
end
expect(Page::Main::Menu.perform(&:signed_in?)).to be_truthy
Page::Main::Menu.perform(&:sign_out)
Flow::Login.sign_in(as: user, skip_page_validation: true)
Page::Main::TwoFactorAuth.perform do |two_fa_auth|
two_fa_auth.set_2fa_code(recovery_code)
two_fa_auth.click_verify_code_button
end
expect(page).to have_text('Invalid two-factor code')
end
def enable_2fa_for_user(user)
Flow::Login.while_signed_in(as: user) do
Page::Main::Menu.perform(&:click_settings_link)
Page::Profile::Menu.perform(&:click_account)
Page::Profile::Accounts::Show.perform(&:click_enable_2fa_button)
Page::Profile::TwoFactorAuth.perform do |two_fa_auth|
otp = QA::Support::OTP.new(two_fa_auth.otp_secret_content)
two_fa_auth.set_pin_code(otp.fresh_otp)
two_fa_auth.click_register_2fa_app_button
two_fa_auth.click_proceed_button
end
end
end
end
end
end

View File

@ -41,7 +41,7 @@ module QA
retry_on_fail do
expect { push_new_file('oversize_file_2.bin', wait_for_push: false) }
.to raise_error(QA::Git::Repository::RepositoryCommandError, /remote: fatal: pack exceeds maximum allowed size/)
.to raise_error(QA::Support::Run::CommandError, /remote: fatal: pack exceeds maximum allowed size/)
end
end

View File

@ -35,7 +35,7 @@ module QA
roles: Resource::ProtectedBranch::Roles::NO_ONE
})
expect { push_new_file(branch_name) }.to raise_error(QA::Git::Repository::RepositoryCommandError, /remote: GitLab: You are not allowed to push code to protected branches on this project\.([\s\S]+)\[remote rejected\] #{branch_name} -> #{branch_name} \(pre-receive hook declined\)/)
expect { push_new_file(branch_name) }.to raise_error(QA::Support::Run::CommandError, /remote: GitLab: You are not allowed to push code to protected branches on this project\.([\s\S]+)\[remote rejected\] #{branch_name} -> #{branch_name} \(pre-receive hook declined\)/)
end
end

View File

@ -91,7 +91,7 @@ module QA
repository.init_repository
expect { repository.pull(repository_uri_ssh, branch_name) }
.to raise_error(QA::Git::Repository::RepositoryCommandError, /fatal: Could not read from remote repository\./)
.to raise_error(QA::Support::Run::CommandError, /fatal: Could not read from remote repository\./)
end
end

View File

@ -90,7 +90,7 @@ module QA
repository.init_repository
expect { repository.pull(repository_uri_ssh, branch_name) }
.to raise_error(QA::Git::Repository::RepositoryCommandError, /fatal: Could not read from remote repository\./)
.to raise_error(QA::Support::Run::CommandError, /fatal: Could not read from remote repository\./)
end
end

View File

@ -13,11 +13,14 @@ module QA
# Fetches a fresh OTP and returns it only after rotp provides the same OTP twice
# An OTP is valid for 30 seconds so 70 attempts with 0.5 interval would ensure we complete 1 cycle
Support::Retrier.retry_until(max_attempts: 70, sleep_interval: 0.5) do
QA::Runtime::Logger.debug("Fetching a fresh OTP...")
Support::Retrier.retry_until(max_attempts: 70, sleep_interval: 0.5, log: false) do
otps << @rotp.now
otps.size >= 3 && otps[-1] == otps[-2] && otps[-1] != otps[-3]
end
QA::Runtime::Logger.debug("Fetched OTP: #{otps.last}")
otps.last
end
end

View File

@ -34,15 +34,17 @@ module QA
result
end
def retry_until(max_attempts: nil, max_duration: nil, reload_page: nil, sleep_interval: 0, raise_on_failure: true, retry_on_exception: false)
def retry_until(max_attempts: nil, max_duration: nil, reload_page: nil, sleep_interval: 0, raise_on_failure: true, retry_on_exception: false, log: true)
# For backwards-compatibility
max_attempts = 3 if max_attempts.nil? && max_duration.nil?
start_msg ||= ["with retry_until:"]
start_msg << "max_attempts: #{max_attempts};" if max_attempts
start_msg << "max_duration: #{max_duration};" if max_duration
start_msg << "reload_page: #{reload_page}; sleep_interval: #{sleep_interval}; raise_on_failure: #{raise_on_failure}; retry_on_exception: #{retry_on_exception}"
QA::Runtime::Logger.debug(start_msg.join(' '))
if log
start_msg ||= ["with retry_until:"]
start_msg << "max_attempts: #{max_attempts};" if max_attempts
start_msg << "max_duration: #{max_duration};" if max_duration
start_msg << "reload_page: #{reload_page}; sleep_interval: #{sleep_interval}; raise_on_failure: #{raise_on_failure}; retry_on_exception: #{retry_on_exception}"
QA::Runtime::Logger.debug(start_msg.join(' '))
end
result = nil
repeat_until(
@ -51,7 +53,8 @@ module QA
reload_page: reload_page,
sleep_interval: sleep_interval,
raise_on_failure: raise_on_failure,
retry_on_exception: retry_on_exception
retry_on_exception: retry_on_exception,
log: log
) do
result = yield
end

43
qa/qa/support/run.rb Normal file
View File

@ -0,0 +1,43 @@
# frozen_string_literal: true
require 'open3'
module QA
module Support
module Run
include QA::Support::Repeater
CommandError = Class.new(StandardError)
Result = Struct.new(:command, :exitstatus, :response) do
alias_method :to_s, :response
def success?
exitstatus == 0 && !response.include?('Error encountered')
end
end
def run(command_str, env: [], max_attempts: 1, log_prefix: '')
command = [*env, command_str, '2>&1'].compact.join(' ')
result = nil
repeat_until(max_attempts: max_attempts, raise_on_failure: false) do
Runtime::Logger.debug "#{log_prefix}pwd=[#{Dir.pwd}], command=[#{command}]"
output, status = Open3.capture2e(command)
output.chomp!
Runtime::Logger.debug "#{log_prefix}output=[#{output}], exitstatus=[#{status.exitstatus}]"
result = Result.new(command, status.exitstatus, output)
result.success?
end
unless result.success?
raise CommandError, "The command #{result.command} failed (#{result.exitstatus}) with the following output:\n#{result.response}"
end
result
end
end
end
end

62
qa/qa/support/ssh.rb Normal file
View File

@ -0,0 +1,62 @@
# frozen_string_literal: true
require 'tempfile'
require 'etc'
module QA
module Support
class SSH
include Scenario::Actable
include Support::Run
attr_accessor :known_hosts_file, :private_key_file, :key
attr_reader :uri
def initialize
@private_key_file = Tempfile.new("id_#{SecureRandom.hex(8)}")
@known_hosts_file = Tempfile.new("known_hosts_#{SecureRandom.hex(8)}")
end
def uri=(address)
@uri = URI(address)
end
def setup(env: nil)
File.binwrite(private_key_file, key.private_key)
File.chmod(0700, private_key_file)
keyscan_params = ['-H']
keyscan_params << "-p #{uri_port}" if uri_port
keyscan_params << uri.host
res = run("ssh-keyscan #{keyscan_params.join(' ')} >> #{known_hosts_file.path}", env: env, log_prefix: 'SSH: ')
return res.response unless res.success?
true
end
def delete
private_key_file.close(true)
known_hosts_file.close(true)
end
def reset_2fa_codes
ssh_params = [uri.host]
ssh_params << "-p #{uri_port}" if uri_port
ssh_params << "2fa_recovery_codes"
run("echo yes | ssh -i #{private_key_file.path} -o UserKnownHostsFile=#{known_hosts_file.path} #{git_user}@#{ssh_params.join(' ')}", log_prefix: 'SSH: ').to_s
end
private
def uri_port
uri.port && (uri.port != 80) ? uri.port : nil
end
def git_user
QA::Runtime::Env.running_in_ci? || [443, 80].include?(uri.port) ? 'git' : Etc.getlogin
end
end
end
end

View File

@ -6,7 +6,16 @@ RSpec.describe QA::Git::Repository do
shared_context 'unresolvable git directory' do
let(:repo_uri) { 'http://foo/bar.git' }
let(:repo_uri_with_credentials) { 'http://root@foo/bar.git' }
let(:repository) { described_class.new.tap { |r| r.uri = repo_uri } }
let(:env_vars) { [%q{HOME="temp"}] }
let(:extra_env_vars) { [] }
let(:run_params) { { env: env_vars + extra_env_vars, log_prefix: "Git: " } }
let(:repository) do
described_class.new.tap do |r|
r.uri = repo_uri
r.env_vars = env_vars
end
end
let(:tmp_git_dir) { Dir.mktmpdir }
let(:tmp_netrc_dir) { Dir.mktmpdir }
@ -28,14 +37,13 @@ RSpec.describe QA::Git::Repository do
end
shared_examples 'command with retries' do
let(:extra_args) { {} }
let(:result_output) { +'Command successful' }
let(:result) { described_class::Result.new(any_args, 0, result_output) }
let(:command_return) { result_output }
context 'when command is successful' do
it 'returns the #run command Result output' do
expect(repository).to receive(:run).with(command, extra_args.merge(max_attempts: 3)).and_return(result)
expect(repository).to receive(:run).with(command, run_params.merge(max_attempts: 3)).and_return(result)
expect(call_method).to eq(command_return)
end
@ -52,10 +60,10 @@ RSpec.describe QA::Git::Repository do
end
context 'and retried command is not successful after 3 attempts' do
it 'raises a RepositoryCommandError exception' do
it 'raises a CommandError exception' do
expect(Open3).to receive(:capture2e).and_return([+'FAILURE', double(exitstatus: 42)]).exactly(3).times
expect { call_method }.to raise_error(described_class::RepositoryCommandError, /The command .* failed \(42\) with the following output:\nFAILURE/)
expect { call_method }.to raise_error(QA::Support::Run::CommandError, /The command .* failed \(42\) with the following output:\nFAILURE/)
end
end
end
@ -182,7 +190,7 @@ RSpec.describe QA::Git::Repository do
describe '#git_protocol=' do
[0, 1, 2].each do |version|
it "configures git to use protocol version #{version}" do
expect(repository).to receive(:run).with("git config protocol.version #{version}")
expect(repository).to receive(:run).with("git config protocol.version #{version}", run_params.merge(max_attempts: 1))
repository.git_protocol = version
end
@ -200,7 +208,7 @@ RSpec.describe QA::Git::Repository do
let(:command) { "git ls-remote #{repo_uri_with_credentials}" }
let(:result_output) { +'packet: git< version 2' }
let(:command_return) { '2' }
let(:extra_args) { { env: "GIT_TRACE_PACKET=1" } }
let(:extra_env_vars) { ["GIT_TRACE_PACKET=1"] }
end
it "reports the detected version" do

View File

@ -0,0 +1,27 @@
# frozen_string_literal: true
RSpec.describe QA::Support::Run do
let(:class_instance) { (Class.new { include QA::Support::Run }).new }
let(:response) { 'successful response' }
let(:command) { 'some command' }
let(:expected_result) { described_class::Result.new("#{command} 2>&1", 0, response) }
it 'runs successfully' do
expect(Open3).to receive(:capture2e).and_return([+response, double(exitstatus: 0)])
expect(class_instance.run(command)).to eq(expected_result)
end
it 'retries twice and succeeds the third time' do
allow(Open3).to receive(:capture2e).and_return([+'', double(exitstatus: 1)]).twice
allow(Open3).to receive(:capture2e).and_return([+response, double(exitstatus: 0)])
expect(class_instance.run(command)).to eq(expected_result)
end
it 'raises an exception on 3rd failure' do
allow(Open3).to receive(:capture2e).and_return([+'FAILURE', double(exitstatus: 1)]).thrice
expect { class_instance.run(command) }.to raise_error(QA::Support::Run::CommandError, /The command .* failed \(1\) with the following output:\nFAILURE/)
end
end

114
qa/spec/support/ssh_spec.rb Normal file
View File

@ -0,0 +1,114 @@
# frozen_string_literal: true
RSpec.describe QA::Support::SSH do
let(:key) { Struct.new(:private_key).new('private_key') }
let(:known_hosts_file) { Tempfile.new('known_hosts_file') }
let(:private_key_file) { Tempfile.new('private_key_file') }
let(:result) { QA::Support::Run::Result.new('', 0, '') }
let(:ssh) do
described_class.new.tap do |ssh|
ssh.uri = uri
ssh.key = key
ssh.private_key_file = private_key_file
ssh.known_hosts_file = known_hosts_file
end
end
shared_examples 'providing correct ports' do
context 'when no port specified in uri' do
let(:uri) { 'http://foo.com' }
it 'does not provide port in ssh command' do
expect(ssh).to receive(:run).with(expected_ssh_command_no_port, any_args).and_return(result)
call_method
end
end
context 'when port 80 specified in uri' do
let(:uri) { 'http://foo.com:80' }
it 'does not provide port in ssh command' do
expect(ssh).to receive(:run).with(expected_ssh_command_no_port, any_args).and_return(result)
call_method
end
end
context 'when other port is specified in uri' do
let(:port) { 1234 }
let(:uri) { "http://foo.com:#{port}" }
it "provides other port in ssh command" do
expect(ssh).to receive(:run).with(expected_ssh_command_port, any_args).and_return(result)
call_method
end
end
end
describe '#setup' do
let(:expected_ssh_command_no_port) { "ssh-keyscan -H foo.com >> #{known_hosts_file.path}" }
let(:expected_ssh_command_port) { "ssh-keyscan -H -p #{port} foo.com >> #{known_hosts_file.path}" }
let(:call_method) { ssh.setup }
before do
allow(File).to receive(:binwrite).with(private_key_file, key.private_key)
allow(File).to receive(:chmod).with(0700, private_key_file)
end
it_behaves_like 'providing correct ports'
end
describe '#reset_2fa_codes' do
let(:expected_ssh_command_no_port) { "echo yes | ssh -i #{private_key_file.path} -o UserKnownHostsFile=#{known_hosts_file.path} git@foo.com 2fa_recovery_codes" }
let(:expected_ssh_command_port) { "echo yes | ssh -i #{private_key_file.path} -o UserKnownHostsFile=#{known_hosts_file.path} git@foo.com -p #{port} 2fa_recovery_codes" }
let(:call_method) { ssh.reset_2fa_codes }
before do
allow(ssh).to receive(:git_user).and_return('git')
end
it_behaves_like 'providing correct ports'
end
describe '#git_user' do
context 'when running on CI' do
let(:uri) { 'http://gitlab.com' }
before do
allow(QA::Runtime::Env).to receive(:running_in_ci?).and_return(true)
end
it 'returns git user' do
expect(ssh.send(:git_user)).to eq('git')
end
end
context 'when running against environment on a port other than 80 or 443' do
let(:uri) { 'http://localhost:3000' }
before do
allow(Etc).to receive(:getlogin).and_return('dummy_username')
allow(QA::Runtime::Env).to receive(:running_in_ci?).and_return(false)
end
it 'returns the local user' do
expect(ssh.send(:git_user)).to eq('dummy_username')
end
end
context 'when running against environment on port 80 and not on CI (docker)' do
let(:uri) { 'http://localhost' }
before do
allow(QA::Runtime::Env).to receive(:running_in_ci?).and_return(false)
end
it 'returns git user' do
expect(ssh.send(:git_user)).to eq('git')
end
end
end
end

View File

@ -672,6 +672,27 @@ describe('Api', () => {
});
});
describe('pipelineJobs', () => {
it('fetches the jobs for a given pipeline', done => {
const projectId = 123;
const pipelineId = 456;
const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${projectId}/pipelines/${pipelineId}/jobs`;
const payload = [
{
name: 'test',
},
];
mock.onGet(expectedUrl).reply(httpStatus.OK, payload);
Api.pipelineJobs(projectId, pipelineId)
.then(({ data }) => {
expect(data).toEqual(payload);
})
.then(done)
.catch(done.fail);
});
});
describe('createBranch', () => {
it('creates new branch', done => {
const ref = 'master';

View File

@ -43,7 +43,7 @@ describe('Design management design sidebar component', () => {
const findNewDiscussionDisclaimer = () =>
wrapper.find('[data-testid="new-discussion-disclaimer"]');
function createComponent(props = {}, { enableTodoButton } = {}) {
function createComponent(props = {}) {
wrapper = shallowMount(DesignSidebar, {
propsData: {
design,
@ -58,9 +58,6 @@ describe('Design management design sidebar component', () => {
},
},
stubs: { GlPopover },
provide: {
glFeatures: { designManagementTodoButton: enableTodoButton },
},
});
}
@ -80,6 +77,12 @@ describe('Design management design sidebar component', () => {
expect(findParticipants().props('participants')).toHaveLength(1);
});
it('renders To-Do button', () => {
createComponent();
expect(wrapper.find(DesignTodoButton).exists()).toBe(true);
});
describe('when has no discussions', () => {
beforeEach(() => {
createComponent({
@ -245,23 +248,4 @@ describe('Design management design sidebar component', () => {
expect(Cookies.set).toHaveBeenCalledWith(cookieKey, 'true', { expires: 365 * 10 });
});
});
it('does not render To-Do button by default', () => {
createComponent();
expect(wrapper.find(DesignTodoButton).exists()).toBe(false);
});
describe('when `design_management_todo_button` feature flag is enabled', () => {
beforeEach(() => {
createComponent({}, { enableTodoButton: true });
});
it('renders sidebar root element with no top padding', () => {
expect(wrapper.classes()).toContain('gl-pt-0');
});
it('renders To-Do button', () => {
expect(wrapper.find(DesignTodoButton).exists()).toBe(true);
});
});
});

View File

@ -30,9 +30,19 @@ exports[`Design management design index page renders design index 1`] = `
</div>
<div
class="image-notes"
class="image-notes gl-pt-0"
>
<!---->
<div
class="gl-py-4 gl-mb-4 gl-display-flex gl-justify-content-space-between gl-align-items-center gl-border-b-1 gl-border-b-solid gl-border-b-gray-100"
>
<span>
To Do
</span>
<design-todo-button-stub
design="[object Object]"
/>
</div>
<h2
class="gl-font-weight-bold gl-mt-0"
@ -180,9 +190,19 @@ exports[`Design management design index page with error GlAlert is rendered in c
</div>
<div
class="image-notes"
class="image-notes gl-pt-0"
>
<!---->
<div
class="gl-py-4 gl-mb-4 gl-display-flex gl-justify-content-space-between gl-align-items-center gl-border-b-1 gl-border-b-solid gl-border-b-gray-100"
>
<span>
To Do
</span>
<design-todo-button-stub
design="[object Object]"
/>
</div>
<h2
class="gl-font-weight-bold gl-mt-0"

View File

@ -262,6 +262,7 @@ export default {
merge_trains_enabled: true,
merge_trains_count: 3,
merge_train_index: 1,
security_reports_docs_path: 'security-reports-docs-path',
};
export const mockStore = {

View File

@ -2,6 +2,7 @@ import Vue from 'vue';
import MockAdapter from 'axios-mock-adapter';
import mountComponent from 'helpers/vue_mount_component_helper';
import { withGonExperiment } from 'helpers/experimentation_helper';
import Api from '~/api';
import axios from '~/lib/utils/axios_utils';
import mrWidgetOptions from '~/vue_merge_request_widget/mr_widget_options.vue';
import eventHub from '~/vue_merge_request_widget/event_hub';
@ -51,13 +52,13 @@ describe('mrWidgetOptions', () => {
gon.features = {};
});
const createComponent = () => {
const createComponent = (mrData = mockData) => {
if (vm) {
vm.$destroy();
}
vm = mountComponent(MrWidgetOptions, {
mrData: { ...mockData },
mrData: { ...mrData },
});
return axios.waitForAll();
@ -65,6 +66,7 @@ describe('mrWidgetOptions', () => {
const findSuggestPipeline = () => vm.$el.querySelector('[data-testid="mr-suggest-pipeline"]');
const findSuggestPipelineButton = () => findSuggestPipeline().querySelector('button');
const findSecurityMrWidget = () => vm.$el.querySelector('[data-testid="security-mr-widget"]');
describe('default', () => {
beforeEach(() => {
@ -813,6 +815,41 @@ describe('mrWidgetOptions', () => {
});
});
describe('security widget', () => {
describe.each`
context | hasPipeline | reportType | isFlagEnabled | shouldRender
${'security report and flag enabled'} | ${true} | ${'sast'} | ${true} | ${true}
${'security report and flag disabled'} | ${true} | ${'sast'} | ${false} | ${false}
${'no security report and flag enabled'} | ${true} | ${'foo'} | ${true} | ${false}
${'no pipeline and flag enabled'} | ${false} | ${'sast'} | ${true} | ${false}
`('given $context', ({ hasPipeline, reportType, isFlagEnabled, shouldRender }) => {
beforeEach(() => {
gon.features.coreSecurityMrWidget = isFlagEnabled;
if (hasPipeline) {
jest.spyOn(Api, 'pipelineJobs').mockResolvedValue({
data: [{ artifacts: [{ file_type: reportType }] }],
});
}
return createComponent({
...mockData,
...(hasPipeline ? {} : { pipeline: undefined }),
});
});
if (shouldRender) {
it('renders', () => {
expect(findSecurityMrWidget()).toEqual(expect.any(HTMLElement));
});
} else {
it('does not render', () => {
expect(findSecurityMrWidget()).toBeNull();
});
}
});
});
describe('suggestPipeline Experiment', () => {
beforeEach(() => {
mock.onAny().reply(200);

View File

@ -118,27 +118,33 @@ describe('MergeRequestStore', () => {
describe('setPaths', () => {
it('should set the add ci config path', () => {
store.setData({ ...mockData });
store.setPaths({ ...mockData });
expect(store.mergeRequestAddCiConfigPath).toBe('/group2/project2/new/pipeline');
});
it('should set humanAccess=Maintainer when user has that role', () => {
store.setData({ ...mockData });
store.setPaths({ ...mockData });
expect(store.humanAccess).toBe('Maintainer');
});
it('should set pipelinesEmptySvgPath', () => {
store.setData({ ...mockData });
store.setPaths({ ...mockData });
expect(store.pipelinesEmptySvgPath).toBe('/path/to/svg');
});
it('should set newPipelinePath', () => {
store.setData({ ...mockData });
store.setPaths({ ...mockData });
expect(store.newPipelinePath).toBe('/group2/project2/pipelines/new');
});
it('should set securityReportsDocsPath', () => {
store.setPaths({ ...mockData });
expect(store.securityReportsDocsPath).toBe('security-reports-docs-path');
});
});
});

View File

@ -1,6 +1,7 @@
import { shallowMount } from '@vue/test-utils';
import AccessRequestActionButtons from '~/vue_shared/components/members/action_buttons/access_request_action_buttons.vue';
import RemoveMemberButton from '~/vue_shared/components/members/action_buttons/remove_member_button.vue';
import ApproveAccessRequestButton from '~/vue_shared/components/members/action_buttons/approve_access_request_button.vue';
import { accessRequest as member } from '../mock_data';
describe('AccessRequestActionButtons', () => {
@ -17,6 +18,7 @@ describe('AccessRequestActionButtons', () => {
};
const findRemoveMemberButton = () => wrapper.find(RemoveMemberButton);
const findApproveButton = () => wrapper.find(ApproveAccessRequestButton);
afterEach(() => {
wrapper.destroy();
@ -79,4 +81,28 @@ describe('AccessRequestActionButtons', () => {
expect(findRemoveMemberButton().exists()).toBe(false);
});
});
describe('when user has `canUpdate` permissions', () => {
it('renders the approve button', () => {
createComponent({
permissions: {
canUpdate: true,
},
});
expect(findApproveButton().exists()).toBe(true);
});
});
describe('when user does not have `canUpdate` permissions', () => {
it('does not render the approve button', () => {
createComponent({
permissions: {
canUpdate: false,
},
});
expect(findApproveButton().exists()).toBe(false);
});
});
});

View File

@ -0,0 +1,74 @@
import { shallowMount, createLocalVue } from '@vue/test-utils';
import Vuex from 'vuex';
import { GlButton, GlForm } from '@gitlab/ui';
import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
import ApproveAccessRequestButton from '~/vue_shared/components/members/action_buttons/approve_access_request_button.vue';
jest.mock('~/lib/utils/csrf', () => ({ token: 'mock-csrf-token' }));
const localVue = createLocalVue();
localVue.use(Vuex);
describe('ApproveAccessRequestButton', () => {
let wrapper;
const createStore = (state = {}) => {
return new Vuex.Store({
state: {
memberPath: '/groups/foo-bar/-/group_members/:id',
...state,
},
});
};
const createComponent = (propsData = {}, state) => {
wrapper = shallowMount(ApproveAccessRequestButton, {
localVue,
store: createStore(state),
propsData: {
memberId: 1,
...propsData,
},
directives: {
GlTooltip: createMockDirective(),
},
});
};
const findForm = () => wrapper.find(GlForm);
const findButton = () => findForm().find(GlButton);
beforeEach(() => {
createComponent();
});
afterEach(() => {
wrapper.destroy();
});
it('displays a tooltip', () => {
const button = findButton();
expect(getBinding(button.element, 'gl-tooltip')).not.toBeUndefined();
expect(button.attributes('title')).toBe('Grant access');
});
it('sets `aria-label` attribute', () => {
expect(findButton().attributes('aria-label')).toBe('Grant access');
});
it('submits the form when button is clicked', () => {
expect(findButton().attributes('type')).toBe('submit');
});
it('displays form with correct action and inputs', () => {
const form = findForm();
expect(form.attributes('action')).toBe(
'/groups/foo-bar/-/group_members/1/approve_access_request',
);
expect(form.find('input[name="authenticity_token"]').attributes('value')).toBe(
'mock-csrf-token',
);
});
});

View File

@ -0,0 +1,118 @@
import { mount } from '@vue/test-utils';
import Api from '~/api';
import Flash from '~/flash';
import SecurityReportsApp from '~/vue_shared/security_reports/security_reports_app.vue';
jest.mock('~/flash');
describe('Grouped security reports app', () => {
let wrapper;
let mrTabsMock;
const props = {
pipelineId: 123,
projectId: 456,
securityReportsDocsPath: '/docs',
};
const createComponent = () => {
wrapper = mount(SecurityReportsApp, {
propsData: { ...props },
});
};
const findPipelinesTabAnchor = () => wrapper.find('[data-testid="show-pipelines"]');
const findHelpLink = () => wrapper.find('[data-testid="help"]');
const setupMrTabsMock = () => {
mrTabsMock = { tabShown: jest.fn() };
window.mrTabs = mrTabsMock;
};
const setupMockJobArtifact = reportType => {
jest
.spyOn(Api, 'pipelineJobs')
.mockResolvedValue({ data: [{ artifacts: [{ file_type: reportType }] }] });
};
afterEach(() => {
wrapper.destroy();
delete window.mrTabs;
});
describe.each(SecurityReportsApp.reportTypes)('given a report type %p', reportType => {
beforeEach(() => {
window.mrTabs = { tabShown: jest.fn() };
setupMockJobArtifact(reportType);
createComponent();
});
it('calls the pipelineJobs API correctly', () => {
expect(Api.pipelineJobs).toHaveBeenCalledWith(props.projectId, props.pipelineId);
});
it('renders the expected message', () => {
expect(wrapper.text()).toMatchInterpolatedText(SecurityReportsApp.i18n.scansHaveRun);
});
describe('clicking the anchor to the pipelines tab', () => {
beforeEach(() => {
setupMrTabsMock();
findPipelinesTabAnchor().trigger('click');
});
it('calls the mrTabs.tabShown global', () => {
expect(mrTabsMock.tabShown.mock.calls).toEqual([['pipelines']]);
});
});
it('renders a help link', () => {
expect(findHelpLink().attributes()).toMatchObject({
href: props.securityReportsDocsPath,
});
});
});
describe('given a report type "foo"', () => {
beforeEach(() => {
setupMockJobArtifact('foo');
createComponent();
});
it('calls the pipelineJobs API correctly', () => {
expect(Api.pipelineJobs).toHaveBeenCalledWith(props.projectId, props.pipelineId);
});
it('renders nothing', () => {
expect(wrapper.html()).toBe('');
});
});
describe('given an error from the API', () => {
let error;
beforeEach(() => {
error = new Error('an error');
jest.spyOn(Api, 'pipelineJobs').mockRejectedValue(error);
createComponent();
});
it('calls the pipelineJobs API correctly', () => {
expect(Api.pipelineJobs).toHaveBeenCalledWith(props.projectId, props.pipelineId);
});
it('renders nothing', () => {
expect(wrapper.html()).toBe('');
});
it('calls Flash correctly', () => {
expect(Flash.mock.calls).toEqual([
[
{
message: SecurityReportsApp.i18n.apiError,
captureError: true,
error,
},
],
]);
});
});
});

View File

@ -354,4 +354,8 @@ RSpec.describe MergeRequestWidgetEntity do
expect(entity[:rebase_path]).to be_nil
end
end
it 'has security_reports_docs_path' do
expect(subject[:security_reports_docs_path]).not_to be_nil
end
end