Welcome to mirror list, hosted at ThFree Co, Russian Federation.

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2023-05-02 18:16:59 +0300
committerGitLab Bot <gitlab-bot@gitlab.com>2023-05-02 18:16:59 +0300
commit6f991190fe4dbb93070b090a9a31d71b25e8101d (patch)
tree0805552c79613c87d5e99c08f9a588d3cfe6f3c5
parent51d59a3538b97d85ebb46039044d3f498809b55a (diff)
Add latest changes from gitlab-org/gitlab@master
-rw-r--r--.gitlab/ci/package-and-test/main.gitlab-ci.yml9
-rw-r--r--.gitlab/ci/reports.gitlab-ci.yml2
-rw-r--r--.gitlab/ci/rules.gitlab-ci.yml11
-rw-r--r--.rubocop_todo/gitlab/namespaced_class.yml1
-rw-r--r--app/assets/javascripts/admin/abuse_report/components/abuse_report_app.vue35
-rw-r--r--app/assets/javascripts/admin/abuse_report/components/history_items.vue51
-rw-r--r--app/assets/javascripts/admin/abuse_report/components/report_header.vue46
-rw-r--r--app/assets/javascripts/admin/abuse_report/components/reported_content.vue137
-rw-r--r--app/assets/javascripts/admin/abuse_report/components/user_detail.vue27
-rw-r--r--app/assets/javascripts/admin/abuse_report/components/user_details.vue115
-rw-r--r--app/assets/javascripts/admin/abuse_report/constants.js61
-rw-r--r--app/assets/javascripts/admin/abuse_report/index.js27
-rw-r--r--app/assets/javascripts/admin/abuse_reports/components/abuse_report_actions.vue4
-rw-r--r--app/assets/javascripts/ide/components/repo_tab.vue1
-rw-r--r--app/assets/javascripts/pages/admin/abuse_reports/show/index.js3
-rw-r--r--app/assets/javascripts/super_sidebar/components/counter.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/markdown_editor.vue7
-rw-r--r--app/assets/stylesheets/framework/super_sidebar.scss6
-rw-r--r--app/controllers/admin/abuse_reports_controller.rb4
-rw-r--r--app/controllers/users_controller.rb2
-rw-r--r--app/helpers/admin/abuse_reports_helper.rb6
-rw-r--r--app/models/abuse_report.rb51
-rw-r--r--app/models/authentication_event.rb4
-rw-r--r--app/policies/abuse_report_policy.rb7
-rw-r--r--app/serializers/admin/abuse_report_details_entity.rb104
-rw-r--r--app/serializers/admin/abuse_report_details_serializer.rb7
-rw-r--r--app/serializers/admin/abuse_report_entity.rb4
-rw-r--r--app/services/preview_markdown_service.rb2
-rw-r--r--app/services/quick_actions/interpret_service.rb9
-rw-r--r--app/views/admin/abuse_reports/show.html.haml6
-rw-r--r--config/feature_flags/development/openai_moderation.yml8
-rw-r--r--config/routes/admin.rb2
-rw-r--r--doc/api/graphql/reference/index.md30
-rw-r--r--doc/api/graphql/removed_items.md5
-rw-r--r--doc/development/go_guide/index.md2
-rw-r--r--doc/development/secure_coding_guidelines.md9
-rw-r--r--doc/gitlab-basics/start-using-git.md6
-rw-r--r--doc/user/project/merge_requests/creating_merge_requests.md7
-rw-r--r--doc/user/project/repository/branches/index.md35
-rw-r--r--doc/user/project/repository/push_rules.md2
-rw-r--r--lib/gitlab/discussions_diff/highlight_cache.rb4
-rw-r--r--lib/gitlab/quick_actions/extractor.rb18
-rw-r--r--locale/gitlab.pot101
-rw-r--r--spec/controllers/import/bulk_imports_controller_spec.rb47
-rw-r--r--spec/features/dashboard/issues_filter_spec.rb2
-rw-r--r--spec/frontend/admin/abuse_report/components/abuse_report_app_spec.js76
-rw-r--r--spec/frontend/admin/abuse_report/components/history_items_spec.js66
-rw-r--r--spec/frontend/admin/abuse_report/components/report_header_spec.js59
-rw-r--r--spec/frontend/admin/abuse_report/components/reported_content_spec.js188
-rw-r--r--spec/frontend/admin/abuse_report/components/user_detail_spec.js66
-rw-r--r--spec/frontend/admin/abuse_report/components/user_details_spec.js210
-rw-r--r--spec/frontend/admin/abuse_report/mock_data.js61
-rw-r--r--spec/frontend/admin/abuse_reports/components/abuse_report_actions_spec.js43
-rw-r--r--spec/frontend/admin/abuse_reports/mock_data.js2
-rw-r--r--spec/frontend/ide/components/repo_tab_spec.js79
-rw-r--r--spec/frontend/vue_merge_request_widget/mr_widget_options_spec.js10
-rw-r--r--spec/frontend/vue_shared/components/markdown/markdown_editor_spec.js13
-rw-r--r--spec/helpers/admin/abuse_reports_helper_spec.rb10
-rw-r--r--spec/lib/gitlab/quick_actions/extractor_spec.rb41
-rw-r--r--spec/models/abuse_report_spec.rb140
-rw-r--r--spec/models/authentication_event_spec.rb15
-rw-r--r--spec/policies/abuse_report_policy_spec.rb25
-rw-r--r--spec/requests/admin/abuse_reports_controller_spec.rb10
-rw-r--r--spec/requests/users_controller_spec.rb11
-rw-r--r--spec/serializers/admin/abuse_report_details_entity_spec.rb158
-rw-r--r--spec/serializers/admin/abuse_report_details_serializer_spec.rb20
-rw-r--r--spec/serializers/admin/abuse_report_entity_spec.rb7
-rw-r--r--spec/services/preview_markdown_service_spec.rb10
-rw-r--r--spec/services/quick_actions/interpret_service_spec.rb21
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