Add latest changes from gitlab-org/gitlab@master
This commit is contained in:
parent
884a65481f
commit
f5dcc7ae73
|
@ -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));
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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 || {};
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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';
|
||||
|
|
|
@ -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);
|
||||
}
|
|
@ -426,3 +426,17 @@
|
|||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.codequality-report {
|
||||
.media {
|
||||
padding: $gl-padding;
|
||||
}
|
||||
|
||||
.media-body {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.report-block-container {
|
||||
height: auto !important;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -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?
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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
|
|
@ -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**.
|
||||
|
|
|
@ -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 ""
|
||||
|
||||
|
|
2
qa/qa.rb
2
qa/qa.rb
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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
|
|
@ -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';
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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 = {
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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',
|
||||
);
|
||||
});
|
||||
});
|
|
@ -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,
|
||||
},
|
||||
],
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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
|
||||
|
|
Loading…
Reference in New Issue