summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2021-10-08 06:11:46 +0000
committerGitLab Bot <gitlab-bot@gitlab.com>2021-10-08 06:11:46 +0000
commita2b477802dd0cee675d93347a698d24c31ad7ff5 (patch)
tree0339757d48cf367157eac3331d664196a82452c9
parentd27e35761a665e32a7a83fc76ca19613a7a1c7bb (diff)
downloadgitlab-ce-a2b477802dd0cee675d93347a698d24c31ad7ff5.tar.gz
Add latest changes from gitlab-org/gitlab@master
-rw-r--r--app/assets/javascripts/cycle_analytics/utils.js30
-rw-r--r--app/assets/javascripts/lib/utils/datetime/date_format_utility.js29
-rw-r--r--app/assets/javascripts/snippets/components/snippet_header.vue70
-rw-r--r--app/assets/javascripts/snippets/index.js2
-rw-r--r--app/assets/javascripts/user_popovers.js1
-rw-r--r--app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue22
-rw-r--r--app/models/clusters/agents/group_authorization.rb4
-rw-r--r--app/models/clusters/agents/implicit_authorization.rb5
-rw-r--r--app/models/clusters/agents/project_authorization.rb4
-rw-r--r--app/models/users/credit_card_validation.rb8
-rw-r--r--app/views/admin/users/show.html.haml3
-rw-r--r--app/views/projects/snippets/show.html.haml2
-rw-r--r--app/views/snippets/show.html.haml2
-rw-r--r--config/feature_flags/development/finding_ci_pipeline_disable_joins.yml8
-rw-r--r--doc/user/project/settings/index.md14
-rw-r--r--lib/api/entities/clusters/agent_authorization.rb2
-rw-r--r--lib/api/entities/user.rb5
-rw-r--r--locale/gitlab.pot50
-rw-r--r--scripts/rspec_helpers.sh2
-rw-r--r--spec/frontend/cycle_analytics/utils_spec.js16
-rw-r--r--spec/frontend/lib/utils/datetime/date_format_utility_spec.js15
-rw-r--r--spec/frontend/snippets/components/show_spec.js18
-rw-r--r--spec/frontend/snippets/components/snippet_header_spec.js41
-rw-r--r--spec/frontend/vue_shared/components/user_popover/user_popover_spec.js30
-rw-r--r--spec/lib/api/entities/clusters/agent_authorization_spec.rb35
-rw-r--r--spec/lib/api/entities/user_spec.rb13
-rw-r--r--spec/models/clusters/agents/group_authorization_spec.rb6
-rw-r--r--spec/models/clusters/agents/implicit_authorization_spec.rb2
-rw-r--r--spec/models/clusters/agents/project_authorization_spec.rb6
-rw-r--r--spec/models/users/credit_card_validation_spec.rb15
30 files changed, 352 insertions, 108 deletions
diff --git a/app/assets/javascripts/cycle_analytics/utils.js b/app/assets/javascripts/cycle_analytics/utils.js
index fa02fdf914a..3c6267bac06 100644
--- a/app/assets/javascripts/cycle_analytics/utils.js
+++ b/app/assets/javascripts/cycle_analytics/utils.js
@@ -1,13 +1,10 @@
import dateFormat from 'dateformat';
-import { unescape } from 'lodash';
import { dateFormats } from '~/analytics/shared/constants';
import { hideFlash } from '~/flash';
-import { sanitize } from '~/lib/dompurify';
-import { roundToNearestHalf } from '~/lib/utils/common_utils';
import { getDateInPast } from '~/lib/utils/datetime/date_calculation_utility';
import { parseSeconds } from '~/lib/utils/datetime_utility';
+import { formatTimeAsSummary } from '~/lib/utils/datetime/date_format_utility';
import { slugify } from '~/lib/utils/text_utility';
-import { s__, sprintf } from '../locale';
export const removeFlash = (type = 'alert') => {
const flashEl = document.querySelector(`.flash-${type}`);
@@ -45,29 +42,6 @@ export const transformStagesForPathNavigation = ({
return formattedStages;
};
-export const timeSummaryForPathNavigation = ({ seconds, hours, days, minutes, weeks, months }) => {
- if (months) {
- return sprintf(s__('ValueStreamAnalytics|%{value}M'), {
- value: roundToNearestHalf(months),
- });
- } else if (weeks) {
- return sprintf(s__('ValueStreamAnalytics|%{value}w'), {
- value: roundToNearestHalf(weeks),
- });
- } else if (days) {
- return sprintf(s__('ValueStreamAnalytics|%{value}d'), {
- value: roundToNearestHalf(days),
- });
- } else if (hours) {
- return sprintf(s__('ValueStreamAnalytics|%{value}h'), { value: hours });
- } else if (minutes) {
- return sprintf(s__('ValueStreamAnalytics|%{value}m'), { value: minutes });
- } else if (seconds) {
- return unescape(sanitize(s__('ValueStreamAnalytics|&lt;1m'), { ALLOWED_TAGS: [] }));
- }
- return '-';
-};
-
/**
* Takes a raw median value in seconds and converts it to a string representation
* ie. converts 172800 => 2d (2 days)
@@ -76,7 +50,7 @@ export const timeSummaryForPathNavigation = ({ seconds, hours, days, minutes, we
* @returns {String} String representation ie 2w
*/
export const medianTimeToParsedSeconds = (value) =>
- timeSummaryForPathNavigation({
+ formatTimeAsSummary({
...parseSeconds(value, { daysPerWeek: 7, hoursPerDay: 24 }),
seconds: value,
});
diff --git a/app/assets/javascripts/lib/utils/datetime/date_format_utility.js b/app/assets/javascripts/lib/utils/datetime/date_format_utility.js
index 0a35efb0ac8..3c446c21865 100644
--- a/app/assets/javascripts/lib/utils/datetime/date_format_utility.js
+++ b/app/assets/javascripts/lib/utils/datetime/date_format_utility.js
@@ -1,6 +1,8 @@
import dateFormat from 'dateformat';
-import { isString, mapValues, reduce, isDate } from 'lodash';
-import { s__, n__, __ } from '../../../locale';
+import { isString, mapValues, reduce, isDate, unescape } from 'lodash';
+import { roundToNearestHalf } from '~/lib/utils/common_utils';
+import { sanitize } from '~/lib/dompurify';
+import { s__, n__, __, sprintf } from '../../../locale';
/**
* Returns i18n month names array.
@@ -361,3 +363,26 @@ export const dateToTimeInputValue = (date) => {
hour12: false,
});
};
+
+export const formatTimeAsSummary = ({ seconds, hours, days, minutes, weeks, months }) => {
+ if (months) {
+ return sprintf(s__('ValueStreamAnalytics|%{value}M'), {
+ value: roundToNearestHalf(months),
+ });
+ } else if (weeks) {
+ return sprintf(s__('ValueStreamAnalytics|%{value}w'), {
+ value: roundToNearestHalf(weeks),
+ });
+ } else if (days) {
+ return sprintf(s__('ValueStreamAnalytics|%{value}d'), {
+ value: roundToNearestHalf(days),
+ });
+ } else if (hours) {
+ return sprintf(s__('ValueStreamAnalytics|%{value}h'), { value: hours });
+ } else if (minutes) {
+ return sprintf(s__('ValueStreamAnalytics|%{value}m'), { value: minutes });
+ } else if (seconds) {
+ return unescape(sanitize(s__('ValueStreamAnalytics|&lt;1m'), { ALLOWED_TAGS: [] }));
+ }
+ return '-';
+};
diff --git a/app/assets/javascripts/snippets/components/snippet_header.vue b/app/assets/javascripts/snippets/components/snippet_header.vue
index 466b273cae4..a5c98a7ad90 100644
--- a/app/assets/javascripts/snippets/components/snippet_header.vue
+++ b/app/assets/javascripts/snippets/components/snippet_header.vue
@@ -11,15 +11,26 @@ import {
GlButton,
GlTooltipDirective,
} from '@gitlab/ui';
+import { isEmpty } from 'lodash';
import CanCreateProjectSnippet from 'shared_queries/snippet/project_permissions.query.graphql';
import CanCreatePersonalSnippet from 'shared_queries/snippet/user_permissions.query.graphql';
import { fetchPolicies } from '~/lib/graphql';
+import axios from '~/lib/utils/axios_utils';
import { joinPaths } from '~/lib/utils/url_utility';
-import { __ } from '~/locale';
+import { __, s__, sprintf } from '~/locale';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+import createFlash, { FLASH_TYPES } from '~/flash';
import DeleteSnippetMutation from '../mutations/deleteSnippet.mutation.graphql';
+export const i18n = {
+ snippetSpamSuccess: sprintf(
+ s__('Snippets|%{spammable_titlecase} was submitted to Akismet successfully.'),
+ { spammable_titlecase: __('Snippet') },
+ ),
+ snippetSpamFailure: s__('Snippets|Error with Akismet. Please check the logs for more info.'),
+};
+
export default {
components: {
GlAvatar,
@@ -54,7 +65,7 @@ export default {
},
},
},
- inject: ['reportAbusePath'],
+ inject: ['reportAbusePath', 'canReportSpam'],
props: {
snippet: {
type: Object,
@@ -63,7 +74,8 @@ export default {
},
data() {
return {
- isDeleting: false,
+ isLoading: false,
+ isSubmittingSpam: false,
errorMessage: '',
canCreateSnippet: false,
};
@@ -105,10 +117,11 @@ export default {
category: 'secondary',
},
{
- condition: this.reportAbusePath,
+ condition: this.canReportSpam && !isEmpty(this.reportAbusePath),
text: __('Submit as spam'),
- href: this.reportAbusePath,
+ click: this.submitAsSpam,
title: __('Submit as spam'),
+ loading: this.isSubmittingSpam,
},
];
},
@@ -157,7 +170,7 @@ export default {
this.$refs.deleteModal.show();
},
deleteSnippet() {
- this.isDeleting = true;
+ this.isLoading = true;
this.$apollo
.mutate({
mutation: DeleteSnippetMutation,
@@ -167,17 +180,34 @@ export default {
if (data?.destroySnippet?.errors.length) {
throw new Error(data?.destroySnippet?.errors[0]);
}
- this.isDeleting = false;
this.errorMessage = undefined;
this.closeDeleteModal();
this.redirectToSnippets();
})
.catch((err) => {
- this.isDeleting = false;
+ this.isLoading = false;
this.errorMessage = err.message;
+ })
+ .finally(() => {
+ this.isLoading = false;
+ });
+ },
+ async submitAsSpam() {
+ try {
+ this.isSubmittingSpam = true;
+ await axios.post(this.reportAbusePath);
+ createFlash({
+ message: this.$options.i18n.snippetSpamSuccess,
+ type: FLASH_TYPES.SUCCESS,
});
+ } catch (error) {
+ createFlash({ message: this.$options.i18n.snippetSpamFailure });
+ } finally {
+ this.isSubmittingSpam = false;
+ }
},
},
+ i18n,
};
</script>
<template>
@@ -189,9 +219,7 @@ export default {
:title="snippetVisibilityLevelDescription"
data-container="body"
>
- <span class="sr-only">
- {{ s__(`VisibilityLevel|${visibility}`) }}
- </span>
+ <span class="sr-only">{{ s__(`VisibilityLevel|${visibility}`) }}</span>
<gl-icon :name="visibilityLevelIcon" :size="14" />
</div>
<div class="creator" data-testid="authored-message">
@@ -233,6 +261,7 @@ export default {
>
<gl-button
:disabled="action.disabled"
+ :loading="action.loading"
:variant="action.variant"
:category="action.category"
:class="action.cssClass"
@@ -240,9 +269,8 @@ export default {
data-qa-selector="snippet_action_button"
:data-qa-action="action.text"
@click="action.click ? action.click() : undefined"
+ >{{ action.text }}</gl-button
>
- {{ action.text }}
- </gl-button>
</div>
</template>
</div>
@@ -266,14 +294,14 @@ export default {
<gl-modal ref="deleteModal" modal-id="delete-modal" title="Example title">
<template #modal-title>{{ __('Delete snippet?') }}</template>
- <gl-alert v-if="errorMessage" variant="danger" class="mb-2" @dismiss="errorMessage = ''">{{
- errorMessage
- }}</gl-alert>
+ <gl-alert v-if="errorMessage" variant="danger" class="mb-2" @dismiss="errorMessage = ''">
+ {{ errorMessage }}
+ </gl-alert>
<gl-sprintf :message="__('Are you sure you want to delete %{name}?')">
- <template #name
- ><strong>{{ snippet.title }}</strong></template
- >
+ <template #name>
+ <strong>{{ snippet.title }}</strong>
+ </template>
</gl-sprintf>
<template #modal-footer>
@@ -281,11 +309,11 @@ export default {
<gl-button
variant="danger"
category="primary"
- :disabled="isDeleting"
+ :disabled="isLoading"
data-qa-selector="delete_snippet_button"
@click="deleteSnippet"
>
- <gl-loading-icon v-if="isDeleting" size="sm" inline />
+ <gl-loading-icon v-if="isLoading" size="sm" inline />
{{ __('Delete snippet') }}
</gl-button>
</template>
diff --git a/app/assets/javascripts/snippets/index.js b/app/assets/javascripts/snippets/index.js
index dec8dcec179..8e7368ef804 100644
--- a/app/assets/javascripts/snippets/index.js
+++ b/app/assets/javascripts/snippets/index.js
@@ -27,6 +27,7 @@ export default function appFactory(el, Component) {
visibilityLevels = '[]',
selectedLevel,
multipleLevelsRestricted,
+ canReportSpam,
reportAbusePath,
...restDataset
} = el.dataset;
@@ -39,6 +40,7 @@ export default function appFactory(el, Component) {
selectedLevel: SNIPPET_LEVELS_MAP[selectedLevel] ?? SNIPPET_VISIBILITY_PRIVATE,
multipleLevelsRestricted: 'multipleLevelsRestricted' in el.dataset,
reportAbusePath,
+ canReportSpam,
},
render(createElement) {
return createElement(Component, {
diff --git a/app/assets/javascripts/user_popovers.js b/app/assets/javascripts/user_popovers.js
index 7a7518bcf83..4544373d8aa 100644
--- a/app/assets/javascripts/user_popovers.js
+++ b/app/assets/javascripts/user_popovers.js
@@ -41,6 +41,7 @@ const populateUserInfo = (user) => {
workInformation: userData.work_information,
websiteUrl: userData.website_url,
pronouns: userData.pronouns,
+ localTime: userData.local_time,
loaded: true,
});
}
diff --git a/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue b/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue
index 74616763f8f..05e0c3b0be3 100644
--- a/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue
+++ b/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue
@@ -93,19 +93,27 @@ export default {
</div>
<div class="gl-text-gray-500">
<div v-if="user.bio" class="gl-display-flex gl-mb-2">
- <gl-icon name="profile" class="gl-text-gray-400 gl-flex-shrink-0" />
+ <gl-icon name="profile" class="gl-flex-shrink-0" />
<span ref="bio" class="gl-ml-2 gl-overflow-hidden">{{ user.bio }}</span>
</div>
<div v-if="user.workInformation" class="gl-display-flex gl-mb-2">
- <gl-icon name="work" class="gl-text-gray-400 gl-flex-shrink-0" />
+ <gl-icon name="work" class="gl-flex-shrink-0" />
<span ref="workInformation" class="gl-ml-2">{{ user.workInformation }}</span>
</div>
+ <div v-if="user.location" class="gl-display-flex gl-mb-2">
+ <gl-icon name="location" class="gl-flex-shrink-0" />
+ <span class="gl-ml-2">{{ user.location }}</span>
+ </div>
+ <div
+ v-if="user.localTime && !user.bot"
+ class="gl-display-flex gl-mb-2"
+ data-testid="user-popover-local-time"
+ >
+ <gl-icon name="clock" class="gl-flex-shrink-0" />
+ <span class="gl-ml-2">{{ user.localTime }}</span>
+ </div>
</div>
- <div v-if="user.location" class="js-location gl-text-gray-500 gl-display-flex">
- <gl-icon name="location" class="gl-text-gray-400 flex-shrink-0" />
- <span class="gl-ml-2">{{ user.location }}</span>
- </div>
- <div v-if="statusHtml" class="js-user-status gl-mt-3">
+ <div v-if="statusHtml" class="gl-mb-2" data-testid="user-popover-status">
<span v-safe-html:[$options.safeHtmlConfig]="statusHtml"></span>
</div>
<div v-if="user.bot" class="gl-text-blue-500">
diff --git a/app/models/clusters/agents/group_authorization.rb b/app/models/clusters/agents/group_authorization.rb
index 74c0cec3b7e..28a711aaf17 100644
--- a/app/models/clusters/agents/group_authorization.rb
+++ b/app/models/clusters/agents/group_authorization.rb
@@ -10,7 +10,9 @@ module Clusters
validates :config, json_schema: { filename: 'cluster_agent_authorization_configuration' }
- delegate :project, to: :agent
+ def config_project
+ agent.project
+ end
end
end
end
diff --git a/app/models/clusters/agents/implicit_authorization.rb b/app/models/clusters/agents/implicit_authorization.rb
index 967cc686045..9f7f653ed65 100644
--- a/app/models/clusters/agents/implicit_authorization.rb
+++ b/app/models/clusters/agents/implicit_authorization.rb
@@ -6,12 +6,15 @@ module Clusters
attr_reader :agent
delegate :id, to: :agent, prefix: true
- delegate :project, to: :agent
def initialize(agent:)
@agent = agent
end
+ def config_project
+ agent.project
+ end
+
def config
nil
end
diff --git a/app/models/clusters/agents/project_authorization.rb b/app/models/clusters/agents/project_authorization.rb
index 1c71a0a432a..f6d19086751 100644
--- a/app/models/clusters/agents/project_authorization.rb
+++ b/app/models/clusters/agents/project_authorization.rb
@@ -9,6 +9,10 @@ module Clusters
belongs_to :project, class_name: '::Project', optional: false
validates :config, json_schema: { filename: 'cluster_agent_authorization_configuration' }
+
+ def config_project
+ agent.project
+ end
end
end
end
diff --git a/app/models/users/credit_card_validation.rb b/app/models/users/credit_card_validation.rb
index 55f56c6277a..a4cc43d1f13 100644
--- a/app/models/users/credit_card_validation.rb
+++ b/app/models/users/credit_card_validation.rb
@@ -12,5 +12,13 @@ module Users
validates :last_digits, allow_nil: true, numericality: {
greater_than_or_equal_to: 0, less_than_or_equal_to: 9999
}
+
+ def similar_records
+ self.class.where(
+ expiration_date: expiration_date,
+ last_digits: last_digits,
+ holder_name: holder_name
+ ).order(credit_card_validated_at: :desc).includes(:user)
+ end
end
end
diff --git a/app/views/admin/users/show.html.haml b/app/views/admin/users/show.html.haml
index ad8d9d1f04f..2a9b4694e7b 100644
--- a/app/views/admin/users/show.html.haml
+++ b/app/views/admin/users/show.html.haml
@@ -61,7 +61,6 @@
= _('Disabled')
= render_if_exists 'admin/namespace_plan_info', namespace: @user.namespace
- = render_if_exists 'admin/users/credit_card_info', user: @user
%li
%span.light= _('External User:')
@@ -139,6 +138,8 @@
= render_if_exists 'namespaces/shared_runner_status', namespace: @user.namespace
+ = render_if_exists 'admin/users/credit_card_info', user: @user, link_to_match_page: true
+
= render 'shared/custom_attributes', custom_attributes: @user.custom_attributes
-# Rendered on desktop only so order of cards can be different on desktop vs mobile
diff --git a/app/views/projects/snippets/show.html.haml b/app/views/projects/snippets/show.html.haml
index 8ef53c40b11..3e6acdb130a 100644
--- a/app/views/projects/snippets/show.html.haml
+++ b/app/views/projects/snippets/show.html.haml
@@ -3,7 +3,7 @@
- breadcrumb_title @snippet.to_reference
- page_title "#{@snippet.title} (#{@snippet.to_reference})", _("Snippets")
-#js-snippet-view{ data: {'qa-selector': 'snippet_view', 'snippet-gid': @snippet.to_global_id, 'report-abuse-path': snippet_report_abuse_path(@snippet) } }
+#js-snippet-view{ data: {'qa-selector': 'snippet_view', 'snippet-gid': @snippet.to_global_id, 'report-abuse-path': snippet_report_abuse_path(@snippet), 'can-report-spam': @snippet.submittable_as_spam_by?(current_user).to_s } }
.row-content-block.top-block.content-component-block
= render 'award_emoji/awards_block', awardable: @snippet, inline: true, api_awards_path: project_snippets_award_api_path(@snippet)
diff --git a/app/views/snippets/show.html.haml b/app/views/snippets/show.html.haml
index ca52a1f8f46..f1093a3b730 100644
--- a/app/views/snippets/show.html.haml
+++ b/app/views/snippets/show.html.haml
@@ -12,7 +12,7 @@
- content_for :prefetch_asset_tags do
- webpack_preload_asset_tag('monaco', prefetch: true)
-#js-snippet-view{ data: {'qa-selector': 'snippet_view', 'snippet-gid': @snippet.to_global_id, 'report-abuse-path': snippet_report_abuse_path(@snippet) } }
+#js-snippet-view{ data: {'qa-selector': 'snippet_view', 'snippet-gid': @snippet.to_global_id, 'report-abuse-path': snippet_report_abuse_path(@snippet), 'can-report-spam': @snippet.submittable_as_spam_by?(current_user).to_s } }
.row-content-block.top-block.content-component-block
= render 'award_emoji/awards_block', awardable: @snippet, inline: true
diff --git a/config/feature_flags/development/finding_ci_pipeline_disable_joins.yml b/config/feature_flags/development/finding_ci_pipeline_disable_joins.yml
new file mode 100644
index 00000000000..8987b729cac
--- /dev/null
+++ b/config/feature_flags/development/finding_ci_pipeline_disable_joins.yml
@@ -0,0 +1,8 @@
+---
+name: finding_ci_pipeline_disable_joins
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/70216
+rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/338665
+milestone: '14.3'
+type: development
+group: group::threat insights
+default_enabled: true
diff --git a/doc/user/project/settings/index.md b/doc/user/project/settings/index.md
index da055d22695..30def6ae80f 100644
--- a/doc/user/project/settings/index.md
+++ b/doc/user/project/settings/index.md
@@ -192,6 +192,20 @@ cannot change them:
This ensures that your job uses the settings you intend and that they are not overridden by
project-level pipelines.
+##### Avoid parent and child pipelines
+
+Compliance pipelines start on the run of _every_ pipeline in a relevant project. This means that if a pipeline in the relevant project
+triggers a child pipeline, the compliance pipeline runs first. This can trigger the parent pipeline, instead of the child pipeline.
+
+Therefore, in projects with compliance frameworks, we recommend replacing
+[parent-child pipelines](../../../ci/pipelines/parent_child_pipelines.md) with the following:
+
+- Direct [`include`](../../../ci/yaml/index.md#include) statements that provide the parent pipeline with child pipeline configuration.
+- Child pipelines placed in another project that are run using the [trigger API](../../../ci/triggers/) rather than the parent-child
+ pipeline feature.
+
+This alternative ensures the compliance pipeline does not re-start the parent pipeline.
+
### Sharing and permissions
For your repository, you can set up features such as public access, repository features,
diff --git a/lib/api/entities/clusters/agent_authorization.rb b/lib/api/entities/clusters/agent_authorization.rb
index 6c533fff105..7bbe0f1ec45 100644
--- a/lib/api/entities/clusters/agent_authorization.rb
+++ b/lib/api/entities/clusters/agent_authorization.rb
@@ -5,7 +5,7 @@ module API
module Clusters
class AgentAuthorization < Grape::Entity
expose :agent_id, as: :id
- expose :project, with: Entities::ProjectIdentity, as: :config_project
+ expose :config_project, with: Entities::ProjectIdentity
expose :config, as: :configuration
end
end
diff --git a/lib/api/entities/user.rb b/lib/api/entities/user.rb
index 5c46233a639..051f8b49031 100644
--- a/lib/api/entities/user.rb
+++ b/lib/api/entities/user.rb
@@ -4,6 +4,7 @@ module API
module Entities
class User < UserBasic
include UsersHelper
+ include TimeZoneHelper
include ActionView::Helpers::SanitizeHelper
expose :created_at, if: ->(user, opts) { Ability.allowed?(opts[:current_user], :read_user_profile, user) }
@@ -24,6 +25,10 @@ module API
expose :bio_html do |user|
strip_tags(user.bio)
end
+
+ expose :local_time do |user|
+ local_time(user.timezone)
+ end
end
end
end
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index 1603365d0af..557df5c0ded 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -1141,6 +1141,9 @@ msgstr ""
msgid "(revoked)"
msgstr ""
+msgid "(target)"
+msgstr ""
+
msgid "(we need your current password to confirm your changes)"
msgstr ""
@@ -3341,6 +3344,9 @@ msgstr ""
msgid "All users must have a name."
msgstr ""
+msgid "All users with matching cards"
+msgstr ""
+
msgid "Allow \"%{group_name}\" to sign you in"
msgstr ""
@@ -6309,6 +6315,9 @@ msgstr ""
msgid "Capacity threshold"
msgstr ""
+msgid "Card number:"
+msgstr ""
+
msgid "CascadingSettings|Enforce for all subgroups"
msgstr ""
@@ -9924,10 +9933,7 @@ msgstr ""
msgid "CredentialsInventory|SSH Keys"
msgstr ""
-msgid "Credit card validated at:"
-msgstr ""
-
-msgid "Credit card validated:"
+msgid "Credit card:"
msgstr ""
msgid "Critical vulnerabilities present"
@@ -9984,6 +9990,9 @@ msgstr ""
msgid "Current sign-in at:"
msgstr ""
+msgid "Current sign-in ip"
+msgstr ""
+
msgid "Current vulnerabilities count"
msgstr ""
@@ -13806,6 +13815,9 @@ msgstr ""
msgid "Expiration date (optional)"
msgstr ""
+msgid "Expiration date:"
+msgstr ""
+
msgid "Expired"
msgstr ""
@@ -16853,6 +16865,9 @@ msgstr ""
msgid "History of authentications"
msgstr ""
+msgid "Holder name:"
+msgstr ""
+
msgid "Home page URL"
msgstr ""
@@ -22939,6 +22954,9 @@ msgstr ""
msgid "No contributions were found"
msgstr ""
+msgid "No credit card data for matching"
+msgstr ""
+
msgid "No credit card required."
msgstr ""
@@ -31589,6 +31607,9 @@ msgstr ""
msgid "Smartcard authentication failed: client certificate header is missing."
msgstr ""
+msgid "Snippet"
+msgstr ""
+
msgid "Snippets"
msgstr ""
@@ -31613,6 +31634,9 @@ msgstr ""
msgid "SnippetsEmptyState|There are no snippets to show."
msgstr ""
+msgid "Snippets|%{spammable_titlecase} was submitted to Akismet successfully."
+msgstr ""
+
msgid "Snippets|Add another file %{num}/%{total}"
msgstr ""
@@ -31622,6 +31646,9 @@ msgstr ""
msgid "Snippets|Description (optional)"
msgstr ""
+msgid "Snippets|Error with Akismet. Please check the logs for more info."
+msgstr ""
+
msgid "Snippets|Files"
msgstr ""
@@ -36953,6 +36980,9 @@ msgstr ""
msgid "User and IP rate limits"
msgstr ""
+msgid "User created at"
+msgstr ""
+
msgid "User does not have a pending request"
msgstr ""
@@ -37313,6 +37343,15 @@ msgstr ""
msgid "Validate your GitLab CI configuration file"
msgstr ""
+msgid "Validated at"
+msgstr ""
+
+msgid "Validated at:"
+msgstr ""
+
+msgid "Validated:"
+msgstr ""
+
msgid "Validations failed."
msgstr ""
@@ -40851,6 +40890,9 @@ msgstr ""
msgid "originating vulnerability"
msgstr ""
+msgid "other card matches"
+msgstr ""
+
msgid "out of %d total test"
msgid_plural "out of %d total tests"
msgstr[0] ""
diff --git a/scripts/rspec_helpers.sh b/scripts/rspec_helpers.sh
index 280a1586de3..455aaa37692 100644
--- a/scripts/rspec_helpers.sh
+++ b/scripts/rspec_helpers.sh
@@ -98,7 +98,7 @@ function rspec_simple_job() {
}
function rspec_db_library_code() {
- local db_files="spec/lib/gitlab/database/ spec/support/helpers/database/"
+ local db_files="spec/lib/gitlab/database/"
rspec_simple_job "-- ${db_files}"
}
diff --git a/spec/frontend/cycle_analytics/utils_spec.js b/spec/frontend/cycle_analytics/utils_spec.js
index 69fed879fd8..74d64cd8d71 100644
--- a/spec/frontend/cycle_analytics/utils_spec.js
+++ b/spec/frontend/cycle_analytics/utils_spec.js
@@ -1,7 +1,6 @@
import { useFakeDate } from 'helpers/fake_date';
import {
transformStagesForPathNavigation,
- timeSummaryForPathNavigation,
medianTimeToParsedSeconds,
formatMedianValues,
filterStagesByHiddenStatus,
@@ -47,21 +46,6 @@ describe('Value stream analytics utils', () => {
});
});
- describe('timeSummaryForPathNavigation', () => {
- it.each`
- unit | value | result
- ${'months'} | ${1.5} | ${'1.5M'}
- ${'weeks'} | ${1.25} | ${'1.5w'}
- ${'days'} | ${2} | ${'2d'}
- ${'hours'} | ${10} | ${'10h'}
- ${'minutes'} | ${20} | ${'20m'}
- ${'seconds'} | ${10} | ${'<1m'}
- ${'seconds'} | ${0} | ${'-'}
- `('will format $value $unit to $result', ({ unit, value, result }) => {
- expect(timeSummaryForPathNavigation({ [unit]: value })).toBe(result);
- });
- });
-
describe('medianTimeToParsedSeconds', () => {
it.each`
value | result
diff --git a/spec/frontend/lib/utils/datetime/date_format_utility_spec.js b/spec/frontend/lib/utils/datetime/date_format_utility_spec.js
index 942ba56196e..1adc70450e8 100644
--- a/spec/frontend/lib/utils/datetime/date_format_utility_spec.js
+++ b/spec/frontend/lib/utils/datetime/date_format_utility_spec.js
@@ -118,3 +118,18 @@ describe('date_format_utility.js', () => {
});
});
});
+
+describe('formatTimeAsSummary', () => {
+ it.each`
+ unit | value | result
+ ${'months'} | ${1.5} | ${'1.5M'}
+ ${'weeks'} | ${1.25} | ${'1.5w'}
+ ${'days'} | ${2} | ${'2d'}
+ ${'hours'} | ${10} | ${'10h'}
+ ${'minutes'} | ${20} | ${'20m'}
+ ${'seconds'} | ${10} | ${'<1m'}
+ ${'seconds'} | ${0} | ${'-'}
+ `('will format $value $unit to $result', ({ unit, value, result }) => {
+ expect(utils.formatTimeAsSummary({ [unit]: value })).toBe(result);
+ });
+});
diff --git a/spec/frontend/snippets/components/show_spec.js b/spec/frontend/snippets/components/show_spec.js
index b7b638b5137..af61f4ea54f 100644
--- a/spec/frontend/snippets/components/show_spec.js
+++ b/spec/frontend/snippets/components/show_spec.js
@@ -41,19 +41,23 @@ describe('Snippet view app', () => {
},
});
}
+
+ const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
+ const findEmbedDropdown = () => wrapper.findComponent(EmbedDropdown);
+
afterEach(() => {
wrapper.destroy();
});
it('renders loader while the query is in flight', () => {
createComponent({ loading: true });
- expect(wrapper.find(GlLoadingIcon).exists()).toBe(true);
+ expect(findLoadingIcon().exists()).toBe(true);
});
- it('renders all simple components after the query is finished', () => {
+ it('renders all simple components required after the query is finished', () => {
createComponent();
- expect(wrapper.find(SnippetHeader).exists()).toBe(true);
- expect(wrapper.find(SnippetTitle).exists()).toBe(true);
+ expect(wrapper.findComponent(SnippetHeader).exists()).toBe(true);
+ expect(wrapper.findComponent(SnippetTitle).exists()).toBe(true);
});
it('renders embed dropdown component if visibility allows', () => {
@@ -65,7 +69,7 @@ describe('Snippet view app', () => {
},
},
});
- expect(wrapper.find(EmbedDropdown).exists()).toBe(true);
+ expect(findEmbedDropdown().exists()).toBe(true);
});
it('renders correct snippet-blob components', () => {
@@ -98,7 +102,7 @@ describe('Snippet view app', () => {
},
},
});
- expect(wrapper.find(EmbedDropdown).exists()).toBe(isRendered);
+ expect(findEmbedDropdown().exists()).toBe(isRendered);
});
});
@@ -120,7 +124,7 @@ describe('Snippet view app', () => {
},
},
});
- expect(wrapper.find(CloneDropdownButton).exists()).toBe(isRendered);
+ expect(wrapper.findComponent(CloneDropdownButton).exists()).toBe(isRendered);
},
);
});
diff --git a/spec/frontend/snippets/components/snippet_header_spec.js b/spec/frontend/snippets/components/snippet_header_spec.js
index fb95be3a77c..552a1c6fcde 100644
--- a/spec/frontend/snippets/components/snippet_header_spec.js
+++ b/spec/frontend/snippets/components/snippet_header_spec.js
@@ -1,23 +1,30 @@
import { GlButton, GlModal, GlDropdown } from '@gitlab/ui';
import { mount } from '@vue/test-utils';
import { ApolloMutation } from 'vue-apollo';
+import MockAdapter from 'axios-mock-adapter';
import { useMockLocationHelper } from 'helpers/mock_window_location_helper';
import waitForPromises from 'helpers/wait_for_promises';
import { Blob, BinaryBlob } from 'jest/blob/components/mock_data';
import { differenceInMilliseconds } from '~/lib/utils/datetime_utility';
-import SnippetHeader from '~/snippets/components/snippet_header.vue';
+import SnippetHeader, { i18n } from '~/snippets/components/snippet_header.vue';
import DeleteSnippetMutation from '~/snippets/mutations/deleteSnippet.mutation.graphql';
+import axios from '~/lib/utils/axios_utils';
+import createFlash, { FLASH_TYPES } from '~/flash';
+
+jest.mock('~/flash');
describe('Snippet header component', () => {
let wrapper;
let snippet;
let mutationTypes;
let mutationVariables;
+ let mock;
let errorMsg;
let err;
const originalRelativeUrlRoot = gon.relative_url_root;
const reportAbusePath = '/-/snippets/42/mark_as_spam';
+ const canReportSpam = true;
const GlEmoji = { template: '<img/>' };
@@ -47,6 +54,7 @@ describe('Snippet header component', () => {
mocks: { $apollo },
provide: {
reportAbusePath,
+ canReportSpam,
...provide,
},
propsData: {
@@ -118,10 +126,13 @@ describe('Snippet header component', () => {
RESOLVE: jest.fn(() => Promise.resolve({ data: { destroySnippet: { errors: [] } } })),
REJECT: jest.fn(() => Promise.reject(err)),
};
+
+ mock = new MockAdapter(axios);
});
afterEach(() => {
wrapper.destroy();
+ mock.restore();
gon.relative_url_root = originalRelativeUrlRoot;
});
@@ -186,7 +197,6 @@ describe('Snippet header component', () => {
{
category: 'primary',
disabled: false,
- href: reportAbusePath,
text: 'Submit as spam',
variant: 'default',
},
@@ -205,7 +215,6 @@ describe('Snippet header component', () => {
text: 'Delete',
},
{
- href: reportAbusePath,
text: 'Submit as spam',
title: 'Submit as spam',
},
@@ -249,6 +258,31 @@ describe('Snippet header component', () => {
);
});
+ describe('submit snippet as spam', () => {
+ beforeEach(async () => {
+ createComponent();
+ });
+
+ it.each`
+ request | variant | text
+ ${200} | ${'SUCCESS'} | ${i18n.snippetSpamSuccess}
+ ${500} | ${'DANGER'} | ${i18n.snippetSpamFailure}
+ `(
+ 'renders a "$variant" flash message with "$text" message for a request with a "$request" response',
+ async ({ request, variant, text }) => {
+ const submitAsSpamBtn = findButtons().at(2);
+ mock.onPost(reportAbusePath).reply(request);
+ submitAsSpamBtn.trigger('click');
+ await waitForPromises();
+
+ expect(createFlash).toHaveBeenLastCalledWith({
+ message: expect.stringContaining(text),
+ type: FLASH_TYPES[variant],
+ });
+ },
+ );
+ });
+
describe('with guest user', () => {
beforeEach(() => {
createComponent({
@@ -258,6 +292,7 @@ describe('Snippet header component', () => {
},
provide: {
reportAbusePath: null,
+ canReportSpam: false,
},
});
});
diff --git a/spec/frontend/vue_shared/components/user_popover/user_popover_spec.js b/spec/frontend/vue_shared/components/user_popover/user_popover_spec.js
index 926223e0670..09633daf587 100644
--- a/spec/frontend/vue_shared/components/user_popover/user_popover_spec.js
+++ b/spec/frontend/vue_shared/components/user_popover/user_popover_spec.js
@@ -9,6 +9,7 @@ const DEFAULT_PROPS = {
username: 'root',
name: 'Administrator',
location: 'Vienna',
+ localTime: '2:30 PM',
bot: false,
bio: null,
workInformation: null,
@@ -31,10 +32,11 @@ describe('User Popover Component', () => {
wrapper.destroy();
});
- const findUserStatus = () => wrapper.find('.js-user-status');
+ const findUserStatus = () => wrapper.findByTestId('user-popover-status');
const findTarget = () => document.querySelector('.js-user-link');
const findUserName = () => wrapper.find(UserNameWithStatus);
const findSecurityBotDocsLink = () => wrapper.findByTestId('user-popover-bot-docs-link');
+ const findUserLocalTime = () => wrapper.findByTestId('user-popover-local-time');
const createWrapper = (props = {}, options = {}) => {
wrapper = mountExtended(UserPopover, {
@@ -71,7 +73,6 @@ describe('User Popover Component', () => {
expect(wrapper.text()).toContain(DEFAULT_PROPS.user.name);
expect(wrapper.text()).toContain(DEFAULT_PROPS.user.username);
- expect(wrapper.text()).toContain(DEFAULT_PROPS.user.location);
});
it('shows icon for location', () => {
@@ -164,6 +165,25 @@ describe('User Popover Component', () => {
});
});
+ describe('local time', () => {
+ it('should show local time when it is available', () => {
+ createWrapper();
+
+ expect(findUserLocalTime().exists()).toBe(true);
+ });
+
+ it('should not show local time when it is not available', () => {
+ const user = {
+ ...DEFAULT_PROPS.user,
+ localTime: null,
+ };
+
+ createWrapper({ user });
+
+ expect(findUserLocalTime().exists()).toBe(false);
+ });
+ });
+
describe('status data', () => {
it('should show only message', () => {
const user = { ...DEFAULT_PROPS.user, status: { message_html: 'Hello World' } };
@@ -256,5 +276,11 @@ describe('User Popover Component', () => {
const securityBotDocsLink = findSecurityBotDocsLink();
expect(securityBotDocsLink.text()).toBe('Learn more about %<>\';"');
});
+
+ it('does not display local time', () => {
+ createWrapper({ user: SECURITY_BOT_USER });
+
+ expect(findUserLocalTime().exists()).toBe(false);
+ });
});
});
diff --git a/spec/lib/api/entities/clusters/agent_authorization_spec.rb b/spec/lib/api/entities/clusters/agent_authorization_spec.rb
index 101a8af4ac4..3a1deb43bf8 100644
--- a/spec/lib/api/entities/clusters/agent_authorization_spec.rb
+++ b/spec/lib/api/entities/clusters/agent_authorization_spec.rb
@@ -3,15 +3,34 @@
require 'spec_helper'
RSpec.describe API::Entities::Clusters::AgentAuthorization do
- let_it_be(:authorization) { create(:agent_group_authorization) }
-
subject { described_class.new(authorization).as_json }
- it 'includes basic fields' do
- expect(subject).to include(
- id: authorization.agent_id,
- config_project: a_hash_including(id: authorization.agent.project_id),
- configuration: authorization.config
- )
+ shared_examples 'generic authorization' do
+ it 'includes shared fields' do
+ expect(subject).to include(
+ id: authorization.agent_id,
+ config_project: a_hash_including(id: authorization.agent.project_id),
+ configuration: authorization.config
+ )
+ end
+ end
+
+ context 'project authorization' do
+ let(:authorization) { create(:agent_project_authorization) }
+
+ include_examples 'generic authorization'
+ end
+
+ context 'group authorization' do
+ let(:authorization) { create(:agent_group_authorization) }
+
+ include_examples 'generic authorization'
+ end
+
+ context 'implicit authorization' do
+ let(:agent) { create(:cluster_agent) }
+ let(:authorization) { Clusters::Agents::ImplicitAuthorization.new(agent: agent) }
+
+ include_examples 'generic authorization'
end
end
diff --git a/spec/lib/api/entities/user_spec.rb b/spec/lib/api/entities/user_spec.rb
index 860f007f284..9c9a157d68a 100644
--- a/spec/lib/api/entities/user_spec.rb
+++ b/spec/lib/api/entities/user_spec.rb
@@ -3,10 +3,13 @@
require 'spec_helper'
RSpec.describe API::Entities::User do
- let(:user) { create(:user) }
+ let_it_be(:timezone) { 'America/Los_Angeles' }
+
+ let(:user) { create(:user, timezone: timezone) }
let(:current_user) { create(:user) }
+ let(:entity) { described_class.new(user, current_user: current_user) }
- subject { described_class.new(user, current_user: current_user).as_json }
+ subject { entity.as_json }
it 'exposes correct attributes' do
expect(subject).to include(:bio, :location, :public_email, :skype, :linkedin, :twitter, :website_url, :organization, :job_title, :work_information, :pronouns)
@@ -35,4 +38,10 @@ RSpec.describe API::Entities::User do
expect(subject[:bot]).to eq(true)
end
end
+
+ it 'exposes local_time' do
+ local_time = '2:30 PM'
+ expect(entity).to receive(:local_time).with(timezone).and_return(local_time)
+ expect(subject[:local_time]).to eq(local_time)
+ end
end
diff --git a/spec/models/clusters/agents/group_authorization_spec.rb b/spec/models/clusters/agents/group_authorization_spec.rb
index 2a99fb26e3f..baeb8f5464e 100644
--- a/spec/models/clusters/agents/group_authorization_spec.rb
+++ b/spec/models/clusters/agents/group_authorization_spec.rb
@@ -7,4 +7,10 @@ RSpec.describe Clusters::Agents::GroupAuthorization do
it { is_expected.to belong_to(:group).class_name('::Group').required }
it { expect(described_class).to validate_jsonb_schema(['config']) }
+
+ describe '#config_project' do
+ let(:record) { create(:agent_group_authorization) }
+
+ it { expect(record.config_project).to eq(record.agent.project) }
+ end
end
diff --git a/spec/models/clusters/agents/implicit_authorization_spec.rb b/spec/models/clusters/agents/implicit_authorization_spec.rb
index 69aa55a350e..2d6c3ddb426 100644
--- a/spec/models/clusters/agents/implicit_authorization_spec.rb
+++ b/spec/models/clusters/agents/implicit_authorization_spec.rb
@@ -9,6 +9,6 @@ RSpec.describe Clusters::Agents::ImplicitAuthorization do
it { expect(subject.agent).to eq(agent) }
it { expect(subject.agent_id).to eq(agent.id) }
- it { expect(subject.project).to eq(agent.project) }
+ it { expect(subject.config_project).to eq(agent.project) }
it { expect(subject.config).to be_nil }
end
diff --git a/spec/models/clusters/agents/project_authorization_spec.rb b/spec/models/clusters/agents/project_authorization_spec.rb
index 134c70739ac..9ba259356c7 100644
--- a/spec/models/clusters/agents/project_authorization_spec.rb
+++ b/spec/models/clusters/agents/project_authorization_spec.rb
@@ -7,4 +7,10 @@ RSpec.describe Clusters::Agents::ProjectAuthorization do
it { is_expected.to belong_to(:project).class_name('Project').required }
it { expect(described_class).to validate_jsonb_schema(['config']) }
+
+ describe '#config_project' do
+ let(:record) { create(:agent_project_authorization) }
+
+ it { expect(record.config_project).to eq(record.agent.project) }
+ end
end
diff --git a/spec/models/users/credit_card_validation_spec.rb b/spec/models/users/credit_card_validation_spec.rb
index 667649bd5ed..d2b4f5ebd65 100644
--- a/spec/models/users/credit_card_validation_spec.rb
+++ b/spec/models/users/credit_card_validation_spec.rb
@@ -7,4 +7,19 @@ RSpec.describe Users::CreditCardValidation do
it { is_expected.to validate_length_of(:holder_name).is_at_most(26) }
it { is_expected.to validate_numericality_of(:last_digits).is_less_than_or_equal_to(9999) }
+
+ describe '.similar_records' do
+ let(:card_details) { subject.attributes.slice(:expiration_date, :last_digits, :holder_name) }
+
+ subject(:credit_card_validation) { create(:credit_card_validation) }
+
+ let!(:match1) { create(:credit_card_validation, card_details) }
+ let!(:other1) { create(:credit_card_validation, card_details.merge(last_digits: 9)) }
+ let!(:match2) { create(:credit_card_validation, card_details) }
+ let!(:other2) { create(:credit_card_validation, card_details.merge(holder_name: 'foo bar')) }
+
+ it 'returns records with matching credit card, ordered by credit_card_validated_at' do
+ expect(subject.similar_records).to eq([match2, match1, subject])
+ end
+ end
end