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|<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|<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, }; @@ -266,14 +294,14 @@ export default { - {{ - errorMessage - }} + + {{ errorMessage }} + - + 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 {
- + {{ user.bio }}
- + {{ user.workInformation }}
+
+ + {{ user.location }} +
+
+ + {{ user.localTime }} +
-
- - {{ user.location }} -
-
+
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: '' }; @@ -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(:current_user) { create(:user) } + let_it_be(:timezone) { 'America/Los_Angeles' } - subject { described_class.new(user, current_user: current_user).as_json } + let(:user) { create(:user, timezone: timezone) } + let(:current_user) { create(:user) } + let(:entity) { described_class.new(user, current_user: current_user) } + + 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