Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2022-06-07 15:08:12 +00:00
parent edb317e9fe
commit 7bbc731c75
54 changed files with 1067 additions and 533 deletions

View File

@ -152,41 +152,3 @@ feature-flags-usage:
when: always when: always
paths: paths:
- tmp/feature_flags/ - tmp/feature_flags/
semgrep-appsec-custom-rules:
stage: lint
extends:
- .static-analysis:rules:ee
image: returntocorp/semgrep
needs: []
script:
# Required to avoid a timeout https://github.com/returntocorp/semgrep/issues/5395
- git fetch origin master
# Include/exclude list isn't ideal https://github.com/returntocorp/semgrep/issues/5399
- |
semgrep ci --gitlab-sast --metrics off --config $CUSTOM_RULES_URL \
--include app --include lib --include workhorse \
--exclude '*_test.go' --exclude spec --exclude qa > gl-sast-report.json || true
variables:
CUSTOM_RULES_URL: https://gitlab.com/gitlab-com/gl-security/appsec/sast-custom-rules/-/raw/main/appsec-pings/rules.yml
artifacts:
paths:
- gl-sast-report.json
reports:
sast: gl-sast-report.json
ping-appsec-for-sast-findings:
stage: lint
image: alpine:latest
variables:
# Project Access Token bot ID for /gitlab-com/gl-security/appsec/sast-custom-rules
BOT_USER_ID: 11727358
needs:
- semgrep-appsec-custom-rules
rules:
# Requiring $CUSTOM_SAST_RULES_BOT_PAT prevents the bot from running on forks or CE
# Without it the script would fail too.
- if: "$CI_MERGE_REQUEST_IID && $CUSTOM_SAST_RULES_BOT_PAT"
script:
- apk add jq curl
- scripts/process_custom_semgrep_results.sh

View File

@ -1,107 +0,0 @@
*.log
*.swp
*.mo
*.edit.po
*.rej
.dir-locals.el
.DS_Store
.bundle
.chef
.directory
.eslintcache
/.envrc
eslint-report.html
/.gitlab_shell_secret
.idea
.nova
/.vscode/*
/.rbenv-version
.rbx/
/.ruby-gemset
/.ruby-version
/.tool-versions
/.rvmrc
/.secret
.sass-cache/
/.vagrant
/.yarn-cache
/.byebug_history
/Vagrantfile
/app/assets/images/icons.json
/app/assets/images/icons.svg
/app/assets/images/illustrations/
/app/assets/javascripts/locale/**/app.js
/backups/*
/config/aws.yml
/config/cable.yml
/config/database*.yml
/config/gitlab.yml
/config/gitlab_ci.yml
/config/Gitlab.gitlab-license
/config/initializers/smtp_settings.rb
/config/initializers/relative_url.rb
/config/resque.yml
/config/redis.*.yml
/config/unicorn.rb
/config/puma.rb
/config/secrets.yml
/config/sidekiq.yml
/config/registry.key
/coverage/*
/db/*.sqlite3
/db/*.sqlite3-journal
/db/data.yml
/doc/code/*
/dump.rdb
/jsconfig.json
/lefthook-local.yml
/log/*.log*
/node_modules
/nohup.out
/public/assets/
/public/uploads.*
/public/uploads/
/public/sitemap.xml
/public/sitemap.xml.gz
/shared/artifacts/
/spec/examples.txt
/rails_best_practices_output.html
/tags
/vendor/bundle/*
/vendor/gitaly-ruby
/builds*
/.gitlab_workhorse_secret
/.gitlab_pages_secret
/.gitlab_kas_secret
/webpack-report/
/crystalball/
/test_results/
/deprecations/
/knapsack/
/rspec_flaky/
/rspec/
/locale/**/LC_MESSAGES
/locale/**/*.time_stamp
/.rspec
/.gitlab_smime_key
/.gitlab_smime_cert
package-lock.json
/junit_*.xml
/coverage-frontend/
jsdoc/
**/tmp/rubocop_cache/**
.overcommit.yml
.overcommit.yml.backup
.projections.json
/qa/.rakeTasks
webpack-dev-server.json
/.nvimrc
.solargraph.yml
/tmp/matching_foss_tests.txt
/tmp/matching_tests.txt
ee/changelogs/unreleased-ee
/sitespeed-result
tags.lock
tags.temp
.stylelintcache
.solargraph.yml

View File

@ -1 +1 @@
f099614e635d05483055ba6fbebc74d961bf2ce5 70d6aa021ebfc05d9d727a7eb4c9ff4782db4c30

View File

@ -21,6 +21,7 @@ export default {
description: __("Make sure you save it - you won't be able to access it again."), description: __("Make sure you save it - you won't be able to access it again."),
label: __('Your new %{accessTokenType}'), label: __('Your new %{accessTokenType}'),
}, },
tokenInputId: 'new-access-token',
inject: ['accessTokenType'], inject: ['accessTokenType'],
data() { data() {
return { errors: null, infoAlert: null, newToken: null }; return { errors: null, infoAlert: null, newToken: null };
@ -41,6 +42,14 @@ export default {
copyButtonTitle() { copyButtonTitle() {
return sprintf(this.$options.i18n.copyButtonTitle, { accessTokenType: this.accessTokenType }); return sprintf(this.$options.i18n.copyButtonTitle, { accessTokenType: this.accessTokenType });
}, },
formInputGroupProps() {
return {
id: this.$options.tokenInputId,
class: 'qa-created-access-token',
'data-qa-selector': 'created_access_token_field',
name: this.$options.tokenInputId,
};
},
label() { label() {
return sprintf(this.$options.i18n.label, { accessTokenType: this.accessTokenType }); return sprintf(this.$options.i18n.label, { accessTokenType: this.accessTokenType });
}, },
@ -92,16 +101,15 @@ export default {
<template v-if="newToken"> <template v-if="newToken">
<!-- <!--
After issue https://gitlab.com/gitlab-org/gitlab/-/issues/360921 is After issue https://gitlab.com/gitlab-org/gitlab/-/issues/360921 is
closed remove the `initial-visibility` and `input-class` props. closed remove the `initial-visibility`.
--> -->
<input-copy-toggle-visibility <input-copy-toggle-visibility
data-testid="new-access-token"
:copy-button-title="copyButtonTitle" :copy-button-title="copyButtonTitle"
:label="label" :label="label"
:label-for="$options.tokenInputId"
:value="newToken" :value="newToken"
initial-visibility initial-visibility
input-class="qa-created-access-token" :form-input-group-props="formInputGroupProps"
qa-selector="created_access_token_field"
> >
<template #description> <template #description>
{{ $options.i18n.description }} {{ $options.i18n.description }}

View File

@ -17,7 +17,7 @@ export default {
text: __('Syntax is incorrect.'), text: __('Syntax is incorrect.'),
}, },
includesText: __( includesText: __(
'CI configuration validated, including all configuration added with the %{codeStart}includes%{codeEnd} keyword. %{link}', 'CI configuration validated, including all configuration added with the %{codeStart}include%{codeEnd} keyword. %{link}',
), ),
warningTitle: __('The form contains the following warning:'), warningTitle: __('The form contains the following warning:'),
fields: [ fields: [

View File

@ -231,7 +231,7 @@ export default {
<template> <template>
<div <div
ref="linkedPipeline" ref="linkedPipeline"
class="gl-h-full gl-display-flex!" class="gl-h-full gl-display-flex! gl-px-2"
:class="flexDirection" :class="flexDirection"
data-qa-selector="linked_pipeline_container" data-qa-selector="linked_pipeline_container"
@mouseover="onDownstreamHovered" @mouseover="onDownstreamHovered"

View File

@ -45,7 +45,6 @@ export default {
data() { data() {
return { return {
currentRegistrationToken: this.registrationToken, currentRegistrationToken: this.registrationToken,
instructionsModalOpened: false,
}; };
}, },
computed: { computed: {
@ -64,15 +63,7 @@ export default {
}, },
methods: { methods: {
onShowInstructionsClick() { onShowInstructionsClick() {
// Rendering the modal on demand, to avoid this.$refs.runnerInstructionsModal.show();
// loading instructions prematurely from API.
this.instructionsModalOpened = true;
this.$nextTick(() => {
// $refs.runnerInstructionsModal is defined in
// the tick after the modal is rendered
this.$refs.runnerInstructionsModal.show();
});
}, },
onTokenReset(token) { onTokenReset(token) {
this.currentRegistrationToken = token; this.currentRegistrationToken = token;
@ -94,7 +85,6 @@ export default {
<gl-dropdown-item @click.capture.native.stop="onShowInstructionsClick"> <gl-dropdown-item @click.capture.native.stop="onShowInstructionsClick">
{{ $options.i18n.showInstallationInstructions }} {{ $options.i18n.showInstallationInstructions }}
<runner-instructions-modal <runner-instructions-modal
v-if="instructionsModalOpened"
ref="runnerInstructionsModal" ref="runnerInstructionsModal"
:registration-token="currentRegistrationToken" :registration-token="currentRegistrationToken"
data-testid="runner-instructions-modal" data-testid="runner-instructions-modal"

View File

@ -13,6 +13,13 @@ export default {
default: '', default: '',
}, },
}, },
computed: {
formInputGroupProps() {
return {
name: 'token-value',
};
},
},
methods: { methods: {
onCopy() { onCopy() {
// value already in the clipboard, simply notify the user // value already in the clipboard, simply notify the user
@ -26,8 +33,8 @@ export default {
<input-copy-toggle-visibility <input-copy-toggle-visibility
class="gl-m-0" class="gl-m-0"
:value="value" :value="value"
data-testid="token-value"
:copy-button-title="$options.I18N_COPY_BUTTON_TITLE" :copy-button-title="$options.I18N_COPY_BUTTON_TITLE"
:form-input-group-props="formInputGroupProps"
@copy="onCopy" @copy="onCopy"
/> />
</template> </template>

View File

@ -1,7 +1,16 @@
<script> <script>
import { GlAlert, GlBadge, GlLink, GlLoadingIcon, GlSprintf, GlTable, GlTooltip } from '@gitlab/ui'; import {
GlAlert,
GlBadge,
GlLink,
GlLoadingIcon,
GlSprintf,
GlTable,
GlTooltip,
GlTooltipDirective,
} from '@gitlab/ui';
import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { s__ } from '~/locale'; import { s__, sprintf } from '~/locale';
import CiBadge from '~/vue_shared/components/ci_badge_link.vue'; import CiBadge from '~/vue_shared/components/ci_badge_link.vue';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import timeagoMixin from '~/vue_shared/mixins/timeago'; import timeagoMixin from '~/vue_shared/mixins/timeago';
@ -20,6 +29,9 @@ export default {
StateActions, StateActions,
TimeAgoTooltip, TimeAgoTooltip,
}, },
directives: {
GlTooltip: GlTooltipDirective,
},
mixins: [timeagoMixin], mixins: [timeagoMixin],
props: { props: {
states: { states: {
@ -68,6 +80,8 @@ export default {
locked: s__('Terraform|Locked'), locked: s__('Terraform|Locked'),
lockedByUser: s__('Terraform|Locked by %{user} %{timeAgo}'), lockedByUser: s__('Terraform|Locked by %{user} %{timeAgo}'),
lockingState: s__('Terraform|Locking state'), lockingState: s__('Terraform|Locking state'),
deleting: s__('Terraform|Removed'),
deletionInProgress: s__('Terraform|Deletion in progress'),
name: s__('Terraform|Name'), name: s__('Terraform|Name'),
pipeline: s__('Terraform|Pipeline'), pipeline: s__('Terraform|Pipeline'),
removing: s__('Terraform|Removing'), removing: s__('Terraform|Removing'),
@ -85,6 +99,12 @@ export default {
lockedByUserName(item) { lockedByUserName(item) {
return item.lockedByUser?.name || this.$options.i18n.unknownUser; return item.lockedByUser?.name || this.$options.i18n.unknownUser;
}, },
lockedByUserText(item) {
return sprintf(this.$options.i18n.lockedByUser, {
user: this.lockedByUserName(item),
timeAgo: this.timeFormatted(item.lockedAt),
});
},
pipelineDetailedStatus(item) { pipelineDetailedStatus(item) {
return item.latestVersion?.job?.detailedStatus; return item.latestVersion?.job?.detailedStatus;
}, },
@ -142,29 +162,27 @@ export default {
</div> </div>
<div <div
v-else-if="item.lockedAt" v-else-if="item.deletedAt"
:id="`terraformLockedBadgeContainer${item.name}`" v-gl-tooltip.right
class="gl-mx-3" class="gl-mx-3"
:title="$options.i18n.deletionInProgress"
:data-testid="`state-badge-${item.name}`"
> >
<gl-badge :id="`terraformLockedBadge${item.name}`" icon="lock"> <gl-badge icon="remove">
{{ $options.i18n.deleting }}
</gl-badge>
</div>
<div
v-else-if="item.lockedAt"
v-gl-tooltip.right
class="gl-mx-3"
:title="lockedByUserText(item)"
:data-testid="`state-badge-${item.name}`"
>
<gl-badge icon="lock">
{{ $options.i18n.locked }} {{ $options.i18n.locked }}
</gl-badge> </gl-badge>
<gl-tooltip
:container="`terraformLockedBadgeContainer${item.name}`"
:target="`terraformLockedBadge${item.name}`"
placement="right"
>
<gl-sprintf :message="$options.i18n.lockedByUser">
<template #user>
{{ lockedByUserName(item) }}
</template>
<template #timeAgo>
{{ timeFormatted(item.lockedAt) }}
</template>
</gl-sprintf>
</gl-tooltip>
</div> </div>
</div> </div>
</template> </template>

View File

@ -11,6 +11,7 @@ fragment State on TerraformState {
name name
lockedAt lockedAt
updatedAt updatedAt
deletedAt
lockedByUser { lockedByUser {
...User ...User

View File

@ -63,13 +63,13 @@ export default {
<div class="gl-w-full"> <div class="gl-w-full">
<div class="gl-display-flex gl-flex-nowrap"> <div class="gl-display-flex gl-flex-nowrap">
<div class="gl-flex-wrap gl-display-flex gl-w-full"> <div class="gl-flex-wrap gl-display-flex gl-w-full">
<div class="gl-mr-4 gl-display-flex gl-align-items-center"> <div class="gl-display-flex gl-align-items-center">
<p v-safe-html="generateText(data.text)" class="gl-m-0"></p> <p v-safe-html="generateText(data.text)" class="gl-m-0"></p>
</div> </div>
<div v-if="data.link"> <div v-if="data.link" class="gl-pr-2">
<gl-link :href="data.link.href">{{ data.link.text }}</gl-link> <gl-link :href="data.link.href">{{ data.link.text }}</gl-link>
</div> </div>
<div v-if="data.modal"> <div v-if="data.modal" class="gl-pr-2">
<gl-link v-gl-modal="modalId" data-testid="modal-link" @click="data.modal.onClick"> <gl-link v-gl-modal="modalId" data-testid="modal-link" @click="data.modal.onClick">
{{ data.modal.text }} {{ data.modal.text }}
</gl-link> </gl-link>
@ -81,7 +81,11 @@ export default {
{{ data.badge.text }} {{ data.badge.text }}
</gl-badge> </gl-badge>
</div> </div>
<actions :widget="widgetLabel" :tertiary-buttons="data.actions" class="gl-ml-auto" /> <actions
:widget="widgetLabel"
:tertiary-buttons="data.actions"
class="gl-ml-auto gl-pl-3"
/>
</div> </div>
<p <p
v-if="data.subtext" v-if="data.subtext"

View File

@ -1,5 +1,11 @@
<script> <script>
import { GlFormInputGroup, GlFormGroup, GlButton, GlTooltipDirective } from '@gitlab/ui'; import {
GlFormInputGroup,
GlFormInput,
GlFormGroup,
GlButton,
GlTooltipDirective,
} from '@gitlab/ui';
import { __ } from '~/locale'; import { __ } from '~/locale';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
@ -12,6 +18,7 @@ export default {
}, },
components: { components: {
GlFormInputGroup, GlFormInputGroup,
GlFormInput,
GlFormGroup, GlFormGroup,
GlButton, GlButton,
ClipboardButton, ClipboardButton,
@ -52,20 +59,6 @@ export default {
return {}; return {};
}, },
}, },
/*
`inputClass` prop should be removed after https://gitlab.com/gitlab-org/gitlab/-/issues/357848
is implemented.
*/
inputClass: {
type: String,
required: false,
default: '',
},
qaSelector: {
type: String,
required: false,
default: undefined,
},
}, },
data() { data() {
return { return {
@ -87,9 +80,6 @@ export default {
displayedValue() { displayedValue() {
return this.computedValueIsVisible ? this.value : '*'.repeat(this.value.length || 20); return this.computedValueIsVisible ? this.value : '*'.repeat(this.value.length || 20);
}, },
classInput() {
return `gl-font-monospace! gl-cursor-default! ${this.inputClass}`.trimEnd();
},
}, },
methods: { methods: {
handleToggleVisibilityButtonClick() { handleToggleVisibilityButtonClick() {
@ -97,6 +87,9 @@ export default {
this.$emit('visibility-change', this.valueIsVisible); this.$emit('visibility-change', this.valueIsVisible);
}, },
handleClick() {
this.$refs.input.$el.select();
},
handleCopyButtonClick() { handleCopyButtonClick() {
this.$emit('copy'); this.$emit('copy');
}, },
@ -115,15 +108,21 @@ export default {
</script> </script>
<template> <template>
<gl-form-group v-bind="$attrs"> <gl-form-group v-bind="$attrs">
<gl-form-input-group <gl-form-input-group>
:value="displayedValue" <gl-form-input
:input-class="classInput" ref="input"
:data-qa-selector="qaSelector" readonly
select-on-click class="gl-font-monospace! gl-cursor-default!"
readonly v-bind="formInputGroupProps"
v-bind="formInputGroupProps" :value="displayedValue"
@copy="handleFormInputCopy" @copy="handleFormInputCopy"
> @click="handleClick"
/>
<!--
This v-if is necessary to avoid an issue with border radius.
See https://gitlab.com/gitlab-org/gitlab/-/merge_requests/88059#note_969812649
-->
<template v-if="showToggleVisibilityButton || showCopyButton" #append> <template v-if="showToggleVisibilityButton || showCopyButton" #append>
<gl-button <gl-button
v-if="showToggleVisibilityButton" v-if="showToggleVisibilityButton"

View File

@ -9,35 +9,19 @@ export default {
RunnerInstructionsModal, RunnerInstructionsModal,
}, },
directives: { directives: {
GlModalDirective, GlModal: GlModalDirective,
}, },
modalId: 'runner-instructions-modal', modalId: 'runner-instructions-modal',
i18n: { i18n: {
buttonText: s__('Runners|Show runner installation instructions'), buttonText: s__('Runners|Show runner installation instructions'),
}, },
data() {
return {
opened: false,
};
},
methods: {
onClick() {
// lazily mount modal to prevent premature instructions requests
this.opened = true;
},
},
}; };
</script> </script>
<template> <template>
<div> <div>
<gl-button <gl-button v-gl-modal="$options.modalId" class="gl-mt-4" data-testid="show-modal-button">
v-gl-modal-directive="$options.modalId"
class="gl-mt-4"
data-testid="show-modal-button"
@click="onClick"
>
{{ $options.i18n.buttonText }} {{ $options.i18n.buttonText }}
</gl-button> </gl-button>
<runner-instructions-modal v-if="opened" :modal-id="$options.modalId" /> <runner-instructions-modal :modal-id="$options.modalId" />
</div> </div>
</template> </template>

View File

@ -58,6 +58,10 @@ export default {
apollo: { apollo: {
platforms: { platforms: {
query: getRunnerPlatformsQuery, query: getRunnerPlatformsQuery,
skip() {
// Only load instructions once the modal is shown
return !this.shown;
},
update(data) { update(data) {
return ( return (
data?.runnerPlatforms?.nodes.map(({ name, humanReadableName, architectures }) => { data?.runnerPlatforms?.nodes.map(({ name, humanReadableName, architectures }) => {
@ -81,7 +85,7 @@ export default {
instructions: { instructions: {
query: getRunnerSetupInstructionsQuery, query: getRunnerSetupInstructionsQuery,
skip() { skip() {
return !this.selectedPlatform; return !this.shown || !this.selectedPlatform;
}, },
variables() { variables() {
return { return {
@ -99,6 +103,7 @@ export default {
}, },
data() { data() {
return { return {
shown: false,
platforms: [], platforms: [],
selectedPlatform: null, selectedPlatform: null,
selectedArchitecture: null, selectedArchitecture: null,
@ -136,13 +141,24 @@ export default {
return registerInstructions; return registerInstructions;
}, },
}, },
updated() {
// Refocus on dom changes, after loading data
this.refocusSelectedPlatformButton();
},
methods: { methods: {
show() { show() {
this.$refs.modal.show(); this.$refs.modal.show();
}, },
focusSelected() { onShown() {
// By default the first platform always gets the focus, but when the `defaultPlatformName` this.shown = true;
// property is present, any other platform might actually be selected. this.refocusSelectedPlatformButton();
},
refocusSelectedPlatformButton() {
// On modal opening, the first focusable element is auto-focused by bootstrap-vue
// This can be confusing for users, because the wrong platform button can
// get focused when setting a `defaultPlatformName`.
// This method refocuses the expected button.
// See more about this auto-focus: https://bootstrap-vue.org/docs/components/modal#auto-focus-on-open
this.$refs[this.selectedPlatform]?.[0].$el.focus(); this.$refs[this.selectedPlatform]?.[0].$el.focus();
}, },
selectPlatform(platformName) { selectPlatform(platformName) {
@ -171,6 +187,7 @@ export default {
}, },
}, },
i18n: { i18n: {
environment: __('Environment'),
installARunner: s__('Runners|Install a runner'), installARunner: s__('Runners|Install a runner'),
architecture: s__('Runners|Architecture'), architecture: s__('Runners|Architecture'),
downloadInstallBinary: s__('Runners|Download and install binary'), downloadInstallBinary: s__('Runners|Download and install binary'),
@ -178,6 +195,7 @@ export default {
registerRunnerCommand: s__('Runners|Command to register runner'), registerRunnerCommand: s__('Runners|Command to register runner'),
fetchError: s__('Runners|An error has occurred fetching instructions'), fetchError: s__('Runners|An error has occurred fetching instructions'),
copyInstructions: s__('Runners|Copy instructions'), copyInstructions: s__('Runners|Copy instructions'),
viewInstallationInstructions: s__('Runners|View installation instructions'),
}, },
closeButton: { closeButton: {
text: __('Close'), text: __('Close'),
@ -193,7 +211,7 @@ export default {
:action-secondary="$options.closeButton" :action-secondary="$options.closeButton"
v-bind="$attrs" v-bind="$attrs"
v-on="$listeners" v-on="$listeners"
@shown="focusSelected" @shown="onShown"
> >
<gl-alert v-if="showAlert" variant="danger" @dismiss="toggleAlert(false)"> <gl-alert v-if="showAlert" variant="danger" @dismiss="toggleAlert(false)">
{{ $options.i18n.fetchError }} {{ $options.i18n.fetchError }}
@ -203,7 +221,7 @@ export default {
<template v-if="platforms.length"> <template v-if="platforms.length">
<h5> <h5>
{{ __('Environment') }} {{ $options.i18n.environment }}
</h5> </h5>
<div v-gl-resize-observer="onPlatformsButtonResize"> <div v-gl-resize-observer="onPlatformsButtonResize">
<gl-button-group <gl-button-group
@ -295,7 +313,7 @@ export default {
<p>{{ instructionsWithoutArchitecture }}</p> <p>{{ instructionsWithoutArchitecture }}</p>
<gl-button :href="runnerInstallationLink"> <gl-button :href="runnerInstallationLink">
<gl-icon name="external-link" /> <gl-icon name="external-link" />
{{ s__('Runners|View installation instructions') }} {{ $options.i18n.viewInstallationInstructions }}
</gl-button> </gl-button>
</div> </div>
</template> </template>

View File

@ -169,6 +169,9 @@ export default {
setFocus() { setFocus() {
this.$refs.header.focusInput(); this.$refs.header.focusInput();
}, },
hideDropdown() {
this.$refs.dropdown.hide();
},
showDropdown() { showDropdown() {
this.$refs.dropdown.show(); this.$refs.dropdown.show();
}, },
@ -205,7 +208,7 @@ export default {
:show-dropdown-contents-create-view="showDropdownContentsCreateView" :show-dropdown-contents-create-view="showDropdownContentsCreateView"
:is-standalone="isStandalone" :is-standalone="isStandalone"
@toggleDropdownContentsCreateView="toggleDropdownContent" @toggleDropdownContentsCreateView="toggleDropdownContent"
@closeDropdown="$emit('closeDropdown')" @closeDropdown="hideDropdown"
@input="debouncedSearchKeyUpdate" @input="debouncedSearchKeyUpdate"
@searchEnter="selectFirstItem" @searchEnter="selectFirstItem"
/> />

View File

@ -0,0 +1,48 @@
# frozen_string_literal: true
module Mutations
module Packages
module Cleanup
module Policy
class Update < Mutations::BaseMutation
graphql_name 'UpdatePackagesCleanupPolicy'
include FindsProject
authorize :admin_package
argument :project_path,
GraphQL::Types::ID,
required: true,
description: 'Project path where the packages cleanup policy is located.'
argument :keep_n_duplicated_package_files,
Types::Packages::Cleanup::KeepDuplicatedPackageFilesEnum,
required: false,
description: copy_field_description(
Types::Packages::Cleanup::PolicyType,
:keep_n_duplicated_package_files
)
field :packages_cleanup_policy,
Types::Packages::Cleanup::PolicyType,
null: true,
description: 'Packages cleanup policy after mutation.'
def resolve(project_path:, **args)
project = authorized_find!(project_path)
result = ::Packages::Cleanup::UpdatePolicyService
.new(project: project, current_user: current_user, params: args)
.execute
{
packages_cleanup_policy: result.payload[:packages_cleanup_policy],
errors: result.errors
}
end
end
end
end
end
end

View File

@ -136,6 +136,7 @@ module Types
mount_mutation Mutations::UserPreferences::Update mount_mutation Mutations::UserPreferences::Update
mount_mutation Mutations::Packages::Destroy mount_mutation Mutations::Packages::Destroy
mount_mutation Mutations::Packages::DestroyFile mount_mutation Mutations::Packages::DestroyFile
mount_mutation Mutations::Packages::Cleanup::Policy::Update
mount_mutation Mutations::Echo mount_mutation Mutations::Echo
mount_mutation Mutations::WorkItems::Create, deprecated: { milestone: '15.1', reason: :alpha } mount_mutation Mutations::WorkItems::Create, deprecated: { milestone: '15.1', reason: :alpha }
mount_mutation Mutations::WorkItems::CreateFromTask, deprecated: { milestone: '15.1', reason: :alpha } mount_mutation Mutations::WorkItems::CreateFromTask, deprecated: { milestone: '15.1', reason: :alpha }

View File

@ -0,0 +1,25 @@
# frozen_string_literal: true
module Types
module Packages
module Cleanup
class KeepDuplicatedPackageFilesEnum < BaseEnum
graphql_name 'PackagesCleanupKeepDuplicatedPackageFilesEnum'
OPTIONS_MAPPING = {
'all' => 'ALL_PACKAGE_FILES',
'1' => 'ONE_PACKAGE_FILE',
'10' => 'TEN_PACKAGE_FILES',
'20' => 'TWENTY_PACKAGE_FILES',
'30' => 'THIRTY_PACKAGE_FILES',
'40' => 'FORTY_PACKAGE_FILES',
'50' => 'FIFTY_PACKAGE_FILES'
}.freeze
::Packages::Cleanup::Policy::KEEP_N_DUPLICATED_PACKAGE_FILES_VALUES.each do |keep_value|
value OPTIONS_MAPPING[keep_value], value: keep_value, description: "Value to keep #{keep_value} package files"
end
end
end
end
end

View File

@ -0,0 +1,23 @@
# frozen_string_literal: true
module Types
module Packages
module Cleanup
class PolicyType < ::Types::BaseObject
graphql_name 'PackagesCleanupPolicy'
description 'A packages cleanup policy designed to keep only packages and packages assets that matter most'
authorize :admin_package
field :keep_n_duplicated_package_files,
Types::Packages::Cleanup::KeepDuplicatedPackageFilesEnum,
null: false,
description: 'Number of duplicated package files to retain.'
field :next_run_at,
Types::TimeType,
null: true,
description: 'Next time that this packages cleanup policy will be executed.'
end
end
end
end

View File

@ -181,6 +181,11 @@ module Types
description: 'Packages of the project.', description: 'Packages of the project.',
resolver: Resolvers::ProjectPackagesResolver resolver: Resolvers::ProjectPackagesResolver
field :packages_cleanup_policy,
Types::Packages::Cleanup::PolicyType,
null: true,
description: 'Packages cleanup policy for the project.'
field :jobs, field :jobs,
type: Types::Ci::JobType.connection_type, type: Types::Ci::JobType.connection_type,
null: true, null: true,

View File

@ -38,6 +38,10 @@ module Types
null: false, null: false,
description: 'Timestamp the Terraform state was updated.' description: 'Timestamp the Terraform state was updated.'
field :deleted_at, Types::TimeType,
null: true,
description: 'Timestamp the Terraform state was deleted.'
def locked_by_user def locked_by_user
Gitlab::Graphql::Loaders::BatchModelLoader.new(User, object.locked_by_user_id).find Gitlab::Graphql::Loaders::BatchModelLoader.new(User, object.locked_by_user_id).find
end end

View File

@ -15,7 +15,7 @@ module Packages
validates :keep_n_duplicated_package_files, validates :keep_n_duplicated_package_files,
inclusion: { inclusion: {
in: KEEP_N_DUPLICATED_PACKAGE_FILES_VALUES, in: KEEP_N_DUPLICATED_PACKAGE_FILES_VALUES,
message: 'keep_n_duplicated_package_files is invalid' message: 'is invalid'
} }
# used by Schedulable # used by Schedulable

View File

@ -0,0 +1,9 @@
# frozen_string_literal: true
module Packages
module Cleanup
class PolicyPolicy < BasePolicy
delegate { @subject.project }
end
end
end

View File

@ -433,6 +433,7 @@ class ProjectPolicy < BasePolicy
rule { can?(:maintainer_access) }.policy do rule { can?(:maintainer_access) }.policy do
enable :destroy_package enable :destroy_package
enable :admin_package
enable :admin_issue_board enable :admin_issue_board
enable :push_to_delete_protected_branch enable :push_to_delete_protected_branch
enable :update_snippet enable :update_snippet

View File

@ -0,0 +1,35 @@
# frozen_string_literal: true
module Packages
module Cleanup
class UpdatePolicyService < BaseProjectService
ALLOWED_ATTRIBUTES = %i[keep_n_duplicated_package_files].freeze
def execute
return ServiceResponse.error(message: 'Access denied') unless allowed?
if policy.update(policy_params)
ServiceResponse.success(payload: { packages_cleanup_policy: policy })
else
ServiceResponse.error(message: policy.errors.full_messages.to_sentence || 'Bad request')
end
end
private
def policy
strong_memoize(:policy) do
project.packages_cleanup_policy
end
end
def allowed?
can?(current_user, :admin_package, project)
end
def policy_params
params.slice(*ALLOWED_ATTRIBUTES)
end
end
end
end

View File

@ -0,0 +1,8 @@
---
name: standard_context_type_check
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/88540
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/364265
milestone: '15.1'
type: development
group: group::product intelligence
default_enabled: false

View File

@ -145,6 +145,8 @@
- 1 - 1
- - elastic_namespace_rollout - - elastic_namespace_rollout
- 1 - 1
- - elastic_project_transfer
- 1
- - email_receiver - - email_receiver
- 2 - 2
- - emails_on_push - - emails_on_push

View File

@ -5112,6 +5112,26 @@ Input type: `UpdateNoteInput`
| <a id="mutationupdatenoteerrors"></a>`errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. | | <a id="mutationupdatenoteerrors"></a>`errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. |
| <a id="mutationupdatenotenote"></a>`note` | [`Note`](#note) | Note after mutation. | | <a id="mutationupdatenotenote"></a>`note` | [`Note`](#note) | Note after mutation. |
### `Mutation.updatePackagesCleanupPolicy`
Input type: `UpdatePackagesCleanupPolicyInput`
#### Arguments
| Name | Type | Description |
| ---- | ---- | ----------- |
| <a id="mutationupdatepackagescleanuppolicyclientmutationid"></a>`clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. |
| <a id="mutationupdatepackagescleanuppolicykeepnduplicatedpackagefiles"></a>`keepNDuplicatedPackageFiles` | [`PackagesCleanupKeepDuplicatedPackageFilesEnum`](#packagescleanupkeepduplicatedpackagefilesenum) | Number of duplicated package files to retain. |
| <a id="mutationupdatepackagescleanuppolicyprojectpath"></a>`projectPath` | [`ID!`](#id) | Project path where the packages cleanup policy is located. |
#### Fields
| Name | Type | Description |
| ---- | ---- | ----------- |
| <a id="mutationupdatepackagescleanuppolicyclientmutationid"></a>`clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. |
| <a id="mutationupdatepackagescleanuppolicyerrors"></a>`errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. |
| <a id="mutationupdatepackagescleanuppolicypackagescleanuppolicy"></a>`packagesCleanupPolicy` | [`PackagesCleanupPolicy`](#packagescleanuppolicy) | Packages cleanup policy after mutation. |
### `Mutation.updateRequirement` ### `Mutation.updateRequirement`
Input type: `UpdateRequirementInput` Input type: `UpdateRequirementInput`
@ -14382,6 +14402,17 @@ Represents a package tag.
| <a id="packagetagname"></a>`name` | [`String!`](#string) | Name of the tag. | | <a id="packagetagname"></a>`name` | [`String!`](#string) | Name of the tag. |
| <a id="packagetagupdatedat"></a>`updatedAt` | [`Time!`](#time) | Updated date. | | <a id="packagetagupdatedat"></a>`updatedAt` | [`Time!`](#time) | Updated date. |
### `PackagesCleanupPolicy`
A packages cleanup policy designed to keep only packages and packages assets that matter most.
#### Fields
| Name | Type | Description |
| ---- | ---- | ----------- |
| <a id="packagescleanuppolicykeepnduplicatedpackagefiles"></a>`keepNDuplicatedPackageFiles` | [`PackagesCleanupKeepDuplicatedPackageFilesEnum!`](#packagescleanupkeepduplicatedpackagefilesenum) | Number of duplicated package files to retain. |
| <a id="packagescleanuppolicynextrunat"></a>`nextRunAt` | [`Time`](#time) | Next time that this packages cleanup policy will be executed. |
### `PageInfo` ### `PageInfo`
Information about pagination in a connection. Information about pagination in a connection.
@ -14690,6 +14721,7 @@ Represents vulnerability finding of a security report on the pipeline.
| <a id="projectonlyallowmergeifalldiscussionsareresolved"></a>`onlyAllowMergeIfAllDiscussionsAreResolved` | [`Boolean`](#boolean) | Indicates if merge requests of the project can only be merged when all the discussions are resolved. | | <a id="projectonlyallowmergeifalldiscussionsareresolved"></a>`onlyAllowMergeIfAllDiscussionsAreResolved` | [`Boolean`](#boolean) | Indicates if merge requests of the project can only be merged when all the discussions are resolved. |
| <a id="projectonlyallowmergeifpipelinesucceeds"></a>`onlyAllowMergeIfPipelineSucceeds` | [`Boolean`](#boolean) | Indicates if merge requests of the project can only be merged with successful jobs. | | <a id="projectonlyallowmergeifpipelinesucceeds"></a>`onlyAllowMergeIfPipelineSucceeds` | [`Boolean`](#boolean) | Indicates if merge requests of the project can only be merged with successful jobs. |
| <a id="projectopenissuescount"></a>`openIssuesCount` | [`Int`](#int) | Number of open issues for the project. | | <a id="projectopenissuescount"></a>`openIssuesCount` | [`Int`](#int) | Number of open issues for the project. |
| <a id="projectpackagescleanuppolicy"></a>`packagesCleanupPolicy` | [`PackagesCleanupPolicy`](#packagescleanuppolicy) | Packages cleanup policy for the project. |
| <a id="projectpath"></a>`path` | [`String!`](#string) | Path of the project. | | <a id="projectpath"></a>`path` | [`String!`](#string) | Path of the project. |
| <a id="projectpathlocks"></a>`pathLocks` | [`PathLockConnection`](#pathlockconnection) | The project's path locks. (see [Connections](#connections)) | | <a id="projectpathlocks"></a>`pathLocks` | [`PathLockConnection`](#pathlockconnection) | The project's path locks. (see [Connections](#connections)) |
| <a id="projectpipelineanalytics"></a>`pipelineAnalytics` | [`PipelineAnalytics`](#pipelineanalytics) | Pipeline analytics. | | <a id="projectpipelineanalytics"></a>`pipelineAnalytics` | [`PipelineAnalytics`](#pipelineanalytics) | Pipeline analytics. |
@ -16765,6 +16797,7 @@ Completion status of tasks.
| Name | Type | Description | | Name | Type | Description |
| ---- | ---- | ----------- | | ---- | ---- | ----------- |
| <a id="terraformstatecreatedat"></a>`createdAt` | [`Time!`](#time) | Timestamp the Terraform state was created. | | <a id="terraformstatecreatedat"></a>`createdAt` | [`Time!`](#time) | Timestamp the Terraform state was created. |
| <a id="terraformstatedeletedat"></a>`deletedAt` | [`Time`](#time) | Timestamp the Terraform state was deleted. |
| <a id="terraformstateid"></a>`id` | [`ID!`](#id) | ID of the Terraform state. | | <a id="terraformstateid"></a>`id` | [`ID!`](#id) | ID of the Terraform state. |
| <a id="terraformstatelatestversion"></a>`latestVersion` | [`TerraformStateVersion`](#terraformstateversion) | Latest version of the Terraform state. | | <a id="terraformstatelatestversion"></a>`latestVersion` | [`TerraformStateVersion`](#terraformstateversion) | Latest version of the Terraform state. |
| <a id="terraformstatelockedat"></a>`lockedAt` | [`Time`](#time) | Timestamp the Terraform state was locked. | | <a id="terraformstatelockedat"></a>`lockedAt` | [`Time`](#time) | Timestamp the Terraform state was locked. |
@ -19177,6 +19210,18 @@ Values for sorting package.
| <a id="packagetypeenumrubygems"></a>`RUBYGEMS` | Packages from the Rubygems package manager. | | <a id="packagetypeenumrubygems"></a>`RUBYGEMS` | Packages from the Rubygems package manager. |
| <a id="packagetypeenumterraform_module"></a>`TERRAFORM_MODULE` | Packages from the Terraform Module package manager. | | <a id="packagetypeenumterraform_module"></a>`TERRAFORM_MODULE` | Packages from the Terraform Module package manager. |
### `PackagesCleanupKeepDuplicatedPackageFilesEnum`
| Value | Description |
| ----- | ----------- |
| <a id="packagescleanupkeepduplicatedpackagefilesenumall_package_files"></a>`ALL_PACKAGE_FILES` | Value to keep all package files. |
| <a id="packagescleanupkeepduplicatedpackagefilesenumfifty_package_files"></a>`FIFTY_PACKAGE_FILES` | Value to keep 50 package files. |
| <a id="packagescleanupkeepduplicatedpackagefilesenumforty_package_files"></a>`FORTY_PACKAGE_FILES` | Value to keep 40 package files. |
| <a id="packagescleanupkeepduplicatedpackagefilesenumone_package_file"></a>`ONE_PACKAGE_FILE` | Value to keep 1 package files. |
| <a id="packagescleanupkeepduplicatedpackagefilesenumten_package_files"></a>`TEN_PACKAGE_FILES` | Value to keep 10 package files. |
| <a id="packagescleanupkeepduplicatedpackagefilesenumthirty_package_files"></a>`THIRTY_PACKAGE_FILES` | Value to keep 30 package files. |
| <a id="packagescleanupkeepduplicatedpackagefilesenumtwenty_package_files"></a>`TWENTY_PACKAGE_FILES` | Value to keep 20 package files. |
### `PipelineConfigSourceEnum` ### `PipelineConfigSourceEnum`
| Value | Description | | Value | Description |

View File

@ -6,7 +6,7 @@ info: To determine the technical writer assigned to the Stage/Group associated w
# Free user limit **(FREE SAAS)** # Free user limit **(FREE SAAS)**
In GitLab 15.1 (June 22, 2022) and later, namespaces in GitLab.com on the Free tier From September 15, 2022, namespaces in GitLab.com on the Free tier
will be limited to five (5) members per [namespace](group/index.md#namespaces). will be limited to five (5) members per [namespace](group/index.md#namespaces).
This limit applies to top-level groups and personal namespaces. This limit applies to top-level groups and personal namespaces.

View File

@ -7,6 +7,12 @@ module Gitlab
GITLAB_RAILS_SOURCE = 'gitlab-rails' GITLAB_RAILS_SOURCE = 'gitlab-rails'
def initialize(namespace: nil, project: nil, user: nil, **extra) def initialize(namespace: nil, project: nil, user: nil, **extra)
if Feature.enabled?(:standard_context_type_check)
check_argument_type(:namespace, namespace, [Namespace])
check_argument_type(:project, project, [Project, Integer])
check_argument_type(:user, user, [User, DeployToken])
end
@namespace = namespace @namespace = namespace
@plan = namespace&.actual_plan_name @plan = namespace&.actual_plan_name
@project = project @project = project
@ -54,6 +60,14 @@ module Gitlab
def project_id def project_id
project.is_a?(Integer) ? project : project&.id project.is_a?(Integer) ? project : project&.id
end end
def check_argument_type(argument_name, argument_value, allowed_classes)
return if argument_value.nil? || allowed_classes.any? { |allowed_class| argument_value.is_a?(allowed_class) }
exception = "Invalid argument type passed for #{argument_name}." \
" Should be one of #{allowed_classes.map(&:to_s)}"
Gitlab::ErrorTracking.track_and_raise_for_dev_exception(ArgumentError.new(exception))
end
end end
end end
end end

View File

@ -6829,7 +6829,7 @@ msgstr ""
msgid "CI Lint" msgid "CI Lint"
msgstr "" msgstr ""
msgid "CI configuration validated, including all configuration added with the %{codeStart}includes%{codeEnd} keyword. %{link}" msgid "CI configuration validated, including all configuration added with the %{codeStart}include%{codeEnd} keyword. %{link}"
msgstr "" msgstr ""
msgid "CI settings" msgid "CI settings"
@ -37451,6 +37451,9 @@ msgstr ""
msgid "Terraform|Copy Terraform init command" msgid "Terraform|Copy Terraform init command"
msgstr "" msgstr ""
msgid "Terraform|Deletion in progress"
msgstr ""
msgid "Terraform|Details" msgid "Terraform|Details"
msgstr "" msgstr ""
@ -37496,6 +37499,9 @@ msgstr ""
msgid "Terraform|Remove state file and versions" msgid "Terraform|Remove state file and versions"
msgstr "" msgstr ""
msgid "Terraform|Removed"
msgstr ""
msgid "Terraform|Removing" msgid "Terraform|Removing"
msgstr "" msgstr ""

View File

@ -2,10 +2,7 @@
module QA module QA
RSpec.describe 'Manage' do RSpec.describe 'Manage' do
describe 'Project transfer between groups', :reliable, quarantine: { describe 'Project transfer between groups', :reliable do
issue: 'https://gitlab.com/gitlab-org/gitlab/-/issues/359043',
type: :investigating
} do
let(:source_group) do let(:source_group) do
Resource::Group.fabricate_via_api! do |group| Resource::Group.fabricate_via_api! do |group|
group.path = "source-group-#{SecureRandom.hex(8)}" group.path = "source-group-#{SecureRandom.hex(8)}"

View File

@ -1,49 +1,70 @@
# Make sure to update the docs if this file moves. Docs URL: https://docs.gitlab.com/ee/development/migration_style_guide.html#when-to-use-the-helper-method # Make sure to update the docs if this file moves. Docs URL: https://docs.gitlab.com/ee/development/migration_style_guide.html#when-to-use-the-helper-method
Migration/UpdateLargeTable: Migration/UpdateLargeTable:
Enabled: true Enabled: true
HighTrafficTables: &high_traffic_tables # size in GB (>= 10 GB on GitLab.com as of 02/2020) and/or number of records HighTrafficTables: &high_traffic_tables # size in GB (>= 10 GB on GitLab.com as of 06/2022) and/or number of records
- :alert_management_alerts
- :approval_merge_request_rules_users
- :audit_events - :audit_events
- :ci_build_trace_sections - :authentication_events
- :ci_build_needs
- :ci_build_report_results
- :ci_builds - :ci_builds
- :ci_builds_metadata - :ci_builds_metadata
- :ci_build_trace_metadata
- :ci_job_artifacts - :ci_job_artifacts
- :ci_pipeline_variables - :ci_pipeline_messages
- :ci_pipelines - :ci_pipelines
- :ci_pipelines_config
- :ci_pipeline_variables
- :ci_stages - :ci_stages
- :deployments - :deployments
- :description_versions
- :error_tracking_error_events
- :events - :events
- :gitlab_subscriptions - :gitlab_subscriptions
- :gpg_signatures
- :issues - :issues
- :label_links
- :lfs_objects
- :lfs_objects_projects
- :members - :members
- :merge_request_cleanup_schedules
- :merge_request_diff_commits - :merge_request_diff_commits
- :merge_request_diff_files - :merge_request_diff_files
- :merge_request_diffs - :merge_request_diffs
- :merge_request_metrics - :merge_request_metrics
- :merge_requests - :merge_requests
- :namespace_settings
- :namespaces - :namespaces
- :namespace_settings
- :note_diff_files - :note_diff_files
- :notes - :notes
- :packages_package_files
- :project_authorizations - :project_authorizations
- :projects
- :project_ci_cd_settings - :project_ci_cd_settings
- :project_settings - :project_daily_statistics
- :project_features - :project_features
- :projects
- :project_settings
- :protected_branches - :protected_branches
- :push_event_payloads - :push_event_payloads
- :resource_label_events - :resource_label_events
- :resource_state_events
- :routes - :routes
- :security_findings - :security_findings
- :sent_notifications - :sent_notifications
- :system_note_metadata - :system_note_metadata
- :taggings - :taggings
- :todos - :todos
- :users - :uploads
- :user_preferences
- :user_details - :user_details
- :vulnerability_occurrences - :user_preferences
- :web_hook_logs - :users
- :vulnerabilities - :vulnerabilities
- :vulnerability_occurrence_identifiers
- :vulnerability_occurrence_pipelines
- :vulnerability_occurrences
- :vulnerability_reads
- :web_hook_logs
DeniedMethods: DeniedMethods:
- :change_column_type_concurrently - :change_column_type_concurrently
- :rename_column_concurrently - :rename_column_concurrently

View File

@ -1,55 +0,0 @@
# This script requires BOT_USER_ID, CUSTOM_SAST_RULES_BOT_PAT and CI_MERGE_REQUEST_IID variables to be set
echo "Processing vuln report"
# Preparing the message for the comment that will be posted by the bot
# Empty string if there are no findings
jq -crM '.vulnerabilities |
map( select( .identifiers[0].name | test( "glappsec_" ) ) |
"- `" + .location.file + "` line " + ( .location.start_line | tostring ) +
(
if .location.start_line = .location.end_line then ""
else ( " to " + ( .location.end_line | tostring ) ) end
) + ": " + .message
) |
sort |
if length > 0 then
{ body: ("The findings below have been detected based on the [AppSec custom Semgrep rules](https://gitlab.com/gitlab-com/gl-security/appsec/sast-custom-rules/) and need attention:\n\n" + join("\n") + "\n\n/cc @gitlab-com/gl-security/appsec") }
else
empty
end' gl-sast-report.json >findings.txt
echo "Resulting file:"
cat findings.txt
EXISTING_COMMENT_ID=$(curl "https://gitlab.com/api/v4/projects/$CI_PROJECT_ID/merge_requests/$CI_MERGE_REQUEST_IID/notes" \
--header "Private-Token: $CUSTOM_SAST_RULES_BOT_PAT" |
jq -crM 'map( select( .author.id == (env.BOT_USER_ID | tonumber) ) | .id ) | first')
echo "EXISTING_COMMENT_ID: $EXISTING_COMMENT_ID"
if [ "$EXISTING_COMMENT_ID" == "null" ]; then
if [ -s findings.txt ]; then
echo "No existing comment and there are findings: a new comment will be posted"
curl "https://gitlab.com/api/v4/projects/$CI_PROJECT_ID/merge_requests/$CI_MERGE_REQUEST_IID/notes" \
--header "Private-Token: $CUSTOM_SAST_RULES_BOT_PAT" \
--header 'Content-Type: application/json' \
--data '@findings.txt'
else
echo "No existing comment and no findings: nothing to do"
fi
else
if [ -s findings.txt ]; then
echo "There is an existing comment and there are findings: the existing comment will be updated"
curl --request PUT "https://gitlab.com/api/v4/projects/$CI_PROJECT_ID/merge_requests/$CI_MERGE_REQUEST_IID/notes/$EXISTING_COMMENT_ID" \
--header "Private-Token: $CUSTOM_SAST_RULES_BOT_PAT" \
--header 'Content-Type: application/json' \
--data '@findings.txt'
else
echo "There is an existing comment but no findings: the existing comment will be updated to mention everything is resolved"
curl --request PUT "https://gitlab.com/api/v4/projects/$CI_PROJECT_ID/merge_requests/$CI_MERGE_REQUEST_IID/notes/$EXISTING_COMMENT_ID" \
--header "Private-Token: $CUSTOM_SAST_RULES_BOT_PAT" \
--header 'Content-Type: application/json' \
--data '{"body":"All findings based on the [AppSec custom Semgrep rules](https://gitlab.com/gitlab-com/gl-security/appsec/sast-custom-rules/) have been resolved! :tada:"}'
fi
fi

View File

@ -11,7 +11,7 @@ RSpec.describe 'Profile > Personal Access Tokens', :js do
end end
def created_personal_access_token def created_personal_access_token
find("[data-testid='new-access-token'] input").value find_field('new-access-token').value
end end
def feed_token_description def feed_token_description

View File

@ -1,7 +1,7 @@
import { GlAlert } from '@gitlab/ui'; import { GlAlert } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import { nextTick } from 'vue'; import { nextTick } from 'vue';
import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
import { mountExtended } from 'helpers/vue_test_utils_helper';
import NewAccessTokenApp from '~/access_tokens/components/new_access_token_app.vue'; import NewAccessTokenApp from '~/access_tokens/components/new_access_token_app.vue';
import { createAlert, VARIANT_INFO } from '~/flash'; import { createAlert, VARIANT_INFO } from '~/flash';
import { __, sprintf } from '~/locale'; import { __, sprintf } from '~/locale';
@ -16,7 +16,7 @@ describe('~/access_tokens/components/new_access_token_app', () => {
const accessTokenType = 'personal access token'; const accessTokenType = 'personal access token';
const createComponent = (provide = { accessTokenType }) => { const createComponent = (provide = { accessTokenType }) => {
wrapper = shallowMount(NewAccessTokenApp, { wrapper = mountExtended(NewAccessTokenApp, {
provide, provide,
}); });
}; };
@ -64,17 +64,26 @@ describe('~/access_tokens/components/new_access_token_app', () => {
sprintf(__('Copy %{accessTokenType}'), { accessTokenType }), sprintf(__('Copy %{accessTokenType}'), { accessTokenType }),
); );
expect(InputCopyToggleVisibilityComponent.props('initialVisibility')).toBe(true); expect(InputCopyToggleVisibilityComponent.props('initialVisibility')).toBe(true);
expect(InputCopyToggleVisibilityComponent.props('inputClass')).toBe(
'qa-created-access-token',
);
expect(InputCopyToggleVisibilityComponent.props('qaSelector')).toBe(
'created_access_token_field',
);
expect(InputCopyToggleVisibilityComponent.attributes('label')).toBe( expect(InputCopyToggleVisibilityComponent.attributes('label')).toBe(
sprintf(__('Your new %{accessTokenType}'), { accessTokenType }), sprintf(__('Your new %{accessTokenType}'), { accessTokenType }),
); );
}); });
it('input field should contain QA-related selectors', async () => {
const newToken = '12345';
await triggerSuccess(newToken);
expect(wrapper.findComponent(GlAlert).exists()).toBe(false);
const inputAttributes = wrapper
.findByLabelText(sprintf(__('Your new %{accessTokenType}'), { accessTokenType }))
.attributes();
expect(inputAttributes).toMatchObject({
class: expect.stringContaining('qa-created-access-token'),
'data-qa-selector': 'created_access_token_field',
});
});
it('should render an info alert', async () => { it('should render an info alert', async () => {
await triggerSuccess(); await triggerSuccess();

View File

@ -1,4 +1,4 @@
import { GlDropdown, GlDropdownItem, GlDropdownForm } from '@gitlab/ui'; import { GlModal, GlDropdown, GlDropdownItem, GlDropdownForm } from '@gitlab/ui';
import { mount, shallowMount, createWrapper } from '@vue/test-utils'; import { mount, shallowMount, createWrapper } from '@vue/test-utils';
import Vue, { nextTick } from 'vue'; import Vue, { nextTick } from 'vue';
@ -24,6 +24,8 @@ import {
const mockToken = '0123456789'; const mockToken = '0123456789';
const maskToken = '**********'; const maskToken = '**********';
Vue.use(VueApollo);
describe('RegistrationDropdown', () => { describe('RegistrationDropdown', () => {
let wrapper; let wrapper;
@ -32,9 +34,10 @@ describe('RegistrationDropdown', () => {
const findRegistrationInstructionsDropdownItem = () => wrapper.findComponent(GlDropdownItem); const findRegistrationInstructionsDropdownItem = () => wrapper.findComponent(GlDropdownItem);
const findTokenDropdownItem = () => wrapper.findComponent(GlDropdownForm); const findTokenDropdownItem = () => wrapper.findComponent(GlDropdownForm);
const findRegistrationToken = () => wrapper.findComponent(RegistrationToken); const findRegistrationToken = () => wrapper.findComponent(RegistrationToken);
const findRegistrationTokenInput = () => wrapper.findByTestId('token-value').find('input'); const findRegistrationTokenInput = () => wrapper.find('[name=token-value]');
const findTokenResetDropdownItem = () => const findTokenResetDropdownItem = () =>
wrapper.findComponent(RegistrationTokenResetDropdownItem); wrapper.findComponent(RegistrationTokenResetDropdownItem);
const findModal = () => wrapper.findComponent(GlModal);
const findModalContent = () => const findModalContent = () =>
createWrapper(document.body) createWrapper(document.body)
.find('[data-testid="runner-instructions-modal"]') .find('[data-testid="runner-instructions-modal"]')
@ -43,6 +46,8 @@ describe('RegistrationDropdown', () => {
const openModal = async () => { const openModal = async () => {
await findRegistrationInstructionsDropdownItem().trigger('click'); await findRegistrationInstructionsDropdownItem().trigger('click');
findModal().vm.$emit('shown');
await waitForPromises(); await waitForPromises();
}; };
@ -60,8 +65,6 @@ describe('RegistrationDropdown', () => {
}; };
const createComponentWithModal = () => { const createComponentWithModal = () => {
Vue.use(VueApollo);
const requestHandlers = [ const requestHandlers = [
[getRunnerPlatformsQuery, jest.fn().mockResolvedValue(mockGraphqlRunnerPlatforms)], [getRunnerPlatformsQuery, jest.fn().mockResolvedValue(mockGraphqlRunnerPlatforms)],
[getRunnerSetupInstructionsQuery, jest.fn().mockResolvedValue(mockGraphqlInstructions)], [getRunnerSetupInstructionsQuery, jest.fn().mockResolvedValue(mockGraphqlInstructions)],

View File

@ -2,6 +2,8 @@ import { GlBadge, GlLoadingIcon, GlTooltip } from '@gitlab/ui';
import { mount } from '@vue/test-utils'; import { mount } from '@vue/test-utils';
import { nextTick } from 'vue'; import { nextTick } from 'vue';
import { useFakeDate } from 'helpers/fake_date'; import { useFakeDate } from 'helpers/fake_date';
import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import StatesTable from '~/terraform/components/states_table.vue'; import StatesTable from '~/terraform/components/states_table.vue';
import StateActions from '~/terraform/components/states_table_actions.vue'; import StateActions from '~/terraform/components/states_table_actions.vue';
@ -104,11 +106,30 @@ describe('StatesTable', () => {
updatedAt: '2020-10-10T00:00:00Z', updatedAt: '2020-10-10T00:00:00Z',
latestVersion: null, latestVersion: null,
}, },
{
_showDetails: false,
errorMessages: [],
name: 'state-6',
loadingLock: false,
loadingRemove: false,
lockedAt: null,
lockedByUser: null,
updatedAt: '2020-10-10T00:00:00Z',
deletedAt: '2022-02-02T00:00:00Z',
latestVersion: null,
},
], ],
}; };
const createComponent = async (propsData = defaultProps) => { const createComponent = async (propsData = defaultProps) => {
wrapper = mount(StatesTable, { propsData }); wrapper = extendedWrapper(
mount(StatesTable, {
propsData,
directives: {
GlTooltip: createMockDirective(),
},
}),
);
await nextTick(); await nextTick();
}; };
@ -124,27 +145,28 @@ describe('StatesTable', () => {
}); });
it.each` it.each`
name | toolTipText | locked | loading | lineNumber name | toolTipText | hasBadge | loading | lineNumber
${'state-1'} | ${'Locked by user-1 2 days ago'} | ${true} | ${false} | ${0} ${'state-1'} | ${'Locked by user-1 2 days ago'} | ${true} | ${false} | ${0}
${'state-2'} | ${'Locking state'} | ${false} | ${true} | ${1} ${'state-2'} | ${'Locking state'} | ${false} | ${true} | ${1}
${'state-3'} | ${'Unlocking state'} | ${false} | ${true} | ${2} ${'state-3'} | ${'Unlocking state'} | ${false} | ${true} | ${2}
${'state-4'} | ${'Locked by Unknown User 5 days ago'} | ${true} | ${false} | ${3} ${'state-4'} | ${'Locked by Unknown User 5 days ago'} | ${true} | ${false} | ${3}
${'state-5'} | ${'Removing'} | ${false} | ${true} | ${4} ${'state-5'} | ${'Removing'} | ${false} | ${true} | ${4}
${'state-6'} | ${'Deletion in progress'} | ${true} | ${false} | ${5}
`( `(
'displays the name and locked information "$name" for line "$lineNumber"', 'displays the name and locked information "$name" for line "$lineNumber"',
({ name, toolTipText, locked, loading, lineNumber }) => { ({ name, toolTipText, hasBadge, loading, lineNumber }) => {
const states = wrapper.findAll('[data-testid="terraform-states-table-name"]'); const states = wrapper.findAll('[data-testid="terraform-states-table-name"]');
const state = states.at(lineNumber); const state = states.at(lineNumber);
const toolTip = state.find(GlTooltip);
expect(state.text()).toContain(name); expect(state.text()).toContain(name);
expect(state.find(GlBadge).exists()).toBe(locked); expect(state.find(GlBadge).exists()).toBe(hasBadge);
expect(state.find(GlLoadingIcon).exists()).toBe(loading); expect(state.find(GlLoadingIcon).exists()).toBe(loading);
expect(toolTip.exists()).toBe(locked);
if (locked) { if (hasBadge) {
expect(toolTip.text()).toMatchInterpolatedText(toolTipText); const badge = wrapper.findByTestId(`state-badge-${name}`);
expect(getBinding(badge.element, 'gl-tooltip')).toBeDefined();
expect(badge.attributes('title')).toMatchInterpolatedText(toolTipText);
} }
}, },
); );

View File

@ -66,7 +66,7 @@ describe('InputCopyToggleVisibility', () => {
}); });
it('displays value as hidden', () => { it('displays value as hidden', () => {
expect(findFormInputGroup().props('value')).toBe('********************'); expect(findFormInput().element.value).toBe('********************');
}); });
it('saves actual value to clipboard when manually copied', () => { it('saves actual value to clipboard when manually copied', () => {
@ -107,7 +107,7 @@ describe('InputCopyToggleVisibility', () => {
}); });
it('displays value', () => { it('displays value', () => {
expect(findFormInputGroup().props('value')).toBe(valueProp); expect(findFormInput().element.value).toBe(valueProp);
}); });
it('renders a hide button', () => { it('renders a hide button', () => {
@ -159,25 +159,52 @@ describe('InputCopyToggleVisibility', () => {
}); });
it('displays value as hidden with 20 asterisks', () => { it('displays value as hidden with 20 asterisks', () => {
expect(findFormInputGroup().props('value')).toBe('********************'); expect(findFormInput().element.value).toBe('********************');
}); });
}); });
describe('when `initialVisibility` prop is `true`', () => { describe('when `initialVisibility` prop is `true`', () => {
const label = 'My label';
beforeEach(() => { beforeEach(() => {
createComponent({ createComponent({
propsData: { propsData: {
value: valueProp, value: valueProp,
initialVisibility: true, initialVisibility: true,
label,
'label-for': 'my-input',
formInputGroupProps: {
id: 'my-input',
},
}, },
}); });
}); });
it('displays value', () => { it('displays value', () => {
expect(findFormInputGroup().props('value')).toBe(valueProp); expect(findFormInput().element.value).toBe(valueProp);
}); });
itDoesNotModifyCopyEvent(); itDoesNotModifyCopyEvent();
describe('when input is clicked', () => {
it('selects input value', async () => {
const mockSelect = jest.fn();
wrapper.vm.$refs.input.$el.select = mockSelect;
await wrapper.findByLabelText(label).trigger('click');
expect(mockSelect).toHaveBeenCalled();
});
});
describe('when label is clicked', () => {
it('selects input value', async () => {
const mockSelect = jest.fn();
wrapper.vm.$refs.input.$el.select = mockSelect;
await wrapper.find('label').trigger('click');
expect(mockSelect).toHaveBeenCalled();
});
});
}); });
describe('when `showToggleVisibilityButton` is `false`', () => { describe('when `showToggleVisibilityButton` is `false`', () => {
@ -196,7 +223,7 @@ describe('InputCopyToggleVisibility', () => {
}); });
it('displays value', () => { it('displays value', () => {
expect(findFormInputGroup().props('value')).toBe(valueProp); expect(findFormInput().element.value).toBe(valueProp);
}); });
itDoesNotModifyCopyEvent(); itDoesNotModifyCopyEvent();
@ -216,16 +243,30 @@ describe('InputCopyToggleVisibility', () => {
}); });
}); });
it('passes `formInputGroupProps` prop to `GlFormInputGroup`', () => { it('passes `formInputGroupProps` prop only to the input', () => {
createComponent({ createComponent({
propsData: { propsData: {
formInputGroupProps: { formInputGroupProps: {
label: 'Foo bar', name: 'Foo bar',
'data-qa-selector': 'Foo bar',
class: 'Foo bar',
id: 'Foo bar',
}, },
}, },
}); });
expect(findFormInputGroup().props('label')).toBe('Foo bar'); expect(findFormInput().attributes()).toMatchObject({
name: 'Foo bar',
'data-qa-selector': 'Foo bar',
class: expect.stringContaining('Foo bar'),
id: 'Foo bar',
});
const attributesInputGroup = findFormInputGroup().attributes();
expect(attributesInputGroup.name).toBeUndefined();
expect(attributesInputGroup['data-qa-selector']).toBeUndefined();
expect(attributesInputGroup.class).not.toContain('Foo bar');
expect(attributesInputGroup.id).toBeUndefined();
}); });
it('passes `copyButtonTitle` prop to `ClipboardButton`', () => { it('passes `copyButtonTitle` prop to `ClipboardButton`', () => {
@ -248,32 +289,4 @@ describe('InputCopyToggleVisibility', () => {
expect(wrapper.findByText(description).exists()).toBe(true); expect(wrapper.findByText(description).exists()).toBe(true);
}); });
it('passes `inputClass` prop to `GlFormInputGroup`', () => {
createComponent();
expect(findFormInputGroup().props('inputClass')).toBe('gl-font-monospace! gl-cursor-default!');
wrapper.destroy();
createComponent({
propsData: {
inputClass: 'Foo bar',
},
});
expect(findFormInputGroup().props('inputClass')).toBe(
'gl-font-monospace! gl-cursor-default! Foo bar',
);
});
it('passes `qaSelector` prop as an `data-qa-selector` attribute to `GlFormInputGroup`', () => {
createComponent();
expect(findFormInputGroup().attributes('data-qa-selector')).toBeUndefined();
wrapper.destroy();
createComponent({
propsData: {
qaSelector: 'Foo bar',
},
});
expect(findFormInputGroup().attributes('data-qa-selector')).toBe('Foo bar');
});
}); });

View File

@ -48,13 +48,12 @@ describe('RunnerInstructionsModal component', () => {
const findModal = () => wrapper.findComponent(GlModal); const findModal = () => wrapper.findComponent(GlModal);
const findPlatformButtonGroup = () => wrapper.findByTestId('platform-buttons'); const findPlatformButtonGroup = () => wrapper.findByTestId('platform-buttons');
const findPlatformButtons = () => findPlatformButtonGroup().findAllComponents(GlButton); const findPlatformButtons = () => findPlatformButtonGroup().findAllComponents(GlButton);
const findOsxPlatformButton = () => wrapper.find({ ref: 'osx' });
const findArchitectureDropdownItems = () => wrapper.findAllByTestId('architecture-dropdown-item'); const findArchitectureDropdownItems = () => wrapper.findAllByTestId('architecture-dropdown-item');
const findBinaryDownloadButton = () => wrapper.findByTestId('binary-download-button'); const findBinaryDownloadButton = () => wrapper.findByTestId('binary-download-button');
const findBinaryInstructions = () => wrapper.findByTestId('binary-instructions'); const findBinaryInstructions = () => wrapper.findByTestId('binary-instructions');
const findRegisterCommand = () => wrapper.findByTestId('register-command'); const findRegisterCommand = () => wrapper.findByTestId('register-command');
const createComponent = ({ props, ...options } = {}) => { const createComponent = ({ props, shown = true, ...options } = {}) => {
const requestHandlers = [ const requestHandlers = [
[getRunnerPlatformsQuery, runnerPlatformsHandler], [getRunnerPlatformsQuery, runnerPlatformsHandler],
[getRunnerSetupInstructionsQuery, runnerSetupInstructionsHandler], [getRunnerSetupInstructionsQuery, runnerSetupInstructionsHandler],
@ -73,184 +72,202 @@ describe('RunnerInstructionsModal component', () => {
...options, ...options,
}), }),
); );
// trigger open modal
if (shown) {
findModal().vm.$emit('shown');
}
}; };
beforeEach(async () => { beforeEach(async () => {
runnerPlatformsHandler = jest.fn().mockResolvedValue(mockGraphqlRunnerPlatforms); runnerPlatformsHandler = jest.fn().mockResolvedValue(mockGraphqlRunnerPlatforms);
runnerSetupInstructionsHandler = jest.fn().mockResolvedValue(mockGraphqlInstructions); runnerSetupInstructionsHandler = jest.fn().mockResolvedValue(mockGraphqlInstructions);
createComponent();
await waitForPromises();
}); });
afterEach(() => { afterEach(() => {
wrapper.destroy(); wrapper.destroy();
}); });
it('should not show alert', () => { describe('when the modal is shown', () => {
expect(findAlert().exists()).toBe(false); beforeEach(async () => {
}); createComponent();
await waitForPromises();
it('should contain a number of platforms buttons', () => {
expect(runnerPlatformsHandler).toHaveBeenCalledWith({});
const buttons = findPlatformButtons();
expect(buttons).toHaveLength(mockGraphqlRunnerPlatforms.data.runnerPlatforms.nodes.length);
});
it('should contain a number of dropdown items for the architecture options', () => {
expect(findArchitectureDropdownItems()).toHaveLength(
mockGraphqlRunnerPlatforms.data.runnerPlatforms.nodes[0].architectures.nodes.length,
);
});
describe('should display default instructions', () => {
const { installInstructions, registerInstructions } = mockGraphqlInstructions.data.runnerSetup;
it('runner instructions are requested', () => {
expect(runnerSetupInstructionsHandler).toHaveBeenCalledWith({
platform: 'linux',
architecture: 'amd64',
});
}); });
it('binary instructions are shown', async () => { it('should not show alert', async () => {
await waitForPromises(); expect(findAlert().exists()).toBe(false);
const instructions = findBinaryInstructions().text();
expect(instructions).toBe(installInstructions);
}); });
it('register command is shown with a replaced token', async () => { it('should contain a number of platforms buttons', () => {
await waitForPromises(); expect(runnerPlatformsHandler).toHaveBeenCalledWith({});
const instructions = findRegisterCommand().text();
expect(instructions).toBe( const buttons = findPlatformButtons();
'sudo gitlab-runner register --url http://gdk.test:3000/ --registration-token MY_TOKEN',
expect(buttons).toHaveLength(mockGraphqlRunnerPlatforms.data.runnerPlatforms.nodes.length);
});
it('should contain a number of dropdown items for the architecture options', () => {
expect(findArchitectureDropdownItems()).toHaveLength(
mockGraphqlRunnerPlatforms.data.runnerPlatforms.nodes[0].architectures.nodes.length,
); );
}); });
describe('when a register token is not shown', () => { describe('should display default instructions', () => {
beforeEach(async () => { const {
createComponent({ props: { registrationToken: undefined } }); installInstructions,
await waitForPromises(); registerInstructions,
}); } = mockGraphqlInstructions.data.runnerSetup;
it('register command is shown without a defined registration token', () => { it('runner instructions are requested', () => {
const instructions = findRegisterCommand().text();
expect(instructions).toBe(registerInstructions);
});
});
describe('when the modal is shown', () => {
it('sets the focus on the selected platform', () => {
findPlatformButtons().at(0).element.focus = jest.fn();
findModal().vm.$emit('shown');
expect(findPlatformButtons().at(0).element.focus).toHaveBeenCalled();
});
});
describe('when providing a defaultPlatformName', () => {
beforeEach(async () => {
createComponent({ props: { defaultPlatformName: 'osx' } });
await waitForPromises();
});
it('runner instructions for the default selected platform are requested', () => {
expect(runnerSetupInstructionsHandler).toHaveBeenCalledWith({ expect(runnerSetupInstructionsHandler).toHaveBeenCalledWith({
platform: 'osx', platform: 'linux',
architecture: 'amd64', architecture: 'amd64',
}); });
}); });
it('sets the focus on the default selected platform', () => { it('binary instructions are shown', async () => {
findOsxPlatformButton().element.focus = jest.fn(); const instructions = findBinaryInstructions().text();
findModal().vm.$emit('shown'); expect(instructions).toBe(installInstructions);
});
expect(findOsxPlatformButton().element.focus).toHaveBeenCalled(); it('register command is shown with a replaced token', async () => {
const command = findRegisterCommand().text();
expect(command).toBe(
'sudo gitlab-runner register --url http://gdk.test:3000/ --registration-token MY_TOKEN',
);
});
describe('when a register token is not shown', () => {
beforeEach(async () => {
createComponent({ props: { registrationToken: undefined } });
await waitForPromises();
});
it('register command is shown without a defined registration token', () => {
const instructions = findRegisterCommand().text();
expect(instructions).toBe(registerInstructions);
});
});
describe('when providing a defaultPlatformName', () => {
beforeEach(async () => {
createComponent({ props: { defaultPlatformName: 'osx' } });
await waitForPromises();
});
it('runner instructions for the default selected platform are requested', () => {
expect(runnerSetupInstructionsHandler).toHaveBeenCalledWith({
platform: 'osx',
architecture: 'amd64',
});
});
it('sets the focus on the default selected platform', () => {
const findOsxPlatformButton = () => wrapper.find({ ref: 'osx' });
findOsxPlatformButton().element.focus = jest.fn();
findModal().vm.$emit('shown');
expect(findOsxPlatformButton().element.focus).toHaveBeenCalled();
});
});
});
describe('after a platform and architecture are selected', () => {
const windowsIndex = 2;
const { installInstructions } = mockGraphqlInstructionsWindows.data.runnerSetup;
beforeEach(async () => {
runnerSetupInstructionsHandler.mockResolvedValue(mockGraphqlInstructionsWindows);
findPlatformButtons().at(windowsIndex).vm.$emit('click');
await waitForPromises();
});
it('runner instructions are requested', () => {
expect(runnerSetupInstructionsHandler).toHaveBeenLastCalledWith({
platform: 'windows',
architecture: 'amd64',
});
});
it('architecture download link is updated', () => {
const architectures =
mockGraphqlRunnerPlatforms.data.runnerPlatforms.nodes[windowsIndex].architectures.nodes;
expect(findBinaryDownloadButton().attributes('href')).toBe(
architectures[0].downloadLocation,
);
});
it('other binary instructions are shown', () => {
const instructions = findBinaryInstructions().text();
expect(instructions).toBe(installInstructions);
});
it('register command is shown', () => {
const command = findRegisterCommand().text();
expect(command).toBe(
'./gitlab-runner.exe register --url http://gdk.test:3000/ --registration-token MY_TOKEN',
);
});
it('runner instructions are requested with another architecture', async () => {
findArchitectureDropdownItems().at(1).vm.$emit('click');
await waitForPromises();
expect(runnerSetupInstructionsHandler).toHaveBeenLastCalledWith({
platform: 'windows',
architecture: '386',
});
});
});
describe('when the modal resizes', () => {
it('to an xs viewport', async () => {
MockResizeObserver.mockResize('xs');
await nextTick();
expect(findPlatformButtonGroup().attributes('vertical')).toBeTruthy();
});
it('to a non-xs viewport', async () => {
MockResizeObserver.mockResize('sm');
await nextTick();
expect(findPlatformButtonGroup().props('vertical')).toBeFalsy();
}); });
}); });
}); });
describe('after a platform and architecture are selected', () => { describe('when the modal is not shown', () => {
const windowsIndex = 2;
const { installInstructions } = mockGraphqlInstructionsWindows.data.runnerSetup;
beforeEach(async () => { beforeEach(async () => {
runnerSetupInstructionsHandler.mockResolvedValue(mockGraphqlInstructionsWindows); createComponent({ shown: false });
findPlatformButtons().at(windowsIndex).vm.$emit('click');
await waitForPromises(); await waitForPromises();
}); });
it('runner instructions are requested', () => { it('does not fetch instructions', () => {
expect(runnerSetupInstructionsHandler).toHaveBeenLastCalledWith({ expect(runnerPlatformsHandler).not.toHaveBeenCalled();
platform: 'windows', expect(runnerSetupInstructionsHandler).not.toHaveBeenCalled();
architecture: 'amd64',
});
});
it('architecture download link is updated', () => {
const architectures =
mockGraphqlRunnerPlatforms.data.runnerPlatforms.nodes[windowsIndex].architectures.nodes;
expect(findBinaryDownloadButton().attributes('href')).toBe(architectures[0].downloadLocation);
});
it('other binary instructions are shown', () => {
const instructions = findBinaryInstructions().text();
expect(instructions).toBe(installInstructions);
});
it('register command is shown', () => {
const command = findRegisterCommand().text();
expect(command).toBe(
'./gitlab-runner.exe register --url http://gdk.test:3000/ --registration-token MY_TOKEN',
);
});
it('runner instructions are requested with another architecture', async () => {
findArchitectureDropdownItems().at(1).vm.$emit('click');
await waitForPromises();
expect(runnerSetupInstructionsHandler).toHaveBeenLastCalledWith({
platform: 'windows',
architecture: '386',
});
});
});
describe('when the modal resizes', () => {
it('to an xs viewport', async () => {
MockResizeObserver.mockResize('xs');
await nextTick();
expect(findPlatformButtonGroup().attributes('vertical')).toBeTruthy();
});
it('to a non-xs viewport', async () => {
MockResizeObserver.mockResize('sm');
await nextTick();
expect(findPlatformButtonGroup().props('vertical')).toBeFalsy();
}); });
}); });
describe('when apollo is loading', () => { describe('when apollo is loading', () => {
it('should show a skeleton loader', async () => { beforeEach(() => {
createComponent(); createComponent();
});
it('should show a skeleton loader', async () => {
expect(findSkeletonLoader().exists()).toBe(true); expect(findSkeletonLoader().exists()).toBe(true);
expect(findGlLoadingIcon().exists()).toBe(false); expect(findGlLoadingIcon().exists()).toBe(false);
await nextTick(); // wait on fetch of both `platforms` and `instructions`
jest.runOnlyPendingTimers();
await nextTick(); await nextTick();
await nextTick(); await nextTick();
@ -258,7 +275,6 @@ describe('RunnerInstructionsModal component', () => {
}); });
it('once loaded, should not show a loading state', async () => { it('once loaded, should not show a loading state', async () => {
createComponent();
await waitForPromises(); await waitForPromises();
expect(findSkeletonLoader().exists()).toBe(false); expect(findSkeletonLoader().exists()).toBe(false);
@ -271,7 +287,6 @@ describe('RunnerInstructionsModal component', () => {
runnerSetupInstructionsHandler.mockRejectedValue(); runnerSetupInstructionsHandler.mockRejectedValue();
createComponent(); createComponent();
await waitForPromises(); await waitForPromises();
}); });
@ -303,6 +318,7 @@ describe('RunnerInstructionsModal component', () => {
mockShow = jest.fn(); mockShow = jest.fn();
createComponent({ createComponent({
shown: false,
stubs: { stubs: {
GlModal: getGlModalStub({ show: mockShow }), GlModal: getGlModalStub({ show: mockShow }),
}, },

View File

@ -1,6 +1,5 @@
import { shallowMount } from '@vue/test-utils'; import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
import { nextTick } from 'vue'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import RunnerInstructions from '~/vue_shared/components/runner_instructions/runner_instructions.vue'; import RunnerInstructions from '~/vue_shared/components/runner_instructions/runner_instructions.vue';
import RunnerInstructionsModal from '~/vue_shared/components/runner_instructions/runner_instructions_modal.vue'; import RunnerInstructionsModal from '~/vue_shared/components/runner_instructions/runner_instructions_modal.vue';
@ -11,7 +10,11 @@ describe('RunnerInstructions component', () => {
const findModal = () => wrapper.findComponent(RunnerInstructionsModal); const findModal = () => wrapper.findComponent(RunnerInstructionsModal);
const createComponent = () => { const createComponent = () => {
wrapper = extendedWrapper(shallowMount(RunnerInstructions)); wrapper = shallowMountExtended(RunnerInstructions, {
directives: {
GlModal: createMockDirective(),
},
});
}; };
beforeEach(() => { beforeEach(() => {
@ -23,19 +26,12 @@ describe('RunnerInstructions component', () => {
}); });
it('should show the "Show runner installation instructions" button', () => { it('should show the "Show runner installation instructions" button', () => {
expect(findModalButton().exists()).toBe(true);
expect(findModalButton().text()).toBe('Show runner installation instructions'); expect(findModalButton().text()).toBe('Show runner installation instructions');
}); });
it('should not render the modal once mounted', () => { it('should render the modal', () => {
expect(findModal().exists()).toBe(false); const modalId = getBinding(findModal().element, 'gl-modal');
});
it('should render the modal once clicked', async () => { expect(findModalButton().attributes('modal-id')).toBe(modalId);
findModalButton().vm.$emit('click');
await nextTick();
expect(findModal().exists()).toBe(true);
}); });
}); });

View File

@ -0,0 +1,15 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe GitlabSchema.types['PackagesCleanupKeepDuplicatedPackageFilesEnum'] do
it 'exposes all options' do
expect(described_class.values.keys)
.to contain_exactly(*Types::Packages::Cleanup::KeepDuplicatedPackageFilesEnum::OPTIONS_MAPPING.values)
end
it 'uses all possible options from model' do
all_options = Packages::Cleanup::Policy::KEEP_N_DUPLICATED_PACKAGE_FILES_VALUES
expect(described_class::OPTIONS_MAPPING.keys).to contain_exactly(*all_options)
end
end

View File

@ -0,0 +1,26 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe GitlabSchema.types['PackagesCleanupPolicy'] do
specify { expect(described_class.graphql_name).to eq('PackagesCleanupPolicy') }
specify do
expect(described_class.description)
.to eq('A packages cleanup policy designed to keep only packages and packages assets that matter most')
end
specify { expect(described_class).to require_graphql_authorizations(:admin_package) }
describe 'keep_n_duplicated_package_files' do
subject { described_class.fields['keepNDuplicatedPackageFiles'] }
it { is_expected.to have_non_null_graphql_type(Types::Packages::Cleanup::KeepDuplicatedPackageFilesEnum) }
end
describe 'next_run_at' do
subject { described_class.fields['nextRunAt'] }
it { is_expected.to have_nullable_graphql_type(Types::TimeType) }
end
end

View File

@ -36,7 +36,7 @@ RSpec.describe GitlabSchema.types['Project'] do
pipeline_analytics squash_read_only sast_ci_configuration pipeline_analytics squash_read_only sast_ci_configuration
cluster_agent cluster_agents agent_configurations cluster_agent cluster_agents agent_configurations
ci_template timelogs merge_commit_template squash_commit_template work_item_types ci_template timelogs merge_commit_template squash_commit_template work_item_types
recent_issue_boards ci_config_path_or_default recent_issue_boards ci_config_path_or_default packages_cleanup_policy
] ]
expect(described_class).to include_graphql_fields(*expected_fields) expect(described_class).to include_graphql_fields(*expected_fields)
@ -421,6 +421,12 @@ RSpec.describe GitlabSchema.types['Project'] do
it { is_expected.to have_graphql_type(Types::ContainerExpirationPolicyType) } it { is_expected.to have_graphql_type(Types::ContainerExpirationPolicyType) }
end end
describe 'packages cleanup policy field' do
subject { described_class.fields['packagesCleanupPolicy'] }
it { is_expected.to have_graphql_type(Types::Packages::Cleanup::PolicyType) }
end
describe 'terraform state field' do describe 'terraform state field' do
subject { described_class.fields['terraformState'] } subject { described_class.fields['terraformState'] }

View File

@ -7,7 +7,7 @@ RSpec.describe GitlabSchema.types['TerraformState'] do
it { expect(described_class).to require_graphql_authorizations(:read_terraform_state) } it { expect(described_class).to require_graphql_authorizations(:read_terraform_state) }
describe 'fields' do describe 'fields' do
let(:fields) { %i[id name locked_by_user locked_at latest_version created_at updated_at] } let(:fields) { %i[id name locked_by_user locked_at latest_version created_at updated_at deleted_at] }
it { expect(described_class).to have_graphql_fields(fields) } it { expect(described_class).to have_graphql_fields(fields) }
@ -17,6 +17,7 @@ RSpec.describe GitlabSchema.types['TerraformState'] do
it { expect(described_class.fields['lockedAt'].type).not_to be_non_null } it { expect(described_class.fields['lockedAt'].type).not_to be_non_null }
it { expect(described_class.fields['createdAt'].type).to be_non_null } it { expect(described_class.fields['createdAt'].type).to be_non_null }
it { expect(described_class.fields['updatedAt'].type).to be_non_null } it { expect(described_class.fields['updatedAt'].type).to be_non_null }
it { expect(described_class.fields['deletedAt'].type).not_to be_non_null }
it { expect(described_class.fields['latestVersion'].type).not_to be_non_null } it { expect(described_class.fields['latestVersion'].type).not_to be_non_null }
it { expect(described_class.fields['latestVersion'].complexity).to eq(3) } it { expect(described_class.fields['latestVersion'].complexity).to eq(3) }

View File

@ -92,6 +92,34 @@ RSpec.describe Gitlab::Tracking::StandardContext do
end end
end end
context 'with incorrect argument type' do
context 'when standard_context_type_check FF is disabled' do
before do
stub_feature_flags(standard_context_type_check: false)
end
subject { described_class.new(project: create(:group)) }
it 'does not call `track_and_raise_for_dev_exception`' do
expect(Gitlab::ErrorTracking).not_to receive(:track_and_raise_for_dev_exception)
snowplow_context
end
end
context 'when standard_context_type_check FF is enabled' do
before do
stub_feature_flags(standard_context_type_check: true)
end
subject { described_class.new(project: create(:group)) }
it 'does call `track_and_raise_for_dev_exception`' do
expect(Gitlab::ErrorTracking).to receive(:track_and_raise_for_dev_exception)
snowplow_context
end
end
end
it 'contains user id' do it 'contains user id' do
expect(snowplow_context.to_json[:data].keys).to include(:user_id) expect(snowplow_context.to_json[:data].keys).to include(:user_id)
end end

View File

@ -13,7 +13,7 @@ RSpec.describe Packages::Cleanup::Policy, type: :model do
is_expected is_expected
.to validate_inclusion_of(:keep_n_duplicated_package_files) .to validate_inclusion_of(:keep_n_duplicated_package_files)
.in_array(described_class::KEEP_N_DUPLICATED_PACKAGE_FILES_VALUES) .in_array(described_class::KEEP_N_DUPLICATED_PACKAGE_FILES_VALUES)
.with_message('keep_n_duplicated_package_files is invalid') .with_message('is invalid')
end end
end end

View File

@ -1356,6 +1356,36 @@ RSpec.describe ProjectPolicy do
end end
end end
describe 'admin_package' do
context 'with admin' do
let(:current_user) { admin }
context 'when admin mode enabled', :enable_admin_mode do
it { is_expected.to be_allowed(:admin_package) }
end
context 'when admin mode disabled' do
it { is_expected.to be_disallowed(:admin_package) }
end
end
%i[owner maintainer].each do |role|
context "with #{role}" do
let(:current_user) { public_send(role) }
it { is_expected.to be_allowed(:admin_package) }
end
end
%i[developer reporter guest non_member anonymous].each do |role|
context "with #{role}" do
let(:current_user) { public_send(role) }
it { is_expected.to be_disallowed(:admin_package) }
end
end
end
describe 'read_feature_flag' do describe 'read_feature_flag' do
subject { described_class.new(current_user, project) } subject { described_class.new(current_user, project) }

View File

@ -0,0 +1,109 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe 'Updating the packages cleanup policy' do
include GraphqlHelpers
using RSpec::Parameterized::TableSyntax
let_it_be(:project, reload: true) { create(:project) }
let_it_be(:user) { create(:user) }
let(:params) do
{
project_path: project.full_path,
keep_n_duplicated_package_files: 'TWENTY_PACKAGE_FILES'
}
end
let(:mutation) do
graphql_mutation(:update_packages_cleanup_policy, params,
<<~QUERY
packagesCleanupPolicy {
keepNDuplicatedPackageFiles
nextRunAt
}
errors
QUERY
)
end
let(:mutation_response) { graphql_mutation_response(:update_packages_cleanup_policy) }
let(:packages_cleanup_policy_response) { mutation_response['packagesCleanupPolicy'] }
shared_examples 'accepting the mutation request and updates the existing policy' do
it 'returns the updated packages cleanup policy' do
expect { subject }.not_to change { ::Packages::Cleanup::Policy.count }
expect(project.packages_cleanup_policy.keep_n_duplicated_package_files).to eq('20')
expect_graphql_errors_to_be_empty
expect(packages_cleanup_policy_response['keepNDuplicatedPackageFiles'])
.to eq(params[:keep_n_duplicated_package_files])
expect(packages_cleanup_policy_response['nextRunAt']).not_to eq(nil)
end
end
shared_examples 'accepting the mutation request and creates a policy' do
it 'returns the created packages cleanup policy' do
expect { subject }.to change { ::Packages::Cleanup::Policy.count }.by(1)
expect(project.packages_cleanup_policy.keep_n_duplicated_package_files).to eq('20')
expect_graphql_errors_to_be_empty
expect(packages_cleanup_policy_response['keepNDuplicatedPackageFiles'])
.to eq(params[:keep_n_duplicated_package_files])
expect(packages_cleanup_policy_response['nextRunAt']).not_to eq(nil)
end
end
shared_examples 'denying the mutation request' do
it 'returns an error' do
expect { subject }.not_to change { ::Packages::Cleanup::Policy.count }
expect(project.packages_cleanup_policy.keep_n_duplicated_package_files).not_to eq('20')
expect(mutation_response).to be_nil
expect_graphql_errors_to_include(/you don't have permission to perform this action/)
end
end
describe 'post graphql mutation' do
subject { post_graphql_mutation(mutation, current_user: user) }
context 'with existing packages cleanup policy' do
let_it_be(:project_packages_cleanup_policy) { create(:packages_cleanup_policy, project: project) }
where(:user_role, :shared_examples_name) do
:maintainer | 'accepting the mutation request and updates the existing policy'
:developer | 'denying the mutation request'
:reporter | 'denying the mutation request'
:guest | 'denying the mutation request'
:anonymous | 'denying the mutation request'
end
with_them do
before do
project.send("add_#{user_role}", user) unless user_role == :anonymous
end
it_behaves_like params[:shared_examples_name]
end
end
context 'without existing packages cleanup policy' do
where(:user_role, :shared_examples_name) do
:maintainer | 'accepting the mutation request and creates a policy'
:developer | 'denying the mutation request'
:reporter | 'denying the mutation request'
:guest | 'denying the mutation request'
:anonymous | 'denying the mutation request'
end
with_them do
before do
project.send("add_#{user_role}", user) unless user_role == :anonymous
end
it_behaves_like params[:shared_examples_name]
end
end
end
end

View File

@ -0,0 +1,79 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe 'getting the packages cleanup policy linked to a project' do
using RSpec::Parameterized::TableSyntax
include GraphqlHelpers
let_it_be_with_reload(:project) { create(:project) }
let_it_be(:current_user) { project.first_owner }
let(:fields) do
<<~QUERY
#{all_graphql_fields_for('packages_cleanup_policy'.classify)}
QUERY
end
let(:query) do
graphql_query_for(
'project',
{ 'fullPath' => project.full_path },
query_graphql_field('packagesCleanupPolicy', {}, fields)
)
end
subject { post_graphql(query, current_user: current_user) }
it_behaves_like 'a working graphql query' do
before do
subject
end
end
context 'with an existing policy' do
let_it_be(:policy) { create(:packages_cleanup_policy, project: project) }
it_behaves_like 'a working graphql query' do
before do
subject
end
end
end
context 'with different permissions' do
let_it_be(:current_user) { create(:user) }
let(:packages_cleanup_policy_response) { graphql_data_at('project', 'packagesCleanupPolicy') }
where(:visibility, :role, :policy_visible) do
:private | :maintainer | true
:private | :developer | false
:private | :reporter | false
:private | :guest | false
:private | :anonymous | false
:public | :maintainer | true
:public | :developer | false
:public | :reporter | false
:public | :guest | false
:public | :anonymous | false
end
with_them do
before do
project.update!(visibility: visibility.to_s)
project.add_user(current_user, role) unless role == :anonymous
end
it 'return the proper response' do
subject
if policy_visible
expect(packages_cleanup_policy_response)
.to eq('keepNDuplicatedPackageFiles' => 'ALL_PACKAGE_FILES', 'nextRunAt' => nil)
else
expect(packages_cleanup_policy_response).to be_blank
end
end
end
end
end

View File

@ -0,0 +1,105 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Packages::Cleanup::UpdatePolicyService do
using RSpec::Parameterized::TableSyntax
let_it_be_with_reload(:project) { create(:project) }
let_it_be(:current_user) { create(:user) }
let(:params) { { keep_n_duplicated_package_files: 50 } }
describe '#execute' do
subject { described_class.new(project: project, current_user: current_user, params: params).execute }
shared_examples 'creating the policy' do
it 'creates a new one' do
expect { subject }.to change { ::Packages::Cleanup::Policy.count }.from(0).to(1)
expect(subject.payload[:packages_cleanup_policy]).to be_present
expect(subject.success?).to be_truthy
expect(project.packages_cleanup_policy).to be_persisted
expect(project.packages_cleanup_policy.keep_n_duplicated_package_files).to eq('50')
end
context 'with invalid parameters' do
let(:params) { { keep_n_duplicated_package_files: 100 } }
it 'does not create one' do
expect { subject }.not_to change { ::Packages::Cleanup::Policy.count }
expect(subject.status).to eq(:error)
expect(subject.message).to eq('Keep n duplicated package files is invalid')
end
end
end
shared_examples 'updating the policy' do
it 'updates the existing one' do
expect { subject }.not_to change { ::Packages::Cleanup::Policy.count }
expect(subject.payload[:packages_cleanup_policy]).to be_present
expect(subject.success?).to be_truthy
expect(project.packages_cleanup_policy.keep_n_duplicated_package_files).to eq('50')
end
context 'with invalid parameters' do
let(:params) { { keep_n_duplicated_package_files: 100 } }
it 'does not update one' do
expect { subject }.not_to change { policy.keep_n_duplicated_package_files }
expect(subject.status).to eq(:error)
expect(subject.message).to eq('Keep n duplicated package files is invalid')
end
end
end
shared_examples 'denying access' do
it 'returns an error' do
subject
expect(subject.message).to eq('Access denied')
expect(subject.status).to eq(:error)
end
end
context 'with existing container expiration policy' do
let_it_be(:policy) { create(:packages_cleanup_policy, project: project) }
where(:user_role, :shared_examples_name) do
:maintainer | 'updating the policy'
:developer | 'denying access'
:reporter | 'denying access'
:guest | 'denying access'
:anonymous | 'denying access'
end
with_them do
before do
project.send("add_#{user_role}", current_user) unless user_role == :anonymous
end
it_behaves_like params[:shared_examples_name]
end
end
context 'without existing container expiration policy' do
where(:user_role, :shared_examples_name) do
:maintainer | 'creating the policy'
:developer | 'denying access'
:reporter | 'denying access'
:guest | 'denying access'
:anonymous | 'denying access'
end
with_them do
before do
project.send("add_#{user_role}", current_user) unless user_role == :anonymous
end
it_behaves_like params[:shared_examples_name]
end
end
end
end

View File

@ -22,7 +22,7 @@ module NextInstanceOf
def stub_new(target, number, ordered = false, *new_args, &blk) def stub_new(target, number, ordered = false, *new_args, &blk)
receive_new = receive(:new) receive_new = receive(:new)
receive_new.ordered if ordered receive_new.ordered if ordered
receive_new.with(*new_args) if new_args.any? receive_new.with(*new_args) if new_args.present?
if number.is_a?(Range) if number.is_a?(Range)
receive_new.at_least(number.begin).times if number.begin receive_new.at_least(number.begin).times if number.begin

View File

@ -35,11 +35,11 @@ RSpec.shared_examples 'shows and resets runner registration token' do
it 'has a registration token' do it 'has a registration token' do
click_on 'Click to reveal' click_on 'Click to reveal'
expect(page.find('[data-testid="token-value"] input').value).to have_content(registration_token) expect(page.find_field('token-value').value).to have_content(registration_token)
end end
describe 'reset registration token' do describe 'reset registration token' do
let!(:old_registration_token) { find('[data-testid="token-value"] input').value } let!(:old_registration_token) { find_field('token-value').value }
before do before do
click_on 'Reset registration token' click_on 'Reset registration token'

View File

@ -25,7 +25,7 @@ RSpec.describe Ci::ResourceGroups::AssignResourceFromResourceGroupWorker do
context 'when resource group exists' do context 'when resource group exists' do
it 'executes AssignResourceFromResourceGroupService' do it 'executes AssignResourceFromResourceGroupService' do
expect_next_instances_of(Ci::ResourceGroups::AssignResourceFromResourceGroupService, 2, resource_group.project, nil) do |service| expect_next_instances_of(Ci::ResourceGroups::AssignResourceFromResourceGroupService, 2, false, resource_group.project, nil) do |service|
expect(service).to receive(:execute).with(resource_group) expect(service).to receive(:execute).with(resource_group)
end end