diff options
69 files changed, 2227 insertions, 143 deletions
diff --git a/.gitlab/ci/package-and-test/main.gitlab-ci.yml b/.gitlab/ci/package-and-test/main.gitlab-ci.yml index e2834d188c1..d80b1b44a22 100644 --- a/.gitlab/ci/package-and-test/main.gitlab-ci.yml +++ b/.gitlab/ci/package-and-test/main.gitlab-ci.yml @@ -11,7 +11,7 @@ include: - local: .gitlab/ci/package-and-test/rules.gitlab-ci.yml - local: .gitlab/ci/package-and-test/variables.gitlab-ci.yml - project: gitlab-org/quality/pipeline-common - ref: 3.1.3 + ref: 3.1.5 file: - /ci/base.gitlab-ci.yml - /ci/allure-report.yml @@ -730,7 +730,12 @@ notify-slack: STATUS_SYM: ☠️ STATUS: failed TYPE: "(package-and-test) " - when: on_failure + when: always script: + - | + if [ "$SUITE_FAILED" != "true" ] && [ "$SUITE_RAN" == "true" ]; then + echo "Test suite passed. Exiting..." + exit 0 + fi - bundle exec gitlab-qa-report --prepare-stage-reports "$CI_PROJECT_DIR/gitlab-qa-run-*/**/rspec-*.xml" # generate summary - !reference [.notify-slack-qa, script] diff --git a/.gitlab/ci/reports.gitlab-ci.yml b/.gitlab/ci/reports.gitlab-ci.yml index c77ee0276c6..b072b3dc772 100644 --- a/.gitlab/ci/reports.gitlab-ci.yml +++ b/.gitlab/ci/reports.gitlab-ci.yml @@ -31,7 +31,7 @@ code_quality: variables: SAST_BRAKEMAN_LEVEL: 2 # GitLab-specific SAST_EXCLUDED_PATHS: "qa, spec, doc, ee/spec, config/gitlab.yml.example, tmp" # GitLab-specific - SAST_EXCLUDED_ANALYZERS: bandit, flawfinder, phpcs-security-audit, pmd-apex, security-code-scan, spotbugs, eslint, nodejs-scan + SAST_EXCLUDED_ANALYZERS: bandit, flawfinder, phpcs-security-audit, pmd-apex, security-code-scan, spotbugs, eslint, nodejs-scan, sobelow brakeman-sast: rules: !reference [".reports:rules:brakeman-sast", rules] diff --git a/.gitlab/ci/rules.gitlab-ci.yml b/.gitlab/ci/rules.gitlab-ci.yml index 6aa5d44d2ef..2d1e2d5918f 100644 --- a/.gitlab/ci/rules.gitlab-ci.yml +++ b/.gitlab/ci/rules.gitlab-ci.yml @@ -1417,6 +1417,12 @@ - <<: *if-dot-com-gitlab-org-and-security-merge-request-and-qa-tests-specified changes: *code-patterns allow_failure: true + - <<: *if-force-ci + when: manual + allow_failure: true + +.qa:rules:package-and-test-schedule: + rules: - <<: *if-dot-com-gitlab-org-schedule allow_failure: true variables: @@ -1426,13 +1432,11 @@ UPDATE_QA_CACHE: "true" QA_SAVE_TEST_METRICS: "true" QA_EXPORT_TEST_METRICS: "false" # on main runs, metrics are exported to separate bucket via rake task for better consistency - - <<: *if-force-ci - when: manual - allow_failure: true .qa:rules:package-and-test-ee: rules: - !reference [".qa:rules:package-and-test-common", rules] + - !reference [".qa:rules:package-and-test-schedule", rules] - <<: *if-merge-request changes: *code-patterns when: manual @@ -1460,6 +1464,7 @@ - if: '$QA_RUN_TESTS_ON_GDK !~ /true|yes|1/i' when: never - !reference [".qa:rules:package-and-test-common", rules] + - !reference [".qa:rules:package-and-test-schedule", rules] - <<: *if-merge-request changes: *code-patterns allow_failure: true diff --git a/.rubocop_todo/gitlab/namespaced_class.yml b/.rubocop_todo/gitlab/namespaced_class.yml index 0a80b513c6e..cf10ee8b2dc 100644 --- a/.rubocop_todo/gitlab/namespaced_class.yml +++ b/.rubocop_todo/gitlab/namespaced_class.yml @@ -353,6 +353,7 @@ Gitlab/NamespacedClass: - 'app/models/x509_certificate.rb' - 'app/models/x509_issuer.rb' - 'app/models/zoom_meeting.rb' + - 'app/policies/abuse_report_policy.rb' - 'app/policies/application_setting/term_policy.rb' - 'app/policies/award_emoji_policy.rb' - 'app/policies/base_policy.rb' diff --git a/app/assets/javascripts/admin/abuse_report/components/abuse_report_app.vue b/app/assets/javascripts/admin/abuse_report/components/abuse_report_app.vue new file mode 100644 index 00000000000..9355c1c788f --- /dev/null +++ b/app/assets/javascripts/admin/abuse_report/components/abuse_report_app.vue @@ -0,0 +1,35 @@ +<script> +import ReportHeader from './report_header.vue'; +import UserDetails from './user_details.vue'; +import ReportedContent from './reported_content.vue'; +import HistoryItems from './history_items.vue'; + +export default { + name: 'AbuseReportApp', + components: { + ReportHeader, + UserDetails, + ReportedContent, + HistoryItems, + }, + props: { + abuseReport: { + type: Object, + required: true, + }, + }, +}; +</script> + +<template> + <section> + <report-header + v-if="abuseReport.user" + :user="abuseReport.user" + :actions="abuseReport.actions" + /> + <user-details v-if="abuseReport.user" :user="abuseReport.user" /> + <reported-content :report="abuseReport.report" :reporter="abuseReport.reporter" /> + <history-items :report="abuseReport.report" :reporter="abuseReport.reporter" /> + </section> +</template> diff --git a/app/assets/javascripts/admin/abuse_report/components/history_items.vue b/app/assets/javascripts/admin/abuse_report/components/history_items.vue new file mode 100644 index 00000000000..28b66db84a2 --- /dev/null +++ b/app/assets/javascripts/admin/abuse_report/components/history_items.vue @@ -0,0 +1,51 @@ +<script> +import { GlSprintf } from '@gitlab/ui'; +import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; +import HistoryItem from '~/vue_shared/components/registry/history_item.vue'; +import { HISTORY_ITEMS_I18N } from '../constants'; + +export default { + name: 'HistoryItems', + components: { + GlSprintf, + TimeAgoTooltip, + HistoryItem, + }, + props: { + report: { + type: Object, + required: true, + }, + reporter: { + type: Object, + required: false, + default: null, + }, + }, + computed: { + reporterName() { + return this.reporter?.name || this.$options.i18n.deletedReporter; + }, + }, + i18n: HISTORY_ITEMS_I18N, +}; +</script> + +<template> + <!-- The styles `issuable-discussion`, `timeline`, `main-notes-list` and `notes` used below + are declared in app/assets/stylesheets/pages/notes.scss --> + <section class="gl-pt-6 issuable-discussion"> + <h2 class="gl-font-size-h1 gl-mt-0 gl-mb-2">{{ $options.i18n.activity }}</h2> + <ul class="timeline main-notes-list notes"> + <history-item icon="warning"> + <div class="gl-display-flex gl-xs-flex-direction-column"> + <gl-sprintf :message="$options.i18n.reportedByForCategory"> + <template #name>{{ reporterName }}</template> + <template #category>{{ report.category }}</template> + </gl-sprintf> + <time-ago-tooltip :time="report.reportedAt" class="gl-text-secondary gl-sm-ml-3" /> + </div> + </history-item> + </ul> + </section> +</template> diff --git a/app/assets/javascripts/admin/abuse_report/components/report_header.vue b/app/assets/javascripts/admin/abuse_report/components/report_header.vue new file mode 100644 index 00000000000..54586041354 --- /dev/null +++ b/app/assets/javascripts/admin/abuse_report/components/report_header.vue @@ -0,0 +1,46 @@ +<script> +import { GlAvatar, GlButton, GlLink } from '@gitlab/ui'; +import AbuseReportActions from '~/admin/abuse_reports/components/abuse_report_actions.vue'; +import { REPORT_HEADER_I18N } from '../constants'; + +export default { + name: 'ReportHeader', + components: { + GlAvatar, + GlButton, + GlLink, + AbuseReportActions, + }, + props: { + user: { + type: Object, + required: true, + }, + actions: { + type: Object, + required: true, + }, + }, + i18n: REPORT_HEADER_I18N, +}; +</script> + +<template> + <header + class="gl-py-4 gl-border-b gl-display-flex gl-justify-content-space-between gl-xs-flex-direction-column" + > + <div class="gl-display-flex gl-align-items-center"> + <gl-avatar :size="48" :src="user.avatarUrl" /> + <h1 class="gl-font-size-h-display gl-my-0 gl-ml-3"> + {{ user.name }} + </h1> + <gl-link :href="user.path" class="gl-ml-3"> @{{ user.username }} </gl-link> + </div> + <nav class="gl-display-flex gl-sm-align-items-center gl-mt-4 gl-sm-mt-0"> + <gl-button :href="user.adminPath" class="flex-grow-1"> + {{ $options.i18n.adminProfile }} + </gl-button> + <abuse-report-actions :report="actions" class="gl-sm-ml-3" /> + </nav> + </header> +</template> diff --git a/app/assets/javascripts/admin/abuse_report/components/reported_content.vue b/app/assets/javascripts/admin/abuse_report/components/reported_content.vue new file mode 100644 index 00000000000..3cd8d493cf5 --- /dev/null +++ b/app/assets/javascripts/admin/abuse_report/components/reported_content.vue @@ -0,0 +1,137 @@ +<script> +import { GlButton, GlModal, GlCard, GlLink, GlAvatar } from '@gitlab/ui'; +import { __ } from '~/locale'; +import SafeHtml from '~/vue_shared/directives/safe_html'; +import { renderGFM } from '~/behaviors/markdown/render_gfm'; +import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; +import { REPORTED_CONTENT_I18N } from '../constants'; + +export default { + name: 'ReportedContent', + components: { + GlButton, + GlModal, + GlCard, + GlLink, + GlAvatar, + TimeAgoTooltip, + }, + modalId: 'abuse-report-screenshot-modal', + directives: { + SafeHtml, + }, + props: { + report: { + type: Object, + required: true, + }, + reporter: { + type: Object, + required: false, + default: null, + }, + }, + data() { + return { + showScreenshotModal: false, + }; + }, + computed: { + reporterName() { + return this.reporter?.name || this.$options.i18n.deletedReporter; + }, + reportType() { + return this.report.type || 'unknown'; + }, + }, + mounted() { + renderGFM(this.$refs.gfmContent); + }, + methods: { + toggleScreenshotModal() { + this.showScreenshotModal = !this.showScreenshotModal; + }, + }, + i18n: REPORTED_CONTENT_I18N, + screenshotModalButtonAttributes: { + text: __('Close'), + attributes: { + variant: 'confirm', + }, + }, + safeHtmlConfig: { ADD_TAGS: ['gl-emoji'] }, +}; +</script> + +<template> + <div class="gl-pt-6"> + <div + class="gl-pb-3 gl-display-flex gl-justify-content-space-between gl-xs-flex-direction-column" + > + <h2 class="gl-font-size-h1 gl-mt-0 gl-mb-2"> + {{ $options.i18n.reportTypes[reportType] }} + </h2> + <div + class="gl-display-flex gl-align-items-stretch gl-xs-flex-direction-column gl-mt-3 gl-sm-mt-0" + > + <template v-if="report.screenshot"> + <gl-button data-testid="screenshot-button" @click="toggleScreenshotModal"> + {{ $options.i18n.viewScreenshot }} + </gl-button> + <gl-modal + v-model="showScreenshotModal" + :title="$options.i18n.screenshotTitle" + :modal-id="$options.modalId" + :action-primary="$options.screenshotModalButtonAttributes" + > + <img + :src="report.screenshot" + :alt="$options.i18n.screenshotTitle" + class="gl-w-full gl-h-auto" + /> + </gl-modal> + </template> + <gl-button + v-if="report.url" + data-testid="report-url-button" + :href="report.url" + class="gl-sm-ml-3 gl-mt-3 gl-sm-mt-0" + > + {{ $options.i18n.goToType[reportType] }} + </gl-button> + </div> + </div> + <gl-card + header-class="gl-bg-white js-test-card-header" + body-class="gl-bg-gray-50 gl-px-5 gl-py-3 js-test-card-body" + footer-class="gl-bg-white js-test-card-footer" + > + <template v-if="report.content" #header> + <div + ref="gfmContent" + v-safe-html:[$options.safeHtmlConfig]="report.content" + class="md" + ></div> + </template> + {{ $options.i18n.reportedBy }} + <template #footer> + <div class="gl-display-flex gl-align-items-center gl-mb-2"> + <gl-avatar :size="32" :src="reporter && reporter.avatarUrl" /> + <div class="gl-display-flex gl-flex-wrap"> + <span class="gl-ml-3 gl-font-weight-bold"> + {{ reporterName }} + </span> + <gl-link v-if="reporter" :href="reporter.path" class="gl-ml-3"> + @{{ reporter.username }} + </gl-link> + <time-ago-tooltip + :time="report.reportedAt" + class="gl-ml-3 gl-text-secondary gl-xs-w-full" + /> + </div> + </div> + <p v-if="report.message" class="gl-pl-8 gl-mb-0">{{ report.message }}</p> + </template> + </gl-card> + </div> +</template> diff --git a/app/assets/javascripts/admin/abuse_report/components/user_detail.vue b/app/assets/javascripts/admin/abuse_report/components/user_detail.vue new file mode 100644 index 00000000000..0aeee5e05f8 --- /dev/null +++ b/app/assets/javascripts/admin/abuse_report/components/user_detail.vue @@ -0,0 +1,27 @@ +<script> +export default { + name: 'UserDetail', + props: { + label: { + type: String, + required: true, + }, + value: { + type: String, + required: false, + default: '', + }, + }, +}; +</script> + +<template> + <div class="gl-display-flex gl-flex-direction-column gl-sm-flex-direction-row gl-mb-4"> + <p class="gl-font-weight-bold gl-flex-grow-1 gl-flex-basis-0 gl-mb-0"> + {{ label }} + </p> + <div class="gl-flex-grow-1 gl-flex-basis-two-thirds"> + <slot>{{ value }}</slot> + </div> + </div> +</template> diff --git a/app/assets/javascripts/admin/abuse_report/components/user_details.vue b/app/assets/javascripts/admin/abuse_report/components/user_details.vue new file mode 100644 index 00000000000..3dc03a8748f --- /dev/null +++ b/app/assets/javascripts/admin/abuse_report/components/user_details.vue @@ -0,0 +1,115 @@ +<script> +import { GlLink, GlSprintf } from '@gitlab/ui'; +import { formatNumber } from '~/locale'; +import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; +import { USER_DETAILS_I18N } from '../constants'; +import UserDetail from './user_detail.vue'; + +export default { + name: 'UserDetails', + components: { + GlLink, + GlSprintf, + TimeAgoTooltip, + UserDetail, + }, + props: { + user: { + type: Object, + required: true, + }, + }, + computed: { + verificationState() { + return Object.entries(this.user.verificationState) + .filter(([, v]) => v) + .map(([k]) => this.$options.i18n.verificationMethods[k]) + .join(', '); + }, + showSimilarRecords() { + return this.user.creditCard.similarRecordsCount > 1; + }, + similarRecordsCount() { + return formatNumber(this.user.creditCard.similarRecordsCount); + }, + }, + i18n: USER_DETAILS_I18N, +}; +</script> + +<template> + <div class="gl-mt-6"> + <user-detail data-testid="createdAt" :label="$options.i18n.createdAt"> + <time-ago-tooltip :time="user.createdAt" /> + </user-detail> + <user-detail data-testid="email" :label="$options.i18n.email"> + <gl-link :href="`mailto:${user.email}`">{{ user.email }}</gl-link> + </user-detail> + <user-detail data-testid="plan" :label="$options.i18n.plan" :value="user.plan" /> + <user-detail + data-testid="verification" + :label="$options.i18n.verification" + :value="verificationState" + /> + <user-detail v-if="user.creditCard" data-testid="creditCard" :label="$options.i18n.creditCard"> + <gl-sprintf :message="$options.i18n.registeredWith"> + <template #name>{{ user.creditCard.name }}</template> + </gl-sprintf> + <gl-sprintf v-if="showSimilarRecords" :message="$options.i18n.similarRecords"> + <template #cardMatchesLink="{ content }"> + <gl-link :href="user.creditCard.cardMatchesLink"> + <gl-sprintf :message="content"> + <template #count>{{ similarRecordsCount }}</template> + </gl-sprintf> + </gl-link> + </template> + </gl-sprintf> + </user-detail> + <user-detail + v-if="user.otherReports.length" + data-testid="otherReports" + :label="$options.i18n.otherReports" + > + <div + v-for="(report, index) in user.otherReports" + :key="index" + :data-testid="`other-report-${index}`" + > + <gl-sprintf :message="$options.i18n.otherReport"> + <template #reportLink="{ content }"> + <gl-link :href="report.reportPath">{{ content }}</gl-link> + </template> + <template #category>{{ report.category }}</template> + <template #timeAgo> + <time-ago-tooltip :time="report.createdAt" /> + </template> + </gl-sprintf> + </div> + </user-detail> + <user-detail + data-testid="normalLocation" + :label="$options.i18n.normalLocation" + :value="user.mostUsedIp || user.lastSignInIp" + /> + <user-detail + data-testid="lastSignInIp" + :label="$options.i18n.lastSignInIp" + :value="user.lastSignInIp" + /> + <user-detail + data-testid="snippets" + :label="$options.i18n.snippets" + :value="$options.i18n.snippetsCount(user.snippetsCount)" + /> + <user-detail + data-testid="groups" + :label="$options.i18n.groups" + :value="$options.i18n.groupsCount(user.groupsCount)" + /> + <user-detail + data-testid="notes" + :label="$options.i18n.notes" + :value="$options.i18n.notesCount(user.notesCount)" + /> + </div> +</template> diff --git a/app/assets/javascripts/admin/abuse_report/constants.js b/app/assets/javascripts/admin/abuse_report/constants.js new file mode 100644 index 00000000000..a59e10b5d4a --- /dev/null +++ b/app/assets/javascripts/admin/abuse_report/constants.js @@ -0,0 +1,61 @@ +import { s__, n__ } from '~/locale'; + +export const REPORT_HEADER_I18N = { + adminProfile: s__('AbuseReport|Admin profile'), +}; + +export const USER_DETAILS_I18N = { + createdAt: s__('AbuseReport|Member since'), + email: s__('AbuseReport|Email'), + plan: s__('AbuseReport|Tier'), + verification: s__('AbuseReport|Verification'), + creditCard: s__('AbuseReport|Credit card'), + otherReports: s__('AbuseReport|Abuse reports'), + normalLocation: s__('AbuseReport|Normal location'), + lastSignInIp: s__('AbuseReport|Last login'), + snippets: s__('AbuseReport|Snippets'), + groups: s__('AbuseReport|Groups'), + notes: s__('AbuseReport|Comments'), + snippetsCount: (count) => n__(`%d snippet`, `%d snippets`, count), + groupsCount: (count) => n__(`%d group`, `%d groups`, count), + notesCount: (count) => n__(`%d comment`, `%d comments`, count), + verificationMethods: { + email: s__('AbuseReport|Email'), + phone: s__('AbuseReport|Phone'), + creditCard: s__('AbuseReport|Credit card'), + }, + otherReport: s__( + 'AbuseReport|%{reportLinkStart}Reported%{reportLinkEnd} for %{category} %{timeAgo}.', + ), + registeredWith: s__('AbuseReport|Registered with name %{name}.'), + similarRecords: s__( + 'AbuseReport|Card matches %{cardMatchesLinkStart}%{count} accounts%{cardMatchesLinkEnd}', + ), +}; + +export const REPORTED_CONTENT_I18N = { + reportTypes: { + profile: s__('AbuseReport|Reported profile'), + comment: s__('AbuseReport|Reported comment'), + issue: s__('AbuseReport|Reported issue'), + merge_request: s__('AbuseReport|Reported merge request'), + unknown: s__('AbuseReport|Reported content'), + }, + viewScreenshot: s__('AbuseReport|View screenshot'), + screenshotTitle: s__('AbuseReport|Screenshot of reported abuse'), + goToType: { + profile: s__('AbuseReport|Go to profile'), + comment: s__('AbuseReport|Go to comment'), + issue: s__('AbuseReport|Go to issue'), + merge_request: s__('AbuseReport|Go to merge request'), + unknown: s__('AbuseReport|Go to content'), + }, + reportedBy: s__('AbuseReport|Reported by'), + deletedReporter: s__('AbuseReport|No user found'), +}; + +export const HISTORY_ITEMS_I18N = { + activity: s__('AbuseReport|Activity'), + reportedByForCategory: s__('AbuseReport|Reported by %{name} for %{category}.'), + deletedReporter: s__('AbuseReport|No user found'), +}; diff --git a/app/assets/javascripts/admin/abuse_report/index.js b/app/assets/javascripts/admin/abuse_report/index.js new file mode 100644 index 00000000000..8ff3e690127 --- /dev/null +++ b/app/assets/javascripts/admin/abuse_report/index.js @@ -0,0 +1,27 @@ +import Vue from 'vue'; +import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; +import AbuseReportApp from './components/abuse_report_app.vue'; + +export const initAbuseReportApp = () => { + const el = document.querySelector('#js-abuse-reports-detail-view'); + + if (!el) { + return null; + } + + const { abuseReportData } = el.dataset; + const abuseReport = convertObjectPropsToCamelCase(JSON.parse(abuseReportData), { + deep: true, + }); + + return new Vue({ + el, + name: 'AbuseReportAppRoot', + render: (createElement) => + createElement(AbuseReportApp, { + props: { + abuseReport, + }, + }), + }); +}; diff --git a/app/assets/javascripts/admin/abuse_reports/components/abuse_report_actions.vue b/app/assets/javascripts/admin/abuse_reports/components/abuse_report_actions.vue index 3fac8fd78a2..4f3be0e3a59 100644 --- a/app/assets/javascripts/admin/abuse_reports/components/abuse_report_actions.vue +++ b/app/assets/javascripts/admin/abuse_reports/components/abuse_report_actions.vue @@ -2,6 +2,7 @@ import { GlDisclosureDropdown, GlModal } from '@gitlab/ui'; import axios from '~/lib/utils/axios_utils'; import { __, sprintf } from '~/locale'; +import { redirectTo, refreshCurrentPage } from '~/lib/utils/url_utility'; import { createAlert, VARIANT_SUCCESS } from '~/alert'; import { ACTIONS_I18N } from '../constants'; @@ -125,7 +126,8 @@ export default { .catch(this.handleError); }, handleRemoveReportResponse() { - window.location.reload(); + if (this.report.redirectPath) redirectTo(this.report.redirectPath); + else refreshCurrentPage(); }, handleBlockUserResponse({ data }) { const message = data?.error || data?.notice; diff --git a/app/assets/javascripts/ide/components/repo_tab.vue b/app/assets/javascripts/ide/components/repo_tab.vue index 64ec2cc67c7..0fe909fcce8 100644 --- a/app/assets/javascripts/ide/components/repo_tab.vue +++ b/app/assets/javascripts/ide/components/repo_tab.vue @@ -91,6 +91,7 @@ export default { :disabled="tab.pending" type="button" class="multi-file-tab-close" + data-testid="close-button" @click.stop.prevent="closeFile(tab)" > <gl-icon v-if="!showChangedIcon" :size="12" name="close" /> diff --git a/app/assets/javascripts/pages/admin/abuse_reports/show/index.js b/app/assets/javascripts/pages/admin/abuse_reports/show/index.js new file mode 100644 index 00000000000..13475f9560f --- /dev/null +++ b/app/assets/javascripts/pages/admin/abuse_reports/show/index.js @@ -0,0 +1,3 @@ +import { initAbuseReportApp } from '~/admin/abuse_report'; + +initAbuseReportApp(); diff --git a/app/assets/javascripts/super_sidebar/components/counter.vue b/app/assets/javascripts/super_sidebar/components/counter.vue index 98417b7cd25..9f0e1d4ee6f 100644 --- a/app/assets/javascripts/super_sidebar/components/counter.vue +++ b/app/assets/javascripts/super_sidebar/components/counter.vue @@ -40,7 +40,7 @@ export default { :is="component" :aria-label="ariaLabel" :href="href" - class="counter gl-display-block gl-flex-grow-1 gl-text-center gl-py-3 gl-bg-gray-10 gl-rounded-base gl-text-gray-900 gl-border-none gl-inset-border-1-gray-a-08 gl-line-height-1 gl-font-sm gl-hover-text-gray-900 gl-hover-text-decoration-none" + class="counter gl-display-block gl-flex-grow-1 gl-text-center gl-py-3 gl-bg-gray-10 gl-rounded-base gl-text-gray-900 gl-border-none gl-inset-border-1-gray-a-08 gl-line-height-1 gl-font-sm gl-hover-text-gray-900 gl-hover-text-decoration-none gl-focus--focus" > <gl-icon aria-hidden="true" :name="icon" /> <span v-if="count" aria-hidden="true" class="gl-ml-1">{{ count }}</span> diff --git a/app/assets/javascripts/vue_shared/components/markdown/markdown_editor.vue b/app/assets/javascripts/vue_shared/components/markdown/markdown_editor.vue index 52d8aab30d5..1faf57bb79e 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/markdown_editor.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/markdown_editor.vue @@ -3,6 +3,7 @@ import Autosize from 'autosize'; import axios from '~/lib/utils/axios_utils'; import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; import { updateDraft, clearDraft, getDraft } from '~/lib/utils/autosave'; +import { setUrlParams, joinPaths } from '~/lib/utils/url_utility'; import { EDITING_MODE_MARKDOWN_FIELD, EDITING_MODE_CONTENT_EDITOR } from '../../constants'; import MarkdownField from './field.vue'; @@ -150,7 +151,11 @@ export default { this.autosizeTextarea(); }, renderMarkdown(markdown) { - return axios.post(this.renderMarkdownPath, { text: markdown }).then(({ data }) => data.body); + const url = setUrlParams( + { render_quick_actions: this.supportsQuickActions }, + joinPaths(window.location.origin, gon.relative_url_root, this.renderMarkdownPath), + ); + return axios.post(url, { text: markdown }).then(({ data }) => data.body); }, onEditingModeChange(editingMode) { this.editingMode = editingMode; diff --git a/app/assets/stylesheets/framework/super_sidebar.scss b/app/assets/stylesheets/framework/super_sidebar.scss index 592fc886f0a..3973be54795 100644 --- a/app/assets/stylesheets/framework/super_sidebar.scss +++ b/app/assets/stylesheets/framework/super_sidebar.scss @@ -100,7 +100,6 @@ .counter[aria-expanded='true'] { background-color: $gray-50; border-color: transparent; - box-shadow: none; mix-blend-mode: multiply; .gl-dark & { @@ -112,6 +111,11 @@ } } + .counter:hover, + .counter[aria-expanded='true'] { + box-shadow: none; + } + .context-switcher .gl-new-dropdown-custom-toggle { width: 100%; } diff --git a/app/controllers/admin/abuse_reports_controller.rb b/app/controllers/admin/abuse_reports_controller.rb index 49079461698..e88a8cf8fab 100644 --- a/app/controllers/admin/abuse_reports_controller.rb +++ b/app/controllers/admin/abuse_reports_controller.rb @@ -9,6 +9,10 @@ class Admin::AbuseReportsController < Admin::ApplicationController @abuse_reports = AbuseReportsFinder.new(params).execute end + def show + @abuse_report = AbuseReport.find(params[:id]) + end + def destroy abuse_report = AbuseReport.find(params[:id]) diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 02b29d42313..4db5745c005 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -182,7 +182,7 @@ class UsersController < ApplicationController def exists if Gitlab::CurrentSettings.signup_enabled? || current_user - render json: { exists: !!Namespace.find_by_path_or_name(params[:username]) } + render json: { exists: !!Namespace.without_project_namespaces.find_by_path_or_name(params[:username]) } else render json: { error: _('You must be authenticated to access this path.') }, status: :unauthorized end diff --git a/app/helpers/admin/abuse_reports_helper.rb b/app/helpers/admin/abuse_reports_helper.rb index 3218ecfd1db..275bed406f1 100644 --- a/app/helpers/admin/abuse_reports_helper.rb +++ b/app/helpers/admin/abuse_reports_helper.rb @@ -15,5 +15,11 @@ module Admin }.to_json } end + + def abuse_report_data(report) + { + abuse_report_data: Admin::AbuseReportDetailsSerializer.new.represent(report).to_json + } + end end end diff --git a/app/models/abuse_report.rb b/app/models/abuse_report.rb index fa0ff5bc3fd..1384b7470ac 100644 --- a/app/models/abuse_report.rb +++ b/app/models/abuse_report.rb @@ -5,6 +5,7 @@ class AbuseReport < ApplicationRecord include Sortable include Gitlab::FileTypeDetection include WithUploads + include Gitlab::Utils::StrongMemoize MAX_CHAR_LIMIT_URL = 512 MAX_FILE_SIZE = 1.megabyte @@ -77,6 +78,12 @@ class AbuseReport < ApplicationRecord reported_from_url: "Reported from" }.freeze + CONTROLLER_TO_REPORT_TYPE = { + 'users' => :profile, + 'projects/issues' => :issue, + 'projects/merge_requests' => :merge_request + }.freeze + def self.human_attribute_name(attr, options = {}) HUMANIZED_ATTRIBUTES[attr.to_sym] || super end @@ -105,8 +112,52 @@ class AbuseReport < ApplicationRecord Gitlab::Utils.append_path(asset_host, local_path) end + def report_type + type = CONTROLLER_TO_REPORT_TYPE[route_hash[:controller]] + type = :comment if type.in?([:issue, :merge_request]) && note_id_from_url.present? + + type + end + + def reported_content + case report_type + when :issue + project.issues.iid_in(route_hash[:id]).pick(:description_html) + when :merge_request + project.merge_requests.iid_in(route_hash[:id]).pick(:description_html) + when :comment + project.notes.id_in(note_id_from_url).pick(:note_html) + end + end + + def other_reports_for_user + user.abuse_reports.id_not_in(id) + end + private + def project + Project.find_by_full_path(route_hash.values_at(:namespace_id, :project_id).join('/')) + end + + def route_hash + match = Rails.application.routes.recognize_path(reported_from_url) + return {} if match[:unmatched_route].present? + + match + rescue ActionController::RoutingError + {} + end + strong_memoize_attr :route_hash + + def note_id_from_url + fragment = URI(reported_from_url).fragment + Gitlab::UntrustedRegexp.new('^note_(\d+)$').match(fragment).to_a.second if fragment + rescue URI::InvalidURIError + nil + end + strong_memoize_attr :note_id_from_url + def filter_empty_strings_from_links_to_spam return if links_to_spam.blank? diff --git a/app/models/authentication_event.rb b/app/models/authentication_event.rb index d5a5079acd6..a70ebb42008 100644 --- a/app/models/authentication_event.rb +++ b/app/models/authentication_event.rb @@ -30,4 +30,8 @@ class AuthenticationEvent < ApplicationRecord !where(user_id: user).exists? || where(user_id: user, ip_address: ip_address).success.exists? end + + def self.most_used_ip_address_for_user(user) + select('mode() within group (order by ip_address) as ip_address').find_by(user: user).ip_address + end end diff --git a/app/policies/abuse_report_policy.rb b/app/policies/abuse_report_policy.rb new file mode 100644 index 00000000000..f1f994e6a42 --- /dev/null +++ b/app/policies/abuse_report_policy.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AbuseReportPolicy < ::BasePolicy + rule { admin }.policy do + enable :read_abuse_report + end +end diff --git a/app/serializers/admin/abuse_report_details_entity.rb b/app/serializers/admin/abuse_report_details_entity.rb new file mode 100644 index 00000000000..c0394eb38c5 --- /dev/null +++ b/app/serializers/admin/abuse_report_details_entity.rb @@ -0,0 +1,104 @@ +# frozen_string_literal: true + +module Admin + class AbuseReportDetailsEntity < Grape::Entity + include RequestAwareEntity + + expose :user, if: ->(report) { report.user } do + expose :details, merge: true do |report| + UserEntity.represent(report.user, only: [:name, :username, :avatar_url, :email, :created_at, :last_activity_on]) + end + expose :path do |report| + user_path(report.user) + end + expose :admin_path do |report| + admin_user_path(report.user) + end + expose :plan do |report| + if Gitlab::CurrentSettings.current_application_settings.try(:should_check_namespace_plan?) + report.user.namespace&.actual_plan&.title + end + end + expose :verification_state do + expose :email do |report| + report.user.confirmed? + end + expose :phone do |report| + report.user.phone_number_validation.present? && report.user.phone_number_validation.validated? + end + expose :credit_card do |report| + report.user.credit_card_validation.present? + end + end + expose :credit_card, if: ->(report) { report.user.credit_card_validation&.holder_name } do + expose :name do |report| + report.user.credit_card_validation.holder_name + end + expose :similar_records_count do |report| + report.user.credit_card_validation.similar_records.count + end + expose :card_matches_link do |report| + card_match_admin_user_path(report.user) if Gitlab.ee? + end + end + expose :other_reports do |report| + AbuseReportEntity.represent(report.other_reports_for_user, only: [:created_at, :category, :report_path]) + end + expose :most_used_ip do |report| + AuthenticationEvent.most_used_ip_address_for_user(report.user) + end + expose :last_sign_in_ip do |report| + report.user.last_sign_in_ip + end + expose :snippets_count do |report| + report.user.snippets.count + end + expose :groups_count do |report| + report.user.groups.count + end + expose :notes_count do |report| + report.user.notes.count + end + end + + expose :reporter, if: ->(report) { report.reporter } do + expose :details, merge: true do |report| + UserEntity.represent(report.reporter, only: [:name, :username, :avatar_url]) + end + expose :path do |report| + user_path(report.reporter) + end + end + + expose :report do + expose :message + expose :created_at, as: :reported_at + expose :category + expose :report_type, as: :type + expose :reported_content, as: :content + expose :reported_from_url, as: :url + expose :screenshot_path, as: :screenshot + end + + expose :actions, if: ->(report) { report.user } do + expose :user_blocked do |report| + report.user.blocked? + end + expose :block_user_path do |report| + block_admin_user_path(report.user) + end + expose :remove_report_path do |report| + admin_abuse_report_path(report) + end + expose :remove_user_and_report_path do |report| + admin_abuse_report_path(report, remove_user: true) + end + expose :reported_user do |report| + UserEntity.represent(report.user, only: [:name, :created_at]) + end + expose :redirect_path do |_| + admin_abuse_reports_path + end + end + end +end diff --git a/app/serializers/admin/abuse_report_details_serializer.rb b/app/serializers/admin/abuse_report_details_serializer.rb new file mode 100644 index 00000000000..ca90de1cf3c --- /dev/null +++ b/app/serializers/admin/abuse_report_details_serializer.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module Admin + class AbuseReportDetailsSerializer < BaseSerializer + entity Admin::AbuseReportDetailsEntity + end +end diff --git a/app/serializers/admin/abuse_report_entity.rb b/app/serializers/admin/abuse_report_entity.rb index 54916d02ecb..456640bf836 100644 --- a/app/serializers/admin/abuse_report_entity.rb +++ b/app/serializers/admin/abuse_report_entity.rb @@ -37,6 +37,10 @@ module Admin admin_abuse_report_path(report) end + expose :report_path do |report| + admin_abuse_report_path(report) + end + expose :remove_user_and_report_path do |report| admin_abuse_report_path(report, remove_user: true) end diff --git a/app/services/preview_markdown_service.rb b/app/services/preview_markdown_service.rb index b3a9beabba5..c8ccbe1465e 100644 --- a/app/services/preview_markdown_service.rb +++ b/app/services/preview_markdown_service.rb @@ -24,7 +24,7 @@ class PreviewMarkdownService < BaseService return text, [] unless quick_action_types.include?(target_type) quick_actions_service = QuickActions::InterpretService.new(project, current_user) - quick_actions_service.explain(text, find_commands_target) + quick_actions_service.explain(text, find_commands_target, keep_actions: params[:render_quick_actions]) end def find_user_references(text) diff --git a/app/services/quick_actions/interpret_service.rb b/app/services/quick_actions/interpret_service.rb index f1e4dac8835..e1646cbf643 100644 --- a/app/services/quick_actions/interpret_service.rb +++ b/app/services/quick_actions/interpret_service.rb @@ -49,12 +49,13 @@ module QuickActions # Takes a text and interprets the commands that are extracted from it. # Returns the content without commands, and array of changes explained. - def explain(content, quick_action_target) + # `keep_actions: true` will keep the quick actions in the content. + def explain(content, quick_action_target, keep_actions: false) return [content, []] unless current_user.can?(:use_quick_actions) @quick_action_target = quick_action_target - content, commands = extractor.extract_commands(content) + content, commands = extractor(keep_actions).extract_commands(content) commands = explain_commands(commands) [content, commands] end @@ -65,8 +66,8 @@ module QuickActions raise Gitlab::QuickActions::CommandDefinition::ParseError, message end - def extractor - Gitlab::QuickActions::Extractor.new(self.class.command_definitions) + def extractor(keep_actions = false) + Gitlab::QuickActions::Extractor.new(self.class.command_definitions, keep_actions: keep_actions) end # Find users for commands like /assign diff --git a/app/views/admin/abuse_reports/show.html.haml b/app/views/admin/abuse_reports/show.html.haml new file mode 100644 index 00000000000..bd7a1054b5d --- /dev/null +++ b/app/views/admin/abuse_reports/show.html.haml @@ -0,0 +1,6 @@ +- add_to_breadcrumbs _('Abuse Reports'), admin_abuse_reports_path +- breadcrumb_title @abuse_report.user&.name +- page_title @abuse_report.user&.name, _('Abuse Reports') + +#js-abuse-reports-detail-view{ data: abuse_report_data(@abuse_report) } + = gl_loading_icon(css_class: 'gl-my-5', size: 'md') diff --git a/config/feature_flags/development/openai_moderation.yml b/config/feature_flags/development/openai_moderation.yml new file mode 100644 index 00000000000..97915ceac41 --- /dev/null +++ b/config/feature_flags/development/openai_moderation.yml @@ -0,0 +1,8 @@ +--- +name: openai_moderation +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/119050 +rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/409452 +milestone: '16.0' +type: development +group: group::ai-enablement +default_enabled: false diff --git a/config/routes/admin.rb b/config/routes/admin.rb index 6130f7c2fe2..921600125e2 100644 --- a/config/routes/admin.rb +++ b/config/routes/admin.rb @@ -35,7 +35,7 @@ namespace :admin do resource :impersonation, only: :destroy - resources :abuse_reports, only: [:index, :destroy] + resources :abuse_reports, only: [:index, :show, :destroy] resources :gitaly_servers, only: [:index] resources :spam_logs, only: [:index, :destroy] do diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md index 3c9e837930b..a054aab5dd6 100644 --- a/doc/api/graphql/reference/index.md +++ b/doc/api/graphql/reference/index.md @@ -1031,36 +1031,6 @@ Input type: `AlertTodoCreateInput` | <a id="mutationalerttodocreateissue"></a>`issue` | [`Issue`](#issue) | Issue created after mutation. | | <a id="mutationalerttodocreatetodo"></a>`todo` | [`Todo`](#todo) | To-do item after mutation. | -### `Mutation.apiFuzzingCiConfigurationCreate` - -WARNING: -**Deprecated** in 15.1. -The configuration snippet is now generated client-side. - -Input type: `ApiFuzzingCiConfigurationCreateInput` - -#### Arguments - -| Name | Type | Description | -| ---- | ---- | ----------- | -| <a id="mutationapifuzzingciconfigurationcreateapispecificationfile"></a>`apiSpecificationFile` | [`String!`](#string) | File path or URL to the file that defines the API surface for scanning. Must be in the format specified by the `scanMode` argument. | -| <a id="mutationapifuzzingciconfigurationcreateauthpassword"></a>`authPassword` | [`String`](#string) | CI variable containing the password for authenticating with the target API. | -| <a id="mutationapifuzzingciconfigurationcreateauthusername"></a>`authUsername` | [`String`](#string) | CI variable containing the username for authenticating with the target API. | -| <a id="mutationapifuzzingciconfigurationcreateclientmutationid"></a>`clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. | -| <a id="mutationapifuzzingciconfigurationcreateprojectpath"></a>`projectPath` | [`ID!`](#id) | Full path of the project. | -| <a id="mutationapifuzzingciconfigurationcreatescanmode"></a>`scanMode` | [`ApiFuzzingScanMode!`](#apifuzzingscanmode) | Mode for API fuzzing scans. | -| <a id="mutationapifuzzingciconfigurationcreatescanprofile"></a>`scanProfile` | [`String`](#string) | Name of a default profile to use for scanning. Ex: Quick-10. | -| <a id="mutationapifuzzingciconfigurationcreatetarget"></a>`target` | [`String!`](#string) | URL for the target of API fuzzing scans. | - -#### Fields - -| Name | Type | Description | -| ---- | ---- | ----------- | -| <a id="mutationapifuzzingciconfigurationcreateclientmutationid"></a>`clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. | -| <a id="mutationapifuzzingciconfigurationcreateconfigurationyaml"></a>`configurationYaml` **{warning-solid}** | [`String`](#string) | **Deprecated:** The configuration snippet is now generated client-side. Deprecated in 14.6. | -| <a id="mutationapifuzzingciconfigurationcreateerrors"></a>`errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. | -| <a id="mutationapifuzzingciconfigurationcreategitlabciyamleditpath"></a>`gitlabCiYamlEditPath` **{warning-solid}** | [`String`](#string) | **Deprecated:** The configuration snippet is now generated client-side. Deprecated in 14.6. | - ### `Mutation.approveDeployment` Input type: `ApproveDeploymentInput` diff --git a/doc/api/graphql/removed_items.md b/doc/api/graphql/removed_items.md index ef7dcff6345..b4e14dec219 100644 --- a/doc/api/graphql/removed_items.md +++ b/doc/api/graphql/removed_items.md @@ -30,9 +30,10 @@ Fields removed in GitLab 16.0. ### GraphQL Mutations -| Argument name | Mutation | Deprecated in | Use instead | -| -------------------- | -------------------- | ------------- | -------------------------- | +| Argument name | Mutation | Deprecated in | Use instead | +| -------------------- | -------------------- |---------------------------------------------------------------------|------------------------------------------------| | - | `vulnerabilityFindingDismiss` | [15.5](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/99170) | `vulnerabilityDismiss` or `securityFindingDismiss` | +| - | `apiFuzzingCiConfigurationCreate` | [15.1](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/87241) | `todos` | ## GitLab 15.0 diff --git a/doc/development/go_guide/index.md b/doc/development/go_guide/index.md index 6092a05afff..e51542649bb 100644 --- a/doc/development/go_guide/index.md +++ b/doc/development/go_guide/index.md @@ -438,7 +438,7 @@ up to run `goimports -local gitlab.com/gitlab-org` so that it's applied to every ### Naming branches -Only use the characters `a-z`, `0-9` or `-` in branch names. This restriction is due to the fact that `go get` doesn't work as expected when a branch name contains certain characters, such as a slash `/`: +In addition to the GitLab [branch name rules](../../user/project/repository/branches/index.md#name-your-branch), use only the characters `a-z`, `0-9` or `-` in branch names. This restriction is because `go get` doesn't work as expected when a branch name contains certain characters, such as a slash `/`: ```shell $ go get -u gitlab.com/gitlab-org/security-products/analyzers/report/v3@some-user/some-feature diff --git a/doc/development/secure_coding_guidelines.md b/doc/development/secure_coding_guidelines.md index 232b942525d..2e53fb28cb9 100644 --- a/doc/development/secure_coding_guidelines.md +++ b/doc/development/secure_coding_guidelines.md @@ -1309,7 +1309,10 @@ In the event of credential leak through an MR, issue, or any other medium, [reac ### Examples -Encrypting a token with `attr_encrypted` so that the plaintext can be retrieved and used later: +Encrypting a token with `attr_encrypted` so that the plaintext can be retrieved +and used later. Use a binary column to store `attr_encrypted` attributes in the database, +and then set both `encode` and `encode_iv` to `false`. For recommended algorithms, see +the [GitLab Cryptography Standard](https://about.gitlab.com/handbook/security/cryptographic-standard.html#algorithmic-standards). ```ruby module AlertManagement @@ -1318,7 +1321,9 @@ module AlertManagement attr_encrypted :token, mode: :per_attribute_iv, key: Settings.attr_encrypted_db_key_base_32, - algorithm: 'aes-256-gcm' + algorithm: 'aes-256-gcm', + encode: false, + encode_iv: false ``` Hashing a sensitive value with `CryptoHelper` so that it can be compared in future, but the plaintext is irretrievable: diff --git a/doc/gitlab-basics/start-using-git.md b/doc/gitlab-basics/start-using-git.md index cdcff148505..b86765a318e 100644 --- a/doc/gitlab-basics/start-using-git.md +++ b/doc/gitlab-basics/start-using-git.md @@ -275,8 +275,10 @@ To create a feature branch: git checkout -b <name-of-branch> ``` -Branch names cannot contain empty spaces and special characters. Use only lowercase letters, numbers, -hyphens (`-`), and underscores (`_`). +GitLab enforces [branch naming rules](../user/project/repository/branches/index.md#name-your-branch) +to prevent problems, and provides +[branch naming patterns](../user/project/repository/branches/index.md#prefix-branch-names-with-issue-numbers) +to streamline merge request creation. ### Switch to a branch diff --git a/doc/user/project/merge_requests/creating_merge_requests.md b/doc/user/project/merge_requests/creating_merge_requests.md index 4ac6c6e0aa2..15e760e3696 100644 --- a/doc/user/project/merge_requests/creating_merge_requests.md +++ b/doc/user/project/merge_requests/creating_merge_requests.md @@ -7,10 +7,13 @@ description: "How to create merge requests in GitLab." # Creating merge requests **(FREE)** -There are many different ways to create a merge request. +GitLab provides many different ways to create a merge request. NOTE: -Use [branch naming patterns](../repository/branches/index.md#prefix-branch-names-with-issue-numbers) to streamline merge request creation. +GitLab enforces [branch naming rules](../repository/branches/index.md#name-your-branch) +to prevent problems, and provides +[branch naming patterns](../repository/branches/index.md#prefix-branch-names-with-issue-numbers) +to streamline merge request creation. ## From the merge request list diff --git a/doc/user/project/repository/branches/index.md b/doc/user/project/repository/branches/index.md index a128155b65c..af260fa2554 100644 --- a/doc/user/project/repository/branches/index.md +++ b/doc/user/project/repository/branches/index.md @@ -157,13 +157,30 @@ To view the **Branch rules overview** list: ## Name your branch -If you follow GitLab standards for [naming branches](#prefix-branch-names-with-issue-numbers), -and configure branch names that follow these standards, GitLab can streamline your workflow: +Git enforces [branch name rules](https://git-scm.com/docs/git-check-ref-format) +to help ensure branch names remain compatible with other tools. GitLab +adds extra requirements for branch names, and provides benefits for well-structured branch names. -- Connect a merge request to its parent issue. -- Connect a branch to an issue. -- [Close the issue](../../issues/managing_issues.md#closing-issues-automatically) - when the connected merge request closes, and the connected branch merges. +GitLab enforces these additional rules on all branches: + +- No spaces are allowed in branch names. +- Branch names with 40 hexadecimal characters are prohibited, because they are similar to Git commit hashes. + +Common software packages, like Docker, may enforce +[additional branch naming restrictions](../../../../administration/packages/container_registry.md#docker-connection-error). + +For best compatibility with other software packages, use only numbers, hyphens (`-`), +underscores (`_`), and lower-case letters from the ASCII standard table. You +can use forward slashes (`/`) and emoji in branch names, but compatibility with other +software packages cannot be guaranteed. + +Branch names with specific formatting offer extra benefits: + +- Streamline your merge request workflow by + [prefixing branch names with issue numbers](#prefix-branch-names-with-issue-numbers). +- Automate [branch protections](../../protected_branches.md) based on branch name. +- Test branch names with [push rules](../push_rules.md) before branches are pushed up to GitLab. +- Define which [CI/CD jobs](../../../../ci/jobs/index.md) to run on merge requests. ### Configure default pattern for branch names from issues @@ -188,12 +205,14 @@ To change the default pattern for branches created from issues: To streamline the creation of merge requests, start your branch name with an issue number. GitLab uses the issue number to import data into the merge request: -- The issue is marked as related. The issue and merge request display links to each other. +- The issue is marked as related to the merge request. The issue and merge request + display links to each other. +- The branch is connected to the issue. - If your project is configured with a [default closing pattern](../../issues/managing_issues.md#default-closing-pattern), merging the merge request [also closes](../../issues/managing_issues.md#closing-issues-automatically) the related issue. -- The issue milestone and labels are copied to the merge request. +- Issue milestone and labels are copied to the merge request. ## Compare branches diff --git a/doc/user/project/repository/push_rules.md b/doc/user/project/repository/push_rules.md index 7ae085812ae..28afba375fc 100644 --- a/doc/user/project/repository/push_rules.md +++ b/doc/user/project/repository/push_rules.md @@ -13,7 +13,7 @@ can and can't be pushed to your repository. While GitLab offers - Evaluating the contents of a commit. - Confirming commit messages match expected formats. -- Enforcing branch name rules. +- Enforcing [branch name rules](branches/index.md#name-your-branch). - Evaluating the details of files. - Preventing Git tag removal. diff --git a/lib/gitlab/discussions_diff/highlight_cache.rb b/lib/gitlab/discussions_diff/highlight_cache.rb index 14cb773251b..037a4c967e8 100644 --- a/lib/gitlab/discussions_diff/highlight_cache.rb +++ b/lib/gitlab/discussions_diff/highlight_cache.rb @@ -16,11 +16,11 @@ module Gitlab def write_multiple(mapping) with_redis do |redis| Gitlab::Instrumentation::RedisClusterValidator.allow_cross_slot_commands do - redis.multi do |multi| + redis.pipelined do |pipelined| mapping.each do |raw_key, value| key = cache_key_for(raw_key) - multi.set(key, gzip_compress(value.to_json), ex: EXPIRATION) + pipelined.set(key, gzip_compress(value.to_json), ex: EXPIRATION) end end end diff --git a/lib/gitlab/quick_actions/extractor.rb b/lib/gitlab/quick_actions/extractor.rb index 2e4817e6b17..015dbe7063c 100644 --- a/lib/gitlab/quick_actions/extractor.rb +++ b/lib/gitlab/quick_actions/extractor.rb @@ -63,11 +63,12 @@ module Gitlab #{CODE_REGEX} | #{INLINE_CODE_REGEX} | #{HTML_BLOCK_REGEX} | #{QUOTE_BLOCK_REGEX} }mix.freeze - attr_reader :command_definitions + attr_reader :command_definitions, :keep_actions - def initialize(command_definitions) + def initialize(command_definitions, keep_actions: false) @command_definitions = command_definitions @commands_regex = {} + @keep_actions = keep_actions end # Extracts commands from content and return an array of commands. @@ -76,8 +77,8 @@ module Gitlab # ['command1'], # ['command3', 'arg1 arg2'], # ] - # The command and the arguments are stripped. - # The original command text is removed from the given `content`. + # The original command text and arguments are removed from the given `content`, + # unless `keep_actions` is true. # # Usage: # ``` @@ -85,6 +86,11 @@ module Gitlab # msg = %(hello\n/labels ~foo ~"bar baz"\nworld) # commands = extractor.extract_commands(msg) #=> [['labels', '~foo ~"bar baz"']] # msg #=> "hello\nworld" + # + # extractor = Gitlab::QuickActions::Extractor.new([:open, :assign, :labels], keep_actions: true) + # msg = %(hello\n/labels ~foo ~"bar baz"\nworld) + # commands = extractor.extract_commands(msg) #=> [['labels', '~foo ~"bar baz"']] + # msg #=> "hello\n/labels ~foo ~"bar baz"\n\nworld" # ``` def extract_commands(content, only: nil) return [content, []] unless content @@ -138,6 +144,10 @@ module Gitlab if redact output = "`/#{matched_text[:cmd]}#{" " + matched_text[:arg] if matched_text[:arg]}`" output += "\n" if matched_text[0].include?("\n") + elsif keep_actions + # requires an additional newline so that when rendered, it appears + # on its own line, rather than all on the same line + output = "\n#{matched_text[0]}\n" end end diff --git a/locale/gitlab.pot b/locale/gitlab.pot index aca04ffa206..96d88983253 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -404,6 +404,11 @@ msgid_plural "%d seconds" msgstr[0] "" msgstr[1] "" +msgid "%d snippet" +msgid_plural "%d snippets" +msgstr[0] "" +msgstr[1] "" + msgid "%d stage" msgid_plural "%d stages" msgstr[0] "" @@ -2070,6 +2075,102 @@ msgstr "" msgid "AbuseReports|No reports found" msgstr "" +msgid "AbuseReport|%{reportLinkStart}Reported%{reportLinkEnd} for %{category} %{timeAgo}." +msgstr "" + +msgid "AbuseReport|Abuse reports" +msgstr "" + +msgid "AbuseReport|Activity" +msgstr "" + +msgid "AbuseReport|Admin profile" +msgstr "" + +msgid "AbuseReport|Card matches %{cardMatchesLinkStart}%{count} accounts%{cardMatchesLinkEnd}" +msgstr "" + +msgid "AbuseReport|Comments" +msgstr "" + +msgid "AbuseReport|Credit card" +msgstr "" + +msgid "AbuseReport|Email" +msgstr "" + +msgid "AbuseReport|Go to comment" +msgstr "" + +msgid "AbuseReport|Go to content" +msgstr "" + +msgid "AbuseReport|Go to issue" +msgstr "" + +msgid "AbuseReport|Go to merge request" +msgstr "" + +msgid "AbuseReport|Go to profile" +msgstr "" + +msgid "AbuseReport|Groups" +msgstr "" + +msgid "AbuseReport|Last login" +msgstr "" + +msgid "AbuseReport|Member since" +msgstr "" + +msgid "AbuseReport|No user found" +msgstr "" + +msgid "AbuseReport|Normal location" +msgstr "" + +msgid "AbuseReport|Phone" +msgstr "" + +msgid "AbuseReport|Registered with name %{name}." +msgstr "" + +msgid "AbuseReport|Reported by" +msgstr "" + +msgid "AbuseReport|Reported by %{name} for %{category}." +msgstr "" + +msgid "AbuseReport|Reported comment" +msgstr "" + +msgid "AbuseReport|Reported content" +msgstr "" + +msgid "AbuseReport|Reported issue" +msgstr "" + +msgid "AbuseReport|Reported merge request" +msgstr "" + +msgid "AbuseReport|Reported profile" +msgstr "" + +msgid "AbuseReport|Screenshot of reported abuse" +msgstr "" + +msgid "AbuseReport|Snippets" +msgstr "" + +msgid "AbuseReport|Tier" +msgstr "" + +msgid "AbuseReport|Verification" +msgstr "" + +msgid "AbuseReport|View screenshot" +msgstr "" + msgid "Accept invitation" msgstr "" diff --git a/spec/controllers/import/bulk_imports_controller_spec.rb b/spec/controllers/import/bulk_imports_controller_spec.rb index 4e7f9572f65..c5e5aa03669 100644 --- a/spec/controllers/import/bulk_imports_controller_spec.rb +++ b/spec/controllers/import/bulk_imports_controller_spec.rb @@ -213,36 +213,41 @@ RSpec.describe Import::BulkImportsController, feature_category: :importers do end end - context 'when host url is local or not http' do - %w[https://localhost:3000 http://192.168.0.1 ftp://testing].each do |url| - before do - stub_application_setting(allow_local_requests_from_web_hooks_and_services: false) - - session[:bulk_import_gitlab_access_token] = 'test' - session[:bulk_import_gitlab_url] = url - end + shared_examples 'unacceptable url' do |url, expected_error| + before do + stub_application_setting(allow_local_requests_from_web_hooks_and_services: false) - it 'denies network request' do - get :status + session[:bulk_import_gitlab_access_token] = 'test' + session[:bulk_import_gitlab_url] = url + end - expect(controller).to redirect_to(new_group_path(anchor: 'import-group-pane')) - expect(flash[:alert]).to eq('Specified URL cannot be used: "Only allowed schemes are http, https"') - end + it 'denies network request' do + get :status + expect(controller).to redirect_to(new_group_path(anchor: 'import-group-pane')) + expect(flash[:alert]).to eq("Specified URL cannot be used: \"#{expected_error}\"") end + end + + context 'when host url is local or not http' do + include_examples 'unacceptable url', 'https://localhost:3000', "Only allowed schemes are http, https" + include_examples 'unacceptable url', 'http://192.168.0.1', "Only allowed schemes are http, https" + include_examples 'unacceptable url', 'ftp://testing', "Only allowed schemes are http, https" context 'when local requests are allowed' do %w[https://localhost:3000 http://192.168.0.1].each do |url| - before do - stub_application_setting(allow_local_requests_from_web_hooks_and_services: true) + context "with #{url}" do + before do + stub_application_setting(allow_local_requests_from_web_hooks_and_services: true) - session[:bulk_import_gitlab_access_token] = 'test' - session[:bulk_import_gitlab_url] = url - end + session[:bulk_import_gitlab_access_token] = 'test' + session[:bulk_import_gitlab_url] = url + end - it 'allows network request' do - get :status + it 'allows network request' do + get :status - expect(response).to have_gitlab_http_status(:ok) + expect(response).to have_gitlab_http_status(:ok) + end end end end diff --git a/spec/features/dashboard/issues_filter_spec.rb b/spec/features/dashboard/issues_filter_spec.rb index e67e04ee0b0..964ac2f714d 100644 --- a/spec/features/dashboard/issues_filter_spec.rb +++ b/spec/features/dashboard/issues_filter_spec.rb @@ -96,7 +96,7 @@ RSpec.describe 'Dashboard Issues filtering', :js, feature_category: :team_planni visit issues_dashboard_path(assignee_username: user.username) end - it 'remembers last sorting value' do + it 'remembers last sorting value', quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/408749' do click_button 'Created date' click_button 'Updated date' diff --git a/spec/frontend/admin/abuse_report/components/abuse_report_app_spec.js b/spec/frontend/admin/abuse_report/components/abuse_report_app_spec.js new file mode 100644 index 00000000000..cabbb5e1591 --- /dev/null +++ b/spec/frontend/admin/abuse_report/components/abuse_report_app_spec.js @@ -0,0 +1,76 @@ +import { shallowMount } from '@vue/test-utils'; +import AbuseReportApp from '~/admin/abuse_report/components/abuse_report_app.vue'; +import ReportHeader from '~/admin/abuse_report/components/report_header.vue'; +import UserDetails from '~/admin/abuse_report/components/user_details.vue'; +import ReportedContent from '~/admin/abuse_report/components/reported_content.vue'; +import HistoryItems from '~/admin/abuse_report/components/history_items.vue'; +import { mockAbuseReport } from '../mock_data'; + +describe('AbuseReportApp', () => { + let wrapper; + + const findReportHeader = () => wrapper.findComponent(ReportHeader); + const findUserDetails = () => wrapper.findComponent(UserDetails); + const findReportedContent = () => wrapper.findComponent(ReportedContent); + const findHistoryItems = () => wrapper.findComponent(HistoryItems); + + const createComponent = (props = {}) => { + wrapper = shallowMount(AbuseReportApp, { + propsData: { + abuseReport: mockAbuseReport, + ...props, + }, + }); + }; + + beforeEach(() => { + createComponent(); + }); + + describe('ReportHeader', () => { + it('renders ReportHeader', () => { + expect(findReportHeader().props('user')).toBe(mockAbuseReport.user); + expect(findReportHeader().props('actions')).toBe(mockAbuseReport.actions); + }); + + describe('when no user is present', () => { + beforeEach(() => { + createComponent({ + abuseReport: { ...mockAbuseReport, user: undefined }, + }); + }); + + it('does not render the ReportHeader', () => { + expect(findReportHeader().exists()).toBe(false); + }); + }); + }); + + describe('UserDetails', () => { + it('renders UserDetails', () => { + expect(findUserDetails().props('user')).toBe(mockAbuseReport.user); + }); + + describe('when no user is present', () => { + beforeEach(() => { + createComponent({ + abuseReport: { ...mockAbuseReport, user: undefined }, + }); + }); + + it('does not render the UserDetails', () => { + expect(findUserDetails().exists()).toBe(false); + }); + }); + }); + + it('renders ReportedContent', () => { + expect(findReportedContent().props('report')).toBe(mockAbuseReport.report); + expect(findReportedContent().props('reporter')).toBe(mockAbuseReport.reporter); + }); + + it('renders HistoryItems', () => { + expect(findHistoryItems().props('report')).toBe(mockAbuseReport.report); + expect(findHistoryItems().props('reporter')).toBe(mockAbuseReport.reporter); + }); +}); diff --git a/spec/frontend/admin/abuse_report/components/history_items_spec.js b/spec/frontend/admin/abuse_report/components/history_items_spec.js new file mode 100644 index 00000000000..86e994fdc57 --- /dev/null +++ b/spec/frontend/admin/abuse_report/components/history_items_spec.js @@ -0,0 +1,66 @@ +import { GlSprintf } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import { sprintf } from '~/locale'; +import HistoryItems from '~/admin/abuse_report/components/history_items.vue'; +import HistoryItem from '~/vue_shared/components/registry/history_item.vue'; +import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; +import { HISTORY_ITEMS_I18N } from '~/admin/abuse_report/constants'; +import { mockAbuseReport } from '../mock_data'; + +describe('HistoryItems', () => { + let wrapper; + + const { report, reporter } = mockAbuseReport; + + const findHistoryItem = () => wrapper.findComponent(HistoryItem); + const findTimeAgo = () => wrapper.findComponent(TimeAgoTooltip); + + const createComponent = (props = {}) => { + wrapper = shallowMount(HistoryItems, { + propsData: { + report, + reporter, + ...props, + }, + stubs: { + GlSprintf, + }, + }); + }; + + beforeEach(() => { + createComponent(); + }); + + it('renders the icon', () => { + expect(findHistoryItem().props('icon')).toBe('warning'); + }); + + describe('rendering the title', () => { + it('renders the reporters name and the category', () => { + const title = sprintf(HISTORY_ITEMS_I18N.reportedByForCategory, { + name: reporter.name, + category: report.category, + }); + expect(findHistoryItem().text()).toContain(title); + }); + + describe('when the reporter is not defined', () => { + beforeEach(() => { + createComponent({ reporter: undefined }); + }); + + it('renders the `No user found` as the reporters name and the category', () => { + const title = sprintf(HISTORY_ITEMS_I18N.reportedByForCategory, { + name: HISTORY_ITEMS_I18N.deletedReporter, + category: report.category, + }); + expect(findHistoryItem().text()).toContain(title); + }); + }); + }); + + it('renders the time-ago tooltip', () => { + expect(findTimeAgo().props('time')).toBe(report.reportedAt); + }); +}); diff --git a/spec/frontend/admin/abuse_report/components/report_header_spec.js b/spec/frontend/admin/abuse_report/components/report_header_spec.js new file mode 100644 index 00000000000..d584cab05b3 --- /dev/null +++ b/spec/frontend/admin/abuse_report/components/report_header_spec.js @@ -0,0 +1,59 @@ +import { GlAvatar, GlLink, GlButton } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import ReportHeader from '~/admin/abuse_report/components/report_header.vue'; +import AbuseReportActions from '~/admin/abuse_reports/components/abuse_report_actions.vue'; +import { REPORT_HEADER_I18N } from '~/admin/abuse_report/constants'; +import { mockAbuseReport } from '../mock_data'; + +describe('ReportHeader', () => { + let wrapper; + + const { user, actions } = mockAbuseReport; + + const findAvatar = () => wrapper.findComponent(GlAvatar); + const findLink = () => wrapper.findComponent(GlLink); + const findButton = () => wrapper.findComponent(GlButton); + const findActions = () => wrapper.findComponent(AbuseReportActions); + + const createComponent = (props = {}) => { + wrapper = shallowMount(ReportHeader, { + propsData: { + user, + actions, + ...props, + }, + }); + }; + + beforeEach(() => { + createComponent(); + }); + + it('renders the users avatar', () => { + expect(findAvatar().props('src')).toBe(user.avatarUrl); + }); + + it('renders the users name', () => { + expect(wrapper.html()).toContain(user.name); + }); + + it('renders a link to the users profile page', () => { + const link = findLink(); + + expect(link.attributes('href')).toBe(user.path); + expect(link.text()).toBe(`@${user.username}`); + }); + + it('renders a button with a link to the users admin path', () => { + const button = findButton(); + + expect(button.attributes('href')).toBe(user.adminPath); + expect(button.text()).toBe(REPORT_HEADER_I18N.adminProfile); + }); + + it('renders the actions', () => { + const actionsComponent = findActions(); + + expect(actionsComponent.props('report')).toMatchObject(actions); + }); +}); diff --git a/spec/frontend/admin/abuse_report/components/reported_content_spec.js b/spec/frontend/admin/abuse_report/components/reported_content_spec.js new file mode 100644 index 00000000000..471310e01d5 --- /dev/null +++ b/spec/frontend/admin/abuse_report/components/reported_content_spec.js @@ -0,0 +1,188 @@ +import { GlSprintf, GlButton, GlModal, GlCard, GlAvatar, GlLink } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { sprintf } from '~/locale'; +import { renderGFM } from '~/behaviors/markdown/render_gfm'; +import ReportedContent from '~/admin/abuse_report/components/reported_content.vue'; +import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; +import { REPORTED_CONTENT_I18N } from '~/admin/abuse_report/constants'; +import { mockAbuseReport } from '../mock_data'; + +jest.mock('~/behaviors/markdown/render_gfm'); + +const modalId = 'abuse-report-screenshot-modal'; + +describe('ReportedContent', () => { + let wrapper; + + const { report, reporter } = { ...mockAbuseReport }; + + const findScreenshotButton = () => wrapper.findByTestId('screenshot-button'); + const findReportUrlButton = () => wrapper.findByTestId('report-url-button'); + const findModal = () => wrapper.findComponent(GlModal); + const findCard = () => wrapper.findComponent(GlCard); + const findCardHeader = () => findCard().find('.js-test-card-header'); + const findCardBody = () => findCard().find('.js-test-card-body'); + const findCardFooter = () => findCard().find('.js-test-card-footer'); + const findAvatar = () => findCardFooter().findComponent(GlAvatar); + const findProfileLink = () => findCardFooter().findComponent(GlLink); + const findTimeAgo = () => findCardFooter().findComponent(TimeAgoTooltip); + + const createComponent = (props = {}) => { + wrapper = shallowMountExtended(ReportedContent, { + propsData: { + report, + reporter, + ...props, + }, + stubs: { + GlSprintf, + GlButton, + GlCard, + }, + }); + }; + + beforeEach(() => { + createComponent(); + }); + + it('renders the reported type', () => { + expect(wrapper.html()).toContain(sprintf(REPORTED_CONTENT_I18N.reportTypes[report.type])); + }); + + describe('when the type is unknown', () => { + beforeEach(() => { + createComponent({ report: { ...report, type: null } }); + }); + + it('renders a header with a generic text content', () => { + expect(wrapper.html()).toContain(sprintf(REPORTED_CONTENT_I18N.reportTypes.unknown)); + }); + }); + + describe('showing the screenshot', () => { + describe('when the report contains a screenshot', () => { + it('renders a button to show the screenshot', () => { + expect(findScreenshotButton().text()).toBe(REPORTED_CONTENT_I18N.viewScreenshot); + }); + + it('renders a modal with the corrrect id and title', () => { + const modal = findModal(); + + expect(modal.props('title')).toBe(REPORTED_CONTENT_I18N.screenshotTitle); + expect(modal.props('modalId')).toBe(modalId); + }); + + it('contains an image with the screenshot', () => { + expect(findModal().find('img').attributes('src')).toBe(report.screenshot); + expect(findModal().find('img').attributes('alt')).toBe( + REPORTED_CONTENT_I18N.screenshotTitle, + ); + }); + + it('opens the modal when clicking the button', async () => { + const modal = findModal(); + + expect(modal.props('visible')).toBe(false); + + await findScreenshotButton().trigger('click'); + + expect(modal.props('visible')).toBe(true); + }); + }); + + describe('when the report does not contain a screenshot', () => { + beforeEach(() => { + createComponent({ report: { ...report, screenshot: '' } }); + }); + + it('does not render a button and a modal', () => { + expect(findScreenshotButton().exists()).toBe(false); + expect(findModal().exists()).toBe(false); + }); + }); + }); + + describe('showing a button to open the reported URL', () => { + describe('when the report contains a URL', () => { + it('renders a button with a link to the reported URL', () => { + expect(findReportUrlButton().text()).toBe( + sprintf(REPORTED_CONTENT_I18N.goToType[report.type]), + ); + }); + }); + + describe('when the report type is unknown', () => { + beforeEach(() => { + createComponent({ report: { ...report, type: null } }); + }); + + it('renders a button with a generic text content', () => { + expect(findReportUrlButton().text()).toBe(sprintf(REPORTED_CONTENT_I18N.goToType.unknown)); + }); + }); + + describe('when the report contains no URL', () => { + beforeEach(() => { + createComponent({ report: { ...report, url: '' } }); + }); + + it('does not render a button with a link to the reported URL', () => { + expect(findReportUrlButton().exists()).toBe(false); + }); + }); + }); + + describe('rendering the card header', () => { + describe('when the report contains the reported content', () => { + it('renders the content', () => { + expect(findCardHeader().text()).toBe(report.content.replace(/<\/?[^>]+>/g, '')); + }); + + it('renders gfm', () => { + expect(renderGFM).toHaveBeenCalled(); + }); + }); + + describe('when the report does not contain the reported content', () => { + beforeEach(() => { + createComponent({ report: { ...report, content: '' } }); + }); + + it('does not render the card header', () => { + expect(findCardHeader().exists()).toBe(false); + }); + }); + }); + + describe('rendering the card body', () => { + it('renders the reported by', () => { + expect(findCardBody().text()).toBe(REPORTED_CONTENT_I18N.reportedBy); + }); + }); + + describe('rendering the card footer', () => { + it('renders the reporters avatar', () => { + expect(findAvatar().props('src')).toBe(reporter.avatarUrl); + }); + + it('renders the users name', () => { + expect(findCardFooter().text()).toContain(reporter.name); + }); + + it('renders a link to the users profile page', () => { + const link = findProfileLink(); + + expect(link.attributes('href')).toBe(reporter.path); + expect(link.text()).toBe(`@${reporter.username}`); + }); + + it('renders the time-ago tooltip', () => { + expect(findTimeAgo().props('time')).toBe(report.reportedAt); + }); + + it('renders the message', () => { + expect(findCardFooter().text()).toContain(report.message); + }); + }); +}); diff --git a/spec/frontend/admin/abuse_report/components/user_detail_spec.js b/spec/frontend/admin/abuse_report/components/user_detail_spec.js new file mode 100644 index 00000000000..d9e02bc96e2 --- /dev/null +++ b/spec/frontend/admin/abuse_report/components/user_detail_spec.js @@ -0,0 +1,66 @@ +import { shallowMount } from '@vue/test-utils'; +import UserDetail from '~/admin/abuse_report/components/user_detail.vue'; + +describe('UserDetail', () => { + let wrapper; + + const label = 'user detail label'; + const value = 'user detail value'; + + const createComponent = (props = {}, slots = {}) => { + wrapper = shallowMount(UserDetail, { + propsData: { + label, + value, + ...props, + }, + slots, + }); + }; + + beforeEach(() => { + createComponent(); + }); + + describe('UserDetail', () => { + it('renders the label', () => { + expect(wrapper.text()).toContain(label); + }); + + describe('rendering the value', () => { + const slots = { + default: ['slot provided user detail'], + }; + + describe('when `value` property and no default slot is provided', () => { + it('renders the `value` as content', () => { + expect(wrapper.text()).toContain(value); + }); + }); + + describe('when default slot and no `value` property is provided', () => { + beforeEach(() => { + createComponent({ label, value: null }, slots); + }); + + it('renders the content provided via the default slot', () => { + expect(wrapper.text()).toContain(slots.default[0]); + }); + }); + + describe('when `value` property and default slot are both provided', () => { + beforeEach(() => { + createComponent({ label, value }, slots); + }); + + it('does not render `value` as content', () => { + expect(wrapper.text()).not.toContain(value); + }); + + it('renders the content provided via the default slot', () => { + expect(wrapper.text()).toContain(slots.default[0]); + }); + }); + }); + }); +}); diff --git a/spec/frontend/admin/abuse_report/components/user_details_spec.js b/spec/frontend/admin/abuse_report/components/user_details_spec.js new file mode 100644 index 00000000000..ca499fbaa6e --- /dev/null +++ b/spec/frontend/admin/abuse_report/components/user_details_spec.js @@ -0,0 +1,210 @@ +import { GlLink, GlSprintf } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { sprintf } from '~/locale'; +import UserDetails from '~/admin/abuse_report/components/user_details.vue'; +import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; +import { USER_DETAILS_I18N } from '~/admin/abuse_report/constants'; +import { mockAbuseReport } from '../mock_data'; + +describe('UserDetails', () => { + let wrapper; + + const { user } = mockAbuseReport; + + const findUserDetail = (attribute) => wrapper.findByTestId(attribute); + const findUserDetailLabel = (attribute) => findUserDetail(attribute).props('label'); + const findUserDetailValue = (attribute) => findUserDetail(attribute).props('value'); + const findLinkIn = (component) => component.findComponent(GlLink); + const findLinkFor = (attribute) => findLinkIn(findUserDetail(attribute)); + const findTimeIn = (component) => component.findComponent(TimeAgoTooltip).props('time'); + const findTimeFor = (attribute) => findTimeIn(findUserDetail(attribute)); + const findOtherReport = (index) => wrapper.findByTestId(`other-report-${index}`); + + const createComponent = (props = {}) => { + wrapper = shallowMountExtended(UserDetails, { + propsData: { + user, + ...props, + }, + stubs: { + GlSprintf, + }, + }); + }; + + beforeEach(() => { + createComponent(); + }); + + describe('createdAt', () => { + it('renders the users createdAt with the correct label', () => { + expect(findUserDetailLabel('createdAt')).toBe(USER_DETAILS_I18N.createdAt); + expect(findTimeFor('createdAt')).toBe(user.createdAt); + }); + }); + + describe('email', () => { + it('renders the users email with the correct label', () => { + expect(findUserDetailLabel('email')).toBe(USER_DETAILS_I18N.email); + expect(findLinkFor('email').attributes('href')).toBe(`mailto:${user.email}`); + expect(findLinkFor('email').text()).toBe(user.email); + }); + }); + + describe('plan', () => { + it('renders the users plan with the correct label', () => { + expect(findUserDetailLabel('plan')).toBe(USER_DETAILS_I18N.plan); + expect(findUserDetailValue('plan')).toBe(user.plan); + }); + }); + + describe('verification', () => { + it('renders the users verification with the correct label', () => { + expect(findUserDetailLabel('verification')).toBe(USER_DETAILS_I18N.verification); + expect(findUserDetailValue('verification')).toBe('Email, Credit card'); + }); + }); + + describe('creditCard', () => { + it('renders the correct label', () => { + expect(findUserDetailLabel('creditCard')).toBe(USER_DETAILS_I18N.creditCard); + }); + + it('renders the users name', () => { + expect(findUserDetail('creditCard').text()).toContain( + sprintf(USER_DETAILS_I18N.registeredWith, { ...user.creditCard }), + ); + + expect(findUserDetail('creditCard').text()).toContain(user.creditCard.name); + }); + + describe('similar credit cards', () => { + it('renders the number of similar records', () => { + expect(findUserDetail('creditCard').text()).toContain( + sprintf('Card matches %{similarRecordsCount} accounts', { ...user.creditCard }), + ); + }); + + it('renders a link to the matching cards', () => { + expect(findLinkFor('creditCard').attributes('href')).toBe(user.creditCard.cardMatchesLink); + + expect(findLinkFor('creditCard').text()).toBe( + sprintf('%{similarRecordsCount} accounts', { ...user.creditCard }), + ); + + expect(findLinkFor('creditCard').text()).toContain( + user.creditCard.similarRecordsCount.toString(), + ); + }); + + describe('when the number of similar credit cards is less than 2', () => { + beforeEach(() => { + createComponent({ + user: { ...user, creditCard: { ...user.creditCard, similarRecordsCount: 1 } }, + }); + }); + + it('does not render the number of similar records', () => { + expect(findUserDetail('creditCard').text()).not.toContain( + sprintf('Card matches %{similarRecordsCount} accounts', { ...user.creditCard }), + ); + }); + + it('does not render a link to the matching cards', () => { + expect(findLinkFor('creditCard').exists()).toBe(false); + }); + }); + }); + + describe('when the users creditCard is blank', () => { + beforeEach(() => { + createComponent({ + user: { ...user, creditCard: undefined }, + }); + }); + + it('does not render the users creditCard', () => { + expect(findUserDetail('creditCard').exists()).toBe(false); + }); + }); + }); + + describe('otherReports', () => { + it('renders the correct label', () => { + expect(findUserDetailLabel('otherReports')).toBe(USER_DETAILS_I18N.otherReports); + }); + + describe.each(user.otherReports)('renders a line for report %#', (otherReport) => { + const index = user.otherReports.indexOf(otherReport); + + it('renders the category', () => { + expect(findOtherReport(index).text()).toContain( + sprintf('Reported for %{category}', { ...otherReport }), + ); + }); + + it('renders a link to the report', () => { + expect(findLinkIn(findOtherReport(index)).attributes('href')).toBe(otherReport.reportPath); + }); + + it('renders the time it was created', () => { + expect(findTimeIn(findOtherReport(index))).toBe(otherReport.createdAt); + }); + }); + + describe('when the users otherReports is empty', () => { + beforeEach(() => { + createComponent({ + user: { ...user, otherReports: [] }, + }); + }); + + it('does not render the users otherReports', () => { + expect(findUserDetail('otherReports').exists()).toBe(false); + }); + }); + }); + + describe('normalLocation', () => { + it('renders the correct label', () => { + expect(findUserDetailLabel('normalLocation')).toBe(USER_DETAILS_I18N.normalLocation); + }); + + describe('when the users mostUsedIp is blank', () => { + it('renders the users lastSignInIp', () => { + expect(findUserDetailValue('normalLocation')).toBe(user.lastSignInIp); + }); + }); + + describe('when the users mostUsedIp is not blank', () => { + const mostUsedIp = '127.0.0.1'; + + beforeEach(() => { + createComponent({ + user: { ...user, mostUsedIp }, + }); + }); + + it('renders the users mostUsedIp', () => { + expect(findUserDetailValue('normalLocation')).toBe(mostUsedIp); + }); + }); + }); + + describe('lastSignInIp', () => { + it('renders the users lastSignInIp with the correct label', () => { + expect(findUserDetailLabel('lastSignInIp')).toBe(USER_DETAILS_I18N.lastSignInIp); + expect(findUserDetailValue('lastSignInIp')).toBe(user.lastSignInIp); + }); + }); + + it.each(['snippets', 'groups', 'notes'])( + 'renders the users %s with the correct label', + (attribute) => { + expect(findUserDetailLabel(attribute)).toBe(USER_DETAILS_I18N[attribute]); + expect(findUserDetailValue(attribute)).toBe( + USER_DETAILS_I18N[`${attribute}Count`](user[`${attribute}Count`]), + ); + }, + ); +}); diff --git a/spec/frontend/admin/abuse_report/mock_data.js b/spec/frontend/admin/abuse_report/mock_data.js new file mode 100644 index 00000000000..ee0f0967735 --- /dev/null +++ b/spec/frontend/admin/abuse_report/mock_data.js @@ -0,0 +1,61 @@ +export const mockAbuseReport = { + user: { + username: 'spamuser417', + name: 'Sp4m User', + createdAt: '2023-03-29T09:30:23.885Z', + email: 'sp4m@spam.com', + lastActivityOn: '2023-04-02', + avatarUrl: 'https://www.gravatar.com/avatar/a2579caffc69ea5d7606f9dd9d8504ba?s=80&d=identicon', + path: '/spamuser417', + adminPath: '/admin/users/spamuser417', + plan: 'Free', + verificationState: { email: true, phone: false, creditCard: true }, + creditCard: { + name: 'S. User', + similarRecordsCount: 2, + cardMatchesLink: '/admin/users/spamuser417/card_match', + }, + otherReports: [ + { + category: 'offensive', + createdAt: '2023-02-28T10:09:54.982Z', + reportPath: '/admin/abuse_reports/29', + }, + { + category: 'crypto', + createdAt: '2023-03-31T11:57:11.849Z', + reportPath: '/admin/abuse_reports/31', + }, + ], + mostUsedIp: null, + lastSignInIp: '::1', + snippetsCount: 0, + groupsCount: 0, + notesCount: 6, + }, + reporter: { + username: 'reporter', + name: 'R Porter', + avatarUrl: 'https://www.gravatar.com/avatar/a2579caffc69ea5d7606f9dd9d8504ba?s=80&d=identicon', + path: '/reporter', + }, + report: { + message: 'This is obvious spam', + reportedAt: '2023-03-29T09:39:50.502Z', + category: 'spam', + type: 'comment', + content: + '<p data-sourcepos="1:1-1:772" dir="auto">Farmers Toy Sale ON NOW | SHOP CATALOGUE ... 50% off Kids\' Underwear by Hanes ... BUY 1 GET 1 HALF PRICE on Women\'s Clothing by Whistle, Ella Clothing Farmers Toy Sale ON <a href="http://www.farmers.com" rel="nofollow noreferrer noopener" target="_blank">www.farmers.com</a> | SHOP CATALOGUE ... 50% off Kids\' Underwear by Hanes ... BUY 1 GET 1 HALF PRICE on Women\'s Clothing by Whistle, Ella Clothing Farmers Toy Sale ON NOW | SHOP CATALOGUE ... 50% off Kids\' Underwear by Farmers Toy Sale ON NOW | SHOP CATALOGUE ... 50% off Kids\' Underwear by Hanes ... BUY 1 GET 1 HALF PRICE on Women\'s Clothing by Whistle, Ella Clothing Farmers Toy Sale ON <a href="http://www.farmers.com" rel="nofollow noreferrer noopener" target="_blank">www.farmers.com</a> | SHOP CATALOGUE ... 50% off Kids\' Underwear by Hanes ... BUY 1 GET 1 HALF PRICE on Women\'s Clothing by Whistle, Ella Clothing Farmers Toy Sale ON NOW | SHOP CATALOGUE ... 50% off Kids\' Underwear by.</p>', + url: 'http://localhost:3000/spamuser417/project/-/merge_requests/1#note_1375', + screenshot: + '/uploads/-/system/abuse_report/screenshot/27/Screenshot_2023-03-30_at_16.56.37.png', + }, + actions: { + reportedUser: { name: 'Sp4m User', createdAt: '2023-03-29T09:30:23.885Z' }, + userBlocked: false, + blockUserPath: '/admin/users/spamuser417/block', + removeReportPath: '/admin/abuse_reports/27', + removeUserAndReportPath: '/admin/abuse_reports/27?remove_user=true', + redirectPath: '/admin/abuse_reports', + }, +}; diff --git a/spec/frontend/admin/abuse_reports/components/abuse_report_actions_spec.js b/spec/frontend/admin/abuse_reports/components/abuse_report_actions_spec.js index 2d0f00ea585..9708de69caa 100644 --- a/spec/frontend/admin/abuse_reports/components/abuse_report_actions_spec.js +++ b/spec/frontend/admin/abuse_reports/components/abuse_report_actions_spec.js @@ -3,15 +3,16 @@ import axios from 'axios'; import MockAdapter from 'axios-mock-adapter'; import { GlDisclosureDropdown, GlDisclosureDropdownItem, GlModal } from '@gitlab/ui'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; -import { useMockLocationHelper } from 'helpers/mock_window_location_helper'; import AbuseReportActions from '~/admin/abuse_reports/components/abuse_report_actions.vue'; import { HTTP_STATUS_OK } from '~/lib/utils/http_status'; +import { redirectTo, refreshCurrentPage } from '~/lib/utils/url_utility'; import { createAlert, VARIANT_SUCCESS } from '~/alert'; import { sprintf } from '~/locale'; import { ACTIONS_I18N } from '~/admin/abuse_reports/constants'; import { mockAbuseReports } from '../mock_data'; jest.mock('~/alert'); +jest.mock('~/lib/utils/url_utility'); describe('AbuseReportActions', () => { let wrapper; @@ -69,8 +70,6 @@ describe('AbuseReportActions', () => { describe('actions', () => { let axiosMock; - useMockLocationHelper(); - beforeEach(() => { axiosMock = new MockAdapter(axios); @@ -99,7 +98,25 @@ describe('AbuseReportActions', () => { findConfirmationModal().vm.$emit('primary'); await axios.waitForAll(); - expect(window.location.reload).toHaveBeenCalled(); + expect(refreshCurrentPage).toHaveBeenCalled(); + }); + + describe('when a redirect path is present', () => { + beforeEach(() => { + createComponent({ report: { ...report, redirectPath: '/redirect_path' } }); + }); + + it('redirects to the given path', async () => { + findRemoveUserAndReportButton().trigger('click'); + await nextTick(); + + axiosMock.onDelete(report.removeUserAndReportPath).reply(HTTP_STATUS_OK); + + findConfirmationModal().vm.$emit('primary'); + await axios.waitForAll(); + + expect(redirectTo).toHaveBeenCalledWith('/redirect_path'); + }); }); }); @@ -162,7 +179,23 @@ describe('AbuseReportActions', () => { await axios.waitForAll(); - expect(window.location.reload).toHaveBeenCalled(); + expect(refreshCurrentPage).toHaveBeenCalled(); + }); + + describe('when a redirect path is present', () => { + beforeEach(() => { + createComponent({ report: { ...report, redirectPath: '/redirect_path' } }); + }); + + it('redirects to the given path', async () => { + axiosMock.onDelete(report.removeReportPath).reply(HTTP_STATUS_OK); + + findRemoveReportButton().trigger('click'); + + await axios.waitForAll(); + + expect(redirectTo).toHaveBeenCalledWith('/redirect_path'); + }); }); }); }); diff --git a/spec/frontend/admin/abuse_reports/mock_data.js b/spec/frontend/admin/abuse_reports/mock_data.js index 90289757a74..ee9e56d043b 100644 --- a/spec/frontend/admin/abuse_reports/mock_data.js +++ b/spec/frontend/admin/abuse_reports/mock_data.js @@ -12,6 +12,7 @@ export const mockAbuseReports = [ removeUserAndReportPath: '/remove/user/mr_abuser/and/report/path', removeReportPath: '/remove/report/path', message: 'message 1', + reportPath: '/admin/abuse_reports/1', }, { category: 'phishing', @@ -26,5 +27,6 @@ export const mockAbuseReports = [ removeUserAndReportPath: '/remove/user/mr_phisher/and/report/path', removeReportPath: '/remove/report/path', message: 'message 2', + reportPath: '/admin/abuse_reports/2', }, ]; diff --git a/spec/frontend/ide/components/repo_tab_spec.js b/spec/frontend/ide/components/repo_tab_spec.js index b329baea783..d4f29b16a88 100644 --- a/spec/frontend/ide/components/repo_tab_spec.js +++ b/spec/frontend/ide/components/repo_tab_spec.js @@ -1,11 +1,10 @@ import { GlTab } from '@gitlab/ui'; -import { mount } from '@vue/test-utils'; import Vue from 'vue'; import Vuex from 'vuex'; import { stubComponent } from 'helpers/stub_component'; import RepoTab from '~/ide/components/repo_tab.vue'; -import { createRouter } from '~/ide/ide_router'; import { createStore } from '~/ide/stores'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; import { file } from '../helpers'; Vue.use(Vuex); @@ -17,36 +16,40 @@ const GlTabStub = stubComponent(GlTab, { describe('RepoTab', () => { let wrapper; let store; - let router; + const pushMock = jest.fn(); const findTab = () => wrapper.findComponent(GlTabStub); + const findCloseButton = () => wrapper.findByTestId('close-button'); function createComponent(propsData) { - wrapper = mount(RepoTab, { + wrapper = mountExtended(RepoTab, { store, propsData, stubs: { GlTab: GlTabStub, }, + mocks: { + $router: { + push: pushMock, + }, + }, }); } beforeEach(() => { store = createStore(); - router = createRouter(store); - jest.spyOn(router, 'push').mockImplementation(() => {}); }); it('renders a close link and a name link', () => { + const tab = file(); createComponent({ - tab: file(), + tab, }); - wrapper.vm.$store.state.openFiles.push(wrapper.vm.tab); - const close = wrapper.find('.multi-file-tab-close'); + store.state.openFiles.push(tab); const name = wrapper.find(`[title]`); - expect(close.html()).toContain('#close'); - expect(name.text().trim()).toEqual(wrapper.vm.tab.name); + expect(findCloseButton().html()).toContain('#close'); + expect(name.text()).toBe(tab.name); }); it('does not call openPendingTab when tab is active', async () => { @@ -58,35 +61,33 @@ describe('RepoTab', () => { }, }); - jest.spyOn(wrapper.vm, 'openPendingTab').mockImplementation(() => {}); + jest.spyOn(store, 'dispatch'); await findTab().vm.$emit('click'); - expect(wrapper.vm.openPendingTab).not.toHaveBeenCalled(); + expect(store.dispatch).not.toHaveBeenCalledWith('openPendingTab'); }); - it('fires clickFile when the link is clicked', () => { - createComponent({ - tab: file(), - }); - - jest.spyOn(wrapper.vm, 'clickFile').mockImplementation(() => {}); + it('fires clickFile when the link is clicked', async () => { + const { getters } = store; + const tab = file(); + createComponent({ tab }); - findTab().vm.$emit('click'); + await findTab().vm.$emit('click', tab); - expect(wrapper.vm.clickFile).toHaveBeenCalledWith(wrapper.vm.tab); + expect(pushMock).toHaveBeenCalledWith(getters.getUrlForPath(tab.path)); }); - it('calls closeFile when clicking close button', () => { - createComponent({ - tab: file(), - }); + it('calls closeFile when clicking close button', async () => { + const tab = file(); + createComponent({ tab }); + store.state.entries[tab.path] = tab; - jest.spyOn(wrapper.vm, 'closeFile').mockImplementation(() => {}); + jest.spyOn(store, 'dispatch'); - wrapper.find('.multi-file-tab-close').trigger('click'); + await findCloseButton().trigger('click'); - expect(wrapper.vm.closeFile).toHaveBeenCalledWith(wrapper.vm.tab); + expect(store.dispatch).toHaveBeenCalledWith('closeFile', tab); }); it('changes icon on hover', async () => { @@ -114,7 +115,7 @@ describe('RepoTab', () => { createComponent({ tab }); - expect(wrapper.find('button').attributes('aria-label')).toBe(closeLabel); + expect(findCloseButton().attributes('aria-label')).toBe(closeLabel); }); describe('locked file', () => { @@ -152,15 +153,15 @@ describe('RepoTab', () => { createComponent({ tab, }); - wrapper.vm.$store.state.openFiles.push(tab); - wrapper.vm.$store.state.changedFiles.push(tab); - wrapper.vm.$store.state.entries[tab.path] = tab; - wrapper.vm.$store.dispatch('setFileActive', tab.path); + store.state.openFiles.push(tab); + store.state.changedFiles.push(tab); + store.state.entries[tab.path] = tab; + store.dispatch('setFileActive', tab.path); - await wrapper.find('.multi-file-tab-close').trigger('click'); + await findCloseButton().trigger('click'); expect(tab.opened).toBe(false); - expect(wrapper.vm.$store.state.changedFiles).toHaveLength(1); + expect(store.state.changedFiles).toHaveLength(1); }); it('closes tab when clicking close btn', async () => { @@ -169,11 +170,11 @@ describe('RepoTab', () => { createComponent({ tab, }); - wrapper.vm.$store.state.openFiles.push(tab); - wrapper.vm.$store.state.entries[tab.path] = tab; - wrapper.vm.$store.dispatch('setFileActive', tab.path); + store.state.openFiles.push(tab); + store.state.entries[tab.path] = tab; + store.dispatch('setFileActive', tab.path); - await wrapper.find('.multi-file-tab-close').trigger('click'); + await findCloseButton().trigger('click'); expect(tab.opened).toBe(false); }); diff --git a/spec/frontend/vue_merge_request_widget/mr_widget_options_spec.js b/spec/frontend/vue_merge_request_widget/mr_widget_options_spec.js index 387a5dc8f1d..64fb2806447 100644 --- a/spec/frontend/vue_merge_request_widget/mr_widget_options_spec.js +++ b/spec/frontend/vue_merge_request_widget/mr_widget_options_spec.js @@ -162,9 +162,17 @@ describe('MrWidgetOptions', () => { describe('computed', () => { describe('componentName', () => { + // eslint-disable-next-line jest/no-disabled-tests + it.skip.each` + ${'merged'} | ${'mr-widget-merged'} + `('should translate $state into $componentName', ({ state, componentName }) => { + wrapper.vm.mr.state = state; + + expect(wrapper.vm.componentName).toEqual(componentName); + }); + it.each` state | componentName - ${'merged'} | ${'mr-widget-merged'} ${'conflicts'} | ${'mr-widget-conflicts'} ${'shaMismatch'} | ${'sha-mismatch'} `('should translate $state into $componentName', ({ state, componentName }) => { diff --git a/spec/frontend/vue_shared/components/markdown/markdown_editor_spec.js b/spec/frontend/vue_shared/components/markdown/markdown_editor_spec.js index 69dedd6b68a..e9d5da4edcf 100644 --- a/spec/frontend/vue_shared/components/markdown/markdown_editor_spec.js +++ b/spec/frontend/vue_shared/components/markdown/markdown_editor_spec.js @@ -106,6 +106,19 @@ describe('vue_shared/component/markdown/markdown_editor', () => { }); }); + it.each` + desc | supportsQuickActions + ${'passes render_quick_actions param to renderMarkdownPath if quick actions are enabled'} | ${true} + ${'does not pass render_quick_actions param to renderMarkdownPath if quick actions are disabled'} | ${false} + `('$desc', async ({ supportsQuickActions }) => { + buildWrapper({ propsData: { supportsQuickActions } }); + + await enableContentEditor(); + + expect(mock.history.post).toHaveLength(1); + expect(mock.history.post[0].url).toContain(`render_quick_actions=${supportsQuickActions}`); + }); + it('enables content editor switcher when contentEditorEnabled prop is true', () => { buildWrapper({ propsData: { enableContentEditor: true } }); diff --git a/spec/helpers/admin/abuse_reports_helper_spec.rb b/spec/helpers/admin/abuse_reports_helper_spec.rb index 83393cc641d..496b7361b6e 100644 --- a/spec/helpers/admin/abuse_reports_helper_spec.rb +++ b/spec/helpers/admin/abuse_reports_helper_spec.rb @@ -21,4 +21,14 @@ RSpec.describe Admin::AbuseReportsHelper, feature_category: :insider_threat do expect(data['categories']).to match_array(AbuseReport.categories.keys) end end + + describe '#abuse_report_data' do + let(:report) { build_stubbed(:abuse_report) } + + subject(:data) { helper.abuse_report_data(report)[:abuse_report_data] } + + it 'has the expected attributes' do + expect(data).to include('user', 'reporter', 'report', 'actions') + end + end end diff --git a/spec/lib/gitlab/quick_actions/extractor_spec.rb b/spec/lib/gitlab/quick_actions/extractor_spec.rb index e2f289041ce..f91e8d2a7ef 100644 --- a/spec/lib/gitlab/quick_actions/extractor_spec.rb +++ b/spec/lib/gitlab/quick_actions/extractor_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe Gitlab::QuickActions::Extractor do +RSpec.describe Gitlab::QuickActions::Extractor, feature_category: :team_planning do let(:definitions) do Class.new do include Gitlab::QuickActions::Dsl @@ -19,7 +19,8 @@ RSpec.describe Gitlab::QuickActions::Extractor do end.command_definitions end - let(:extractor) { described_class.new(definitions) } + let(:extractor) { described_class.new(definitions, keep_actions: keep_actions) } + let(:keep_actions) { false } shared_examples 'command with no argument' do it 'extracts command' do @@ -176,6 +177,31 @@ RSpec.describe Gitlab::QuickActions::Extractor do end end + describe 'command with keep_actions' do + let(:keep_actions) { true } + + context 'at the start of content' do + it_behaves_like 'command with a single argument' do + let(:original_msg) { "/assign @joe\nworld" } + let(:final_msg) { "\n/assign @joe\n\nworld" } + end + end + + context 'in the middle of content' do + it_behaves_like 'command with a single argument' do + let(:original_msg) { "hello\n/assign @joe\nworld" } + let(:final_msg) { "hello\n\n/assign @joe\n\nworld" } + end + end + + context 'at the end of content' do + it_behaves_like 'command with a single argument' do + let(:original_msg) { "hello\n/assign @joe" } + let(:final_msg) { "hello\n\n/assign @joe" } + end + end + end + it 'extracts command with multiple arguments and various prefixes' do msg = %(hello\n/power @user.name %9.10 ~"bar baz.2"\nworld) msg, commands = extractor.extract_commands(msg) @@ -244,10 +270,19 @@ RSpec.describe Gitlab::QuickActions::Extractor do msg = %(hello\nworld\n/reopen\n/substitution wow this is a thing.) msg, commands = extractor.extract_commands(msg) - expect(commands).to eq [['reopen'], ['substitution', 'wow this is a thing.']] + expect(commands).to match_array [['reopen'], ['substitution', 'wow this is a thing.']] expect(msg).to eq "hello\nworld\nfoo" end + it 'extracts and performs substitution commands with keep_actions' do + extractor = described_class.new(definitions, keep_actions: true) + msg = %(hello\nworld\n/reopen\n/substitution wow this is a thing.) + msg, commands = extractor.extract_commands(msg) + + expect(commands).to match_array [['reopen'], ['substitution', 'wow this is a thing.']] + expect(msg).to eq "hello\nworld\n\n/reopen\n\nfoo" + end + it 'extracts multiple commands' do msg = %(hello\n/power @user.name %9.10 ~"bar baz.2" label\nworld\n/reopen) msg, commands = extractor.extract_commands(msg) diff --git a/spec/models/abuse_report_spec.rb b/spec/models/abuse_report_spec.rb index 8a9ac618e00..6e678127aff 100644 --- a/spec/models/abuse_report_spec.rb +++ b/spec/models/abuse_report_spec.rb @@ -3,6 +3,8 @@ require 'spec_helper' RSpec.describe AbuseReport, feature_category: :insider_threat do + include Gitlab::Routing.url_helpers + let_it_be(:report, reload: true) { create(:abuse_report) } let_it_be(:user, reload: true) { create(:admin) } @@ -180,6 +182,144 @@ RSpec.describe AbuseReport, feature_category: :insider_threat do end end + describe '#report_type' do + let(:report) { build_stubbed(:abuse_report, reported_from_url: url) } + let_it_be(:issue) { create(:issue) } + let_it_be(:merge_request) { create(:merge_request) } + let_it_be(:user) { create(:user) } + + subject { report.report_type } + + context 'when reported from an issue' do + let(:url) { project_issue_url(issue.project, issue) } + + it { is_expected.to eq :issue } + end + + context 'when reported from a merge request' do + let(:url) { project_merge_request_url(merge_request.project, merge_request) } + + it { is_expected.to eq :merge_request } + end + + context 'when reported from a profile' do + let(:url) { user_url(user) } + + it { is_expected.to eq :profile } + end + + describe 'comment type' do + context 'when reported from an issue comment' do + let(:url) { project_issue_url(issue.project, issue, anchor: 'note_123') } + + it { is_expected.to eq :comment } + end + + context 'when reported from a merge request comment' do + let(:url) { project_merge_request_url(merge_request.project, merge_request, anchor: 'note_123') } + + it { is_expected.to eq :comment } + end + + context 'when anchor exists not from an issue or merge request URL' do + let(:url) { user_url(user, anchor: 'note_123') } + + it { is_expected.to eq :profile } + end + + context 'when note id is invalid' do + let(:url) { project_merge_request_url(merge_request.project, merge_request, anchor: 'note_12x') } + + it { is_expected.to eq :merge_request } + end + end + + context 'when URL cannot be matched' do + let(:url) { '/xxx' } + + it { is_expected.to be_nil } + end + end + + describe '#reported_content' do + let(:report) { build_stubbed(:abuse_report, reported_from_url: url) } + let_it_be(:issue) { create(:issue, description: 'issue description') } + let_it_be(:merge_request) { create(:merge_request, description: 'mr description') } + let_it_be(:user) { create(:user) } + + subject { report.reported_content } + + context 'when reported from an issue' do + let(:url) { project_issue_url(issue.project, issue) } + + it { is_expected.to eq issue.description_html } + end + + context 'when reported from a merge request' do + let(:url) { project_merge_request_url(merge_request.project, merge_request) } + + it { is_expected.to eq merge_request.description_html } + end + + context 'when reported from a merge request with an invalid note ID' do + let(:url) do + "#{project_merge_request_url(merge_request.project, merge_request)}#note_[]" + end + + it { is_expected.to eq merge_request.description_html } + end + + context 'when reported from a profile' do + let(:url) { user_url(user) } + + it { is_expected.to be_nil } + end + + context 'when reported from an unknown URL' do + let(:url) { '/xxx' } + + it { is_expected.to be_nil } + end + + context 'when reported from an invalid URL' do + let(:url) { 'http://example.com/[]' } + + it { is_expected.to be_nil } + end + + context 'when reported from an issue comment' do + let(:note) { create(:note, noteable: issue, project: issue.project, note: 'comment in issue') } + let(:url) { project_issue_url(issue.project, issue, anchor: "note_#{note.id}") } + + it { is_expected.to eq note.note_html } + end + + context 'when reported from a merge request comment' do + let(:note) { create(:note, noteable: merge_request, project: merge_request.project, note: 'comment in mr') } + let(:url) { project_merge_request_url(merge_request.project, merge_request, anchor: "note_#{note.id}") } + + it { is_expected.to eq note.note_html } + end + + context 'when report type cannot be determined, because the comment does not exist' do + let(:url) do + project_merge_request_url(merge_request.project, merge_request, anchor: "note_#{non_existing_record_id}") + end + + it { is_expected.to be_nil } + end + end + + describe '#other_reports_for_user' do + let(:report) { create(:abuse_report) } + let(:another_user_report) { create(:abuse_report, user: report.user) } + let(:another_report) { create(:abuse_report) } + + it 'returns other reports for the same user' do + expect(report.other_reports_for_user).to match_array(another_user_report) + end + end + describe 'enums' do let(:categories) do { diff --git a/spec/models/authentication_event_spec.rb b/spec/models/authentication_event_spec.rb index 23e253c2a28..17fe10b5b4e 100644 --- a/spec/models/authentication_event_spec.rb +++ b/spec/models/authentication_event_spec.rb @@ -71,4 +71,19 @@ RSpec.describe AuthenticationEvent do it { is_expected.to eq(false) } end end + + describe '.most_used_ip_address_for_user' do + let_it_be(:user) { create(:user) } + let_it_be(:most_used_ip_address) { '::1' } + let_it_be(:another_ip_address) { '127.0.0.1' } + + subject { described_class.most_used_ip_address_for_user(user) } + + before do + create_list(:authentication_event, 2, user: user, ip_address: most_used_ip_address) + create(:authentication_event, user: user, ip_address: another_ip_address) + end + + it { is_expected.to eq(most_used_ip_address) } + end end diff --git a/spec/policies/abuse_report_policy_spec.rb b/spec/policies/abuse_report_policy_spec.rb new file mode 100644 index 00000000000..b17b6886b9a --- /dev/null +++ b/spec/policies/abuse_report_policy_spec.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe AbuseReportPolicy, feature_category: :insider_threat do + let(:abuse_report) { build_stubbed(:abuse_report) } + + subject(:policy) { described_class.new(user, abuse_report) } + + context 'when the user is not an admin' do + let(:user) { create(:user) } + + it 'cannot read_abuse_report' do + expect(policy).to be_disallowed(:read_abuse_report) + end + end + + context 'when the user is an admin', :enable_admin_mode do + let(:user) { create(:admin) } + + it 'can read_abuse_report' do + expect(policy).to be_allowed(:read_abuse_report) + end + end +end diff --git a/spec/requests/admin/abuse_reports_controller_spec.rb b/spec/requests/admin/abuse_reports_controller_spec.rb index 3d3bfcd3f60..ab527ab4df6 100644 --- a/spec/requests/admin/abuse_reports_controller_spec.rb +++ b/spec/requests/admin/abuse_reports_controller_spec.rb @@ -42,4 +42,14 @@ RSpec.describe Admin::AbuseReportsController, type: :request, feature_category: end end end + + describe 'GET #show' do + let!(:report) { create(:abuse_report) } + + it 'returns the requested report' do + get admin_abuse_report_path(report) + + expect(assigns(:abuse_report)).to eq report + end + end end diff --git a/spec/requests/users_controller_spec.rb b/spec/requests/users_controller_spec.rb index 3367414b987..c49dbb6a269 100644 --- a/spec/requests/users_controller_spec.rb +++ b/spec/requests/users_controller_spec.rb @@ -770,6 +770,17 @@ RSpec.describe UsersController, feature_category: :user_management do expect(response.body).to eq(expected_json) end end + + context 'when a project has the same name as a desired username' do + let_it_be(:project) { create(:project, name: 'project-name') } + + it 'returns JSON indicating a user by that username does not exist' do + get user_exists_url 'project-name' + + expected_json = { exists: false }.to_json + expect(response.body).to eq(expected_json) + end + end end context 'when the rate limit has been reached' do diff --git a/spec/serializers/admin/abuse_report_details_entity_spec.rb b/spec/serializers/admin/abuse_report_details_entity_spec.rb new file mode 100644 index 00000000000..0e5e6a62ce1 --- /dev/null +++ b/spec/serializers/admin/abuse_report_details_entity_spec.rb @@ -0,0 +1,158 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Admin::AbuseReportDetailsEntity, feature_category: :insider_threat do + include Gitlab::Routing + + let(:report) { build_stubbed(:abuse_report) } + let(:user) { report.user } + let(:reporter) { report.reporter } + let!(:other_report) { create(:abuse_report, user: user) } # rubocop:disable RSpec/FactoryBot/AvoidCreate + + let(:entity) do + described_class.new(report) + end + + describe '#as_json' do + subject(:entity_hash) { entity.as_json } + + it 'exposes correct attributes' do + expect(entity_hash.keys).to include( + :user, + :reporter, + :report, + :actions + ) + end + + it 'correctly exposes `user`', :aggregate_failures do + user_hash = entity_hash[:user] + + expect(user_hash.keys).to match_array([ + :name, + :username, + :avatar_url, + :email, + :created_at, + :last_activity_on, + :path, + :admin_path, + :plan, + :verification_state, + :other_reports, + :most_used_ip, + :last_sign_in_ip, + :snippets_count, + :groups_count, + :notes_count + ]) + + expect(user_hash[:verification_state].keys).to match_array([ + :email, + :phone, + :credit_card + ]) + + expect(user_hash[:other_reports][0].keys).to match_array([ + :created_at, + :category, + :report_path + ]) + end + + describe 'users plan' do + it 'does not include the plan' do + expect(entity_hash[:user][:plan]).to be_nil + end + + context 'when on .com', :saas, if: Gitlab.ee? do + before do + stub_ee_application_setting(should_check_namespace_plan: true) + create(:namespace_with_plan, plan: :bronze_plan, owner: user) # rubocop:disable RSpec/FactoryBot/AvoidCreate + end + + it 'includes the plan' do + expect(entity_hash[:user][:plan]).to eq('Bronze') + end + end + end + + describe 'users credit card' do + let(:credit_card_hash) { entity_hash[:user][:credit_card] } + + context 'when the user has no verified credit card' do + it 'does not expose the credit card' do + expect(credit_card_hash).to be_nil + end + end + + context 'when the user does have a verified credit card' do + let!(:credit_card) { build_stubbed(:credit_card_validation, user: user) } + + it 'exposes the credit card' do + expect(credit_card_hash.keys).to match_array([ + :name, + :similar_records_count, + :card_matches_link + ]) + end + + context 'when not on ee', unless: Gitlab.ee? do + it 'does not include the path to the admin card matches page' do + expect(credit_card_hash[:card_matches_link]).to be_nil + end + end + + context 'when on ee', if: Gitlab.ee? do + it 'includes the path to the admin card matches page' do + expect(credit_card_hash[:card_matches_link]).not_to be_nil + end + end + end + end + + it 'correctly exposes `reporter`' do + reporter_hash = entity_hash[:reporter] + + expect(reporter_hash.keys).to match_array([ + :name, + :username, + :avatar_url, + :path + ]) + end + + it 'correctly exposes `report`' do + report_hash = entity_hash[:report] + + expect(report_hash.keys).to match_array([ + :message, + :reported_at, + :category, + :type, + :content, + :url, + :screenshot + ]) + end + + it 'correctly exposes `actions`', :aggregate_failures do + actions_hash = entity_hash[:actions] + + expect(actions_hash.keys).to match_array([ + :user_blocked, + :block_user_path, + :remove_user_and_report_path, + :remove_report_path, + :reported_user, + :redirect_path + ]) + + expect(actions_hash[:reported_user].keys).to match_array([ + :name, + :created_at + ]) + end + end +end diff --git a/spec/serializers/admin/abuse_report_details_serializer_spec.rb b/spec/serializers/admin/abuse_report_details_serializer_spec.rb new file mode 100644 index 00000000000..f22d92a1763 --- /dev/null +++ b/spec/serializers/admin/abuse_report_details_serializer_spec.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Admin::AbuseReportDetailsSerializer, feature_category: :insider_threat do + let_it_be(:resource) { build_stubbed(:abuse_report) } + + subject { described_class.new.represent(resource).keys } + + describe '#represent' do + it 'serializes an abuse report' do + is_expected.to include( + :user, + :reporter, + :report, + :actions + ) + end + end +end diff --git a/spec/serializers/admin/abuse_report_entity_spec.rb b/spec/serializers/admin/abuse_report_entity_spec.rb index 760c12d3cf9..2101fc15dd0 100644 --- a/spec/serializers/admin/abuse_report_entity_spec.rb +++ b/spec/serializers/admin/abuse_report_entity_spec.rb @@ -33,7 +33,8 @@ RSpec.describe Admin::AbuseReportEntity, feature_category: :insider_threat do :block_user_path, :remove_report_path, :remove_user_and_report_path, - :message + :message, + :report_path ) end @@ -81,6 +82,10 @@ RSpec.describe Admin::AbuseReportEntity, feature_category: :insider_threat do expect(entity_hash[:remove_report_path]).to eq admin_abuse_report_path(abuse_report) end + it 'correctly exposes :report_path' do + expect(entity_hash[:report_path]).to eq admin_abuse_report_path(abuse_report) + end + it 'correctly exposes :remove_user_and_report_path' do expect(entity_hash[:remove_user_and_report_path]).to eq admin_abuse_report_path(abuse_report, remove_user: true) end diff --git a/spec/services/preview_markdown_service_spec.rb b/spec/services/preview_markdown_service_spec.rb index 97e31ec2cd3..6fa44310ae5 100644 --- a/spec/services/preview_markdown_service_spec.rb +++ b/spec/services/preview_markdown_service_spec.rb @@ -117,6 +117,16 @@ RSpec.describe PreviewMarkdownService, feature_category: :team_planning do expect(result[:text]).to eq 'Please do it' end + context 'when render_quick_actions' do + it 'keeps quick actions' do + params[:render_quick_actions] = true + + result = service.execute + + expect(result[:text]).to eq "Please do it\n\n/assign #{user.to_reference}" + end + end + it 'explains quick actions effect' do result = service.execute diff --git a/spec/services/quick_actions/interpret_service_spec.rb b/spec/services/quick_actions/interpret_service_spec.rb index b8abfd1e6ba..b07aa7cc6c9 100644 --- a/spec/services/quick_actions/interpret_service_spec.rb +++ b/spec/services/quick_actions/interpret_service_spec.rb @@ -2517,8 +2517,9 @@ RSpec.describe QuickActions::InterpretService, feature_category: :team_planning let(:content) { '/close' } it 'includes issuable name' do - _, explanations = service.explain(content, issue) + content_result, explanations = service.explain(content, issue) + expect(content_result).to eq('') expect(explanations).to eq(['Closes this issue.']) end end @@ -2946,6 +2947,24 @@ RSpec.describe QuickActions::InterpretService, feature_category: :team_planning end end end + + context 'with keep_actions' do + let(:content) { '/close' } + + it 'keeps quick actions' do + content_result, explanations = service.explain(content, issue, keep_actions: true) + + expect(content_result).to eq("\n/close") + expect(explanations).to eq(['Closes this issue.']) + end + + it 'removes the quick action' do + content_result, explanations = service.explain(content, issue, keep_actions: false) + + expect(content_result).to eq('') + expect(explanations).to eq(['Closes this issue.']) + end + end end describe '#available_commands' do |