diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2023-11-09 00:09:31 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2023-11-09 00:09:31 +0300 |
commit | 11f9ca7e2413e4520f02c4c1b82cec0bce3789bf (patch) | |
tree | d8081c72b9a40b745b248eed09e7ed4a47069518 | |
parent | 2308cd50203f5b377e4d6e03d017066507beacdf (diff) |
Add latest changes from gitlab-org/gitlab@master
65 files changed, 915 insertions, 851 deletions
diff --git a/.gitlab/CODEOWNERS b/.gitlab/CODEOWNERS index 5bd781f4277..a86bc9417df 100644 --- a/.gitlab/CODEOWNERS +++ b/.gitlab/CODEOWNERS @@ -759,89 +759,39 @@ lib/gitlab/checks/** /doc/ci/services/ @fneill /doc/ci/test_cases/ @msedlakjakubowski /doc/ci/testing/code_quality.md @rdickenson -/doc/development/activitypub/ @msedlakjakubowski -/doc/development/advanced_search.md @ashrafkhamis -/doc/development/ai_features/ @sselhorn -/doc/development/application_limits.md @axil -/doc/development/audit_event_guide/ @eread -/doc/development/auto_devops.md @phillipwells -/doc/development/avoiding_required_stops.md @axil -/doc/development/backend/create_source_code_be/ @msedlakjakubowski -/doc/development/build_test_package.md @axil -/doc/development/bulk_import.md @eread @ashrafkhamis -/doc/development/cached_queries.md @jglassman1 -/doc/development/cascading_settings.md @jglassman1 -/doc/development/cells/ @lciutacu -/doc/development/chatops_on_gitlabcom.md @phillipwells -/doc/development/cicd/ @marcel.amirault -/doc/development/cloud_connector/ @jglassman1 -/doc/development/code_intelligence/ @aqualls -/doc/development/code_owners/ @msedlakjakubowski -/doc/development/code_suggestions/ @jglassman1 -/doc/development/contributing/verify/ @marcel.amirault -/doc/development/database/ @aqualls -/doc/development/database/filtering_by_label.md @msedlakjakubowski -/doc/development/database/multiple_databases.md @lciutacu -/doc/development/database_review.md @aqualls -/doc/development/developing_with_solargraph.md @msedlakjakubowski -/doc/development/distribution/ @axil +/doc/development/advanced_search.md @gitlab-org/search-team/migration-maintainers +/doc/development/application_limits.md @gitlab-org/distribution +/doc/development/audit_event_guide/ @gitlab-org/govern/security-policies-frontend @gitlab-org/govern/threat-insights-frontend-team @gitlab-org/govern/threat-insights-backend-team +/doc/development/avoiding_required_stops.md @gitlab-org/distribution +/doc/development/build_test_package.md @gitlab-org/distribution +/doc/development/cascading_settings.md @gitlab-org/govern/authentication/approvers +/doc/development/cells/ @abdwdd @alexpooley @manojmj +/doc/development/cicd/ @gitlab-org/maintainers/cicd-verify +/doc/development/contributing/verify/ @gitlab-org/maintainers/cicd-verify +/doc/development/database/ @abdwdd @alexpooley @manojmj +/doc/development/distribution/ @gitlab-org/distribution /doc/development/documentation/ @sselhorn -/doc/development/export_csv.md @eread @ashrafkhamis -/doc/development/fe_guide/customizable_dashboards.md @lciutacu -/doc/development/fe_guide/merge_request_widget_extensions.md @aqualls -/doc/development/fe_guide/onboarding_course/ @sselhorn -/doc/development/fe_guide/source_editor.md @msedlakjakubowski -/doc/development/fe_guide/view_component.md @sselhorn -/doc/development/feature_categorization/ @sselhorn -/doc/development/fips_compliance.md @msedlakjakubowski -/doc/development/geo.md @axil -/doc/development/geo/ @axil -/doc/development/git_object_deduplication.md @eread -/doc/development/gitaly.md @eread -/doc/development/gitlab_flavored_markdown/ @ashrafkhamis -/doc/development/gitlab_shell/ @msedlakjakubowski -/doc/development/graphql_guide/ @eread @ashrafkhamis -/doc/development/graphql_guide/batchloader.md @aqualls -/doc/development/i18n/ @eread @ashrafkhamis -/doc/development/identity_verification.md @phillipwells -/doc/development/image_scaling.md @lciutacu -/doc/development/import_export.md @eread @ashrafkhamis -/doc/development/integrations/ @eread @ashrafkhamis -/doc/development/integrations/secure.md @rdickenson -/doc/development/integrations/secure_partner_integration.md @rdickenson -/doc/development/internal_analytics/ @lciutacu -/doc/development/internal_api/ @msedlakjakubowski -/doc/development/issuable-like-models.md @msedlakjakubowski -/doc/development/issue_types.md @msedlakjakubowski -/doc/development/kubernetes.md @phillipwells -/doc/development/lfs.md @msedlakjakubowski -/doc/development/maintenance_mode.md @axil -/doc/development/merge_request_concepts/ @aqualls -/doc/development/merge_request_concepts/rate_limits.md @msedlakjakubowski -/doc/development/migration_style_guide.md @aqualls -/doc/development/navigation_sidebar.md @sselhorn -/doc/development/omnibus.md @axil -/doc/development/organization/ @lciutacu -/doc/development/packages/ @phillipwells -/doc/development/packages/cleanup_policies.md @marcel.amirault -/doc/development/packages/dependency_proxy.md @marcel.amirault -/doc/development/packages/harbor_registry_development.md @marcel.amirault -/doc/development/permissions.md @jglassman1 -/doc/development/permissions/ @jglassman1 -/doc/development/policies.md @jglassman1 -/doc/development/project_templates.md @msedlakjakubowski -/doc/development/project_templates/ @msedlakjakubowski -/doc/development/rails_endpoints/ @msedlakjakubowski -/doc/development/real_time.md @jglassman1 -/doc/development/search/ @ashrafkhamis -/doc/development/sec/ @rdickenson -/doc/development/spam_protection_and_captcha/ @phillipwells -/doc/development/sql.md @aqualls -/doc/development/value_stream_analytics.md @lciutacu -/doc/development/value_stream_analytics/ @lciutacu -/doc/development/work_items.md @msedlakjakubowski -/doc/development/work_items_widgets.md @msedlakjakubowski -/doc/development/workhorse/ @msedlakjakubowski +/doc/development/fe_guide/customizable_dashboards.md @gitlab-org/analytics-section/product-analytics/engineers/frontend +/doc/development/fe_guide/onboarding_course/ @gitlab-org/manage/foundations/engineering +/doc/development/fe_guide/view_component.md @gitlab-org/manage/foundations/engineering +/doc/development/git_object_deduplication.md @proglottis @toon +/doc/development/gitaly.md @proglottis @toon +/doc/development/gitlab_flavored_markdown/ @gitlab-org/maintainers/remote-development/backend @gitlab-org/maintainers/remote-development/frontend +/doc/development/gitpod_internals.md @gl-quality/eng-prod +/doc/development/image_scaling.md @abdwdd @alexpooley @manojmj +/doc/development/internal_analytics/ @gitlab-org/analytics-section/product-analytics/engineers/frontend @gitlab-org/analytics-section/analytics-instrumentation/engineers +/doc/development/navigation_sidebar.md @gitlab-org/manage/foundations/engineering +/doc/development/omnibus.md @gitlab-org/distribution +/doc/development/organization/ @abdwdd @alexpooley @manojmj +/doc/development/permissions.md @gitlab-org/govern/authentication/approvers +/doc/development/permissions/ @gitlab-org/govern/authentication/approvers +/doc/development/permissions/custom_roles.md @gitlab-org/govern/authorization/approvers +/doc/development/pipelines/ @gl-quality/eng-prod +/doc/development/policies.md @gitlab-org/govern/authentication/approvers +/doc/development/search/ @gitlab-org/search-team/migration-maintainers +/doc/development/sec/ @gitlab-org/govern/threat-insights-frontend-team +/doc/development/sec/gemnasium_analyzer_data.md @gitlab-org/secure/composition-analysis-be @gitlab-org/secure/static-analysis +/doc/development/software_design.md @gl-quality/eng-prod /doc/downgrade_ee_to_ce/ @axil /doc/drawers/ @ashrafkhamis /doc/editor_extensions/ @aqualls @@ -873,6 +823,7 @@ lib/gitlab/checks/** /doc/security/ @jglassman1 /doc/security/email_verification.md @phillipwells /doc/security/identity_verification.md @phillipwells +/doc/solutions/ @jfullam @brianwald @Darwinjs /doc/subscriptions/ @fneill /doc/subscriptions/gitlab_dedicated/ @lyspin /doc/topics/autodevops/ @phillipwells diff --git a/app/assets/javascripts/admin/users/components/app.vue b/app/assets/javascripts/admin/users/components/app.vue index a3abd904a6b..b0caffb6ca6 100644 --- a/app/assets/javascripts/admin/users/components/app.vue +++ b/app/assets/javascripts/admin/users/components/app.vue @@ -1,9 +1,15 @@ <script> -import UsersTable from './users_table.vue'; +import { createAlert } from '~/alert'; +import { s__ } from '~/locale'; +import { convertNodeIdsFromGraphQLIds } from '~/graphql_shared/utils'; +import UsersTable from '~/vue_shared/components/users_table/users_table.vue'; +import getUsersGroupCountsQuery from '../graphql/queries/get_users_group_counts.query.graphql'; +import UserActions from './user_actions.vue'; export default { components: { UsersTable, + UserActions, }, props: { users: { @@ -16,11 +22,64 @@ export default { required: true, }, }, + data() { + return { + groupCounts: {}, + }; + }, + apollo: { + groupCounts: { + query: getUsersGroupCountsQuery, + variables() { + return { + usernames: this.users.map((user) => user.username), + }; + }, + update(data) { + const nodes = data?.users?.nodes || []; + const parsedIds = convertNodeIdsFromGraphQLIds(nodes); + + return parsedIds.reduce((acc, { id, groupCount }) => { + acc[id] = groupCount || 0; + return acc; + }, {}); + }, + error(error) { + createAlert({ + message: this.$options.i18n.groupCountFetchError, + captureError: true, + error, + }); + }, + skip() { + return !this.users.length; + }, + }, + }, + computed: { + groupCountsLoading() { + return this.$apollo.queries.groupCounts.loading; + }, + }, + i18n: { + groupCountFetchError: s__( + 'AdminUsers|Could not load user group counts. Please refresh the page to try again.', + ), + }, }; </script> <template> <div> - <users-table :users="users" :paths="paths" /> + <users-table + :users="users" + :admin-user-path="paths.adminUser" + :group-counts="groupCounts" + :group-counts-loading="groupCountsLoading" + > + <template #user-actions="{ user }"> + <user-actions :user="user" :paths="paths" :show-button-labels="true" /> + </template> + </users-table> </div> </template> diff --git a/app/assets/javascripts/admin/users/constants.js b/app/assets/javascripts/admin/users/constants.js index 43c9a8749cd..73383623aa2 100644 --- a/app/assets/javascripts/admin/users/constants.js +++ b/app/assets/javascripts/admin/users/constants.js @@ -1,9 +1,5 @@ import { s__, __ } from '~/locale'; -export const USER_AVATAR_SIZE = 32; - -export const LENGTH_OF_USER_NOTE_TOOLTIP = 100; - export const I18N_USER_ACTIONS = { edit: __('Edit'), userAdministration: s__('AdminUsers|User administration'), diff --git a/app/assets/javascripts/ci/job_details/components/log/line_header.vue b/app/assets/javascripts/ci/job_details/components/log/line_header.vue index 658a94e6af4..d36701323da 100644 --- a/app/assets/javascripts/ci/job_details/components/log/line_header.vue +++ b/app/assets/javascripts/ci/job_details/components/log/line_header.vue @@ -17,7 +17,8 @@ export default { }, isClosed: { type: Boolean, - required: true, + required: false, + default: false, }, path: { type: String, diff --git a/app/assets/javascripts/ci/job_details/job_app.vue b/app/assets/javascripts/ci/job_details/job_app.vue index 119f8259be7..e0708289b43 100644 --- a/app/assets/javascripts/ci/job_details/job_app.vue +++ b/app/assets/javascripts/ci/job_details/job_app.vue @@ -307,7 +307,7 @@ export default { @scrollJobLogBottom="scrollBottom" @searchResults="setSearchResults" /> - <log :job-log="jobLog" :is-complete="isJobLogComplete" :search-results="searchResults" /> + <log :search-results="searchResults" /> </div> <!-- EO job log --> diff --git a/app/assets/javascripts/ci/job_details/store/actions.js b/app/assets/javascripts/ci/job_details/store/actions.js index fa23589f7d6..6f538e3b3d4 100644 --- a/app/assets/javascripts/ci/job_details/store/actions.js +++ b/app/assets/javascripts/ci/job_details/store/actions.js @@ -175,7 +175,7 @@ export const fetchJobLog = ({ dispatch, state }) => } }) .catch((e) => { - if (e.response.status === HTTP_STATUS_FORBIDDEN) { + if (e.response?.status === HTTP_STATUS_FORBIDDEN) { dispatch('receiveJobLogUnauthorizedError'); } else { reportToSentry('job_actions', e); diff --git a/app/assets/javascripts/ci/job_details/store/utils.js b/app/assets/javascripts/ci/job_details/store/utils.js index b18a3fa162d..c8b33638821 100644 --- a/app/assets/javascripts/ci/job_details/store/utils.js +++ b/app/assets/javascripts/ci/job_details/store/utils.js @@ -117,28 +117,31 @@ export const getNextLineNumber = (acc) => { * @returns Array parsed log lines */ export const logLinesParser = (lines = [], prevLogLines = [], hash = '') => - lines.reduce((acc, line) => { - const lineNumber = getNextLineNumber(acc); - - const last = acc[acc.length - 1]; - - // If the object is an header, we parse it into another structure - if (line.section_header) { - acc.push(parseHeaderLine(line, lineNumber, hash)); - } else if (isCollapsibleSection(acc, last, line)) { - // if the object belongs to a nested section, we append it to the new `lines` array of the - // previously formatted header - last.lines.push(parseLine(line, lineNumber)); - } else if (line.section_duration) { - // if the line has section_duration, we look for the correct header to add it - addDurationToHeader(acc, line); - } else { - // otherwise it's a regular line - acc.push(parseLine(line, lineNumber)); - } + lines.reduce( + (acc, line) => { + const lineNumber = getNextLineNumber(acc); + + const last = acc[acc.length - 1]; + + // If the object is an header, we parse it into another structure + if (line.section_header) { + acc.push(parseHeaderLine(line, lineNumber, hash)); + } else if (isCollapsibleSection(acc, last, line)) { + // if the object belongs to a nested section, we append it to the new `lines` array of the + // previously formatted header + last.lines.push(parseLine(line, lineNumber)); + } else if (line.section_duration) { + // if the line has section_duration, we look for the correct header to add it + addDurationToHeader(acc, line); + } else { + // otherwise it's a regular line + acc.push(parseLine(line, lineNumber)); + } - return acc; - }, prevLogLines); + return acc; + }, + [...prevLogLines], + ); /** * Finds the repeated offset, removes the old one diff --git a/app/assets/javascripts/vue_shared/components/users_table/constants.js b/app/assets/javascripts/vue_shared/components/users_table/constants.js new file mode 100644 index 00000000000..2a063a1be33 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/users_table/constants.js @@ -0,0 +1,3 @@ +export const USER_AVATAR_SIZE = 32; + +export const LENGTH_OF_USER_NOTE_TOOLTIP = 100; diff --git a/app/assets/javascripts/admin/users/components/user_avatar.vue b/app/assets/javascripts/vue_shared/components/users_table/user_avatar.vue index dd354794cf3..5d86f90880d 100644 --- a/app/assets/javascripts/admin/users/components/user_avatar.vue +++ b/app/assets/javascripts/vue_shared/components/users_table/user_avatar.vue @@ -1,7 +1,7 @@ <script> import { GlAvatarLabeled, GlBadge, GlIcon, GlTooltipDirective } from '@gitlab/ui'; import { truncate } from '~/lib/utils/text_utility'; -import { USER_AVATAR_SIZE, LENGTH_OF_USER_NOTE_TOOLTIP } from '../constants'; +import { USER_AVATAR_SIZE, LENGTH_OF_USER_NOTE_TOOLTIP } from './constants'; export default { directives: { @@ -23,12 +23,21 @@ export default { }, }, computed: { + subLabel() { + if (this.user.email) { + return { + label: this.user.email, + link: `mailto:${this.user.email}`, + }; + } + + return { + label: `@${this.user.username}`, + }; + }, adminUserHref() { return this.adminUserPath.replace('id', this.user.username); }, - adminUserMailto() { - return `mailto:${this.user.email}`; - }, userNoteShort() { return truncate(this.user.note, LENGTH_OF_USER_NOTE_TOOLTIP); }, @@ -48,9 +57,9 @@ export default { :size="$options.USER_AVATAR_SIZE" :src="user.avatarUrl" :label="user.name" - :sub-label="user.email" + :sub-label="subLabel.label" :label-link="adminUserHref" - :sub-label-link="adminUserMailto" + :sub-label-link="subLabel.link" > <template #meta> <div v-if="user.note" class="gl-text-gray-500 gl-p-1"> diff --git a/app/assets/javascripts/admin/users/components/users_table.vue b/app/assets/javascripts/vue_shared/components/users_table/users_table.vue index 65737be1e67..be164bb07a3 100644 --- a/app/assets/javascripts/admin/users/components/users_table.vue +++ b/app/assets/javascripts/vue_shared/components/users_table/users_table.vue @@ -1,12 +1,8 @@ <script> import { GlSkeletonLoader, GlTable } from '@gitlab/ui'; -import { createAlert } from '~/alert'; -import { convertNodeIdsFromGraphQLIds } from '~/graphql_shared/utils'; import { thWidthPercent } from '~/lib/utils/table_utility'; -import { s__, __ } from '~/locale'; +import { __ } from '~/locale'; import UserDate from '~/vue_shared/components/user_date.vue'; -import getUsersGroupCountsQuery from '../graphql/queries/get_users_group_counts.query.graphql'; -import UserActions from './user_actions.vue'; import UserAvatar from './user_avatar.vue'; export default { @@ -14,7 +10,6 @@ export default { GlSkeletonLoader, GlTable, UserAvatar, - UserActions, UserDate, }, props: { @@ -22,49 +17,20 @@ export default { type: Array, required: true, }, - paths: { - type: Object, + adminUserPath: { + type: String, required: true, }, - }, - data() { - return { - groupCounts: [], - }; - }, - apollo: { groupCounts: { - query: getUsersGroupCountsQuery, - variables() { - return { - usernames: this.users.map((user) => user.username), - }; - }, - update(data) { - const nodes = data?.users?.nodes || []; - const parsedIds = convertNodeIdsFromGraphQLIds(nodes); - - return parsedIds.reduce((acc, { id, groupCount }) => { - acc[id] = groupCount || 0; - return acc; - }, {}); - }, - error(error) { - createAlert({ - message: this.$options.i18n.groupCountFetchError, - captureError: true, - error, - }); - }, - skip() { - return !this.users.length; - }, + type: Object, + required: false, + default: () => ({}), + }, + groupCountsLoading: { + type: Boolean, + required: false, + default: false, }, - }, - i18n: { - groupCountFetchError: s__( - 'AdminUsers|Could not load user group counts. Please refresh the page to try again.', - ), }, fields: [ { @@ -112,7 +78,7 @@ export default { :tbody-tr-attr="{ 'data-testid': 'user-row-content' }" > <template #cell(name)="{ item: user }"> - <user-avatar :user="user" :admin-user-path="paths.adminUser" /> + <user-avatar :user="user" :admin-user-path="adminUserPath" /> </template> <template #cell(createdAt)="{ item: { createdAt } }"> @@ -125,17 +91,19 @@ export default { <template #cell(groupCount)="{ item: { id } }"> <div :data-testid="`user-group-count-${id}`"> - <gl-skeleton-loader v-if="$apollo.loading" :width="40" :lines="1" /> - <span v-else>{{ groupCounts[id] }}</span> + <gl-skeleton-loader v-if="groupCountsLoading" :width="40" :lines="1" /> + <span v-else>{{ groupCounts[id] || 0 }}</span> </div> </template> <template #cell(projectsCount)="{ item: { id, projectsCount } }"> - <div :data-testid="`user-project-count-${id}`">{{ projectsCount }}</div> + <div :data-testid="`user-project-count-${id}`"> + {{ projectsCount || 0 }} + </div> </template> <template #cell(settings)="{ item: user }"> - <user-actions :user="user" :paths="paths" :show-button-labels="true" /> + <slot name="user-actions" :user="user"></slot> </template> </gl-table> </div> diff --git a/app/controllers/projects/jobs_controller.rb b/app/controllers/projects/jobs_controller.rb index 5a419aab8e1..d5a7f25d4ce 100644 --- a/app/controllers/projects/jobs_controller.rb +++ b/app/controllers/projects/jobs_controller.rb @@ -15,6 +15,7 @@ class Projects::JobsController < Projects::ApplicationController before_action :authorize_read_build_report_results!, only: [:test_report_summary] before_action :authorize_update_build!, except: [:index, :show, :raw, :trace, :erase, :cancel, :unschedule, :test_report_summary] + before_action :authorize_cancel_build!, only: [:cancel] before_action :authorize_erase_build!, only: [:erase] before_action :authorize_use_build_terminal!, only: [:terminal, :terminal_websocket_authorize] before_action :verify_api_request!, only: :terminal_websocket_authorize @@ -193,6 +194,10 @@ class Projects::JobsController < Projects::ApplicationController return access_denied! unless can?(current_user, :update_build, @build) end + def authorize_cancel_build! + return access_denied! unless can?(current_user, :cancel_build, @build) + end + def authorize_erase_build! return access_denied! unless can?(current_user, :erase_build, @build) end diff --git a/app/graphql/types/permission_types/ci/pipeline.rb b/app/graphql/types/permission_types/ci/pipeline.rb index cfd68380005..94adbf7c59b 100644 --- a/app/graphql/types/permission_types/ci/pipeline.rb +++ b/app/graphql/types/permission_types/ci/pipeline.rb @@ -8,6 +8,7 @@ module Types abilities :admin_pipeline, :destroy_pipeline ability_field :update_pipeline, calls_gitaly: true + ability_field :cancel_pipeline, calls_gitaly: true end end end diff --git a/app/serializers/ci/job_entity.rb b/app/serializers/ci/job_entity.rb index 813938c2a18..828a9eb33a5 100644 --- a/app/serializers/ci/job_entity.rb +++ b/app/serializers/ci/job_entity.rb @@ -53,7 +53,7 @@ module Ci alias_method :job, :object def cancelable? - job.cancelable? && can?(request.current_user, :update_build, job) + job.cancelable? && can?(request.current_user, :cancel_build, job) end def retryable? diff --git a/app/services/ci/catalog/resources/release_service.rb b/app/services/ci/catalog/resources/release_service.rb new file mode 100644 index 00000000000..ad77bff3ef9 --- /dev/null +++ b/app/services/ci/catalog/resources/release_service.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +module Ci + module Catalog + module Resources + class ReleaseService + def initialize(release) + @release = release + @project = release.project + @errors = [] + end + + def execute + validate_catalog_resource + create_version + + if errors.empty? + ServiceResponse.success + else + ServiceResponse.error(message: errors.join(', ')) + end + end + + private + + attr_reader :project, :errors, :release + + def validate_catalog_resource + response = Ci::Catalog::Resources::ValidateService.new(project, release.sha).execute + return if response.success? + + errors << response.message + end + + def create_version + return if errors.present? + + response = Ci::Catalog::Resources::Versions::CreateService.new(release).execute + return if response.success? + + errors << response.message + end + end + end + end +end diff --git a/app/services/ci/retry_job_service.rb b/app/services/ci/retry_job_service.rb index d7c3e9e7f64..a8ea5ac6df0 100644 --- a/app/services/ci/retry_job_service.rb +++ b/app/services/ci/retry_job_service.rb @@ -39,10 +39,6 @@ module Ci ::Ci::CopyCrossDatabaseAssociationsService.new.execute(job, new_job) - if Feature.disabled?(:create_deployment_only_for_processable_jobs, project) - ::Deployments::CreateForJobService.new.execute(new_job) - end - ::MergeRequests::AddTodoWhenBuildFailsService .new(project: project) .close(new_job) diff --git a/app/services/personal_access_tokens/rotate_service.rb b/app/services/personal_access_tokens/rotate_service.rb index b765aacef68..32710629caf 100644 --- a/app/services/personal_access_tokens/rotate_service.rb +++ b/app/services/personal_access_tokens/rotate_service.rb @@ -9,7 +9,7 @@ module PersonalAccessTokens @token = token end - def execute + def execute(params = {}) return ServiceResponse.error(message: _('token already revoked')) if token.revoked? response = ServiceResponse.success @@ -21,7 +21,7 @@ module PersonalAccessTokens end target_user = token.user - new_token = target_user.personal_access_tokens.create(create_token_params(token)) + new_token = target_user.personal_access_tokens.create(create_token_params(token, params)) if new_token.persisted? response = ServiceResponse.success(payload: { personal_access_token: new_token }) @@ -39,12 +39,13 @@ module PersonalAccessTokens attr_reader :current_user, :token - def create_token_params(token) + def create_token_params(token, params) + expires_at = params[:expires_at] || (Date.today + EXPIRATION_PERIOD) { name: token.name, previous_personal_access_token_id: token.id, impersonation: token.impersonation, scopes: token.scopes, - expires_at: Date.today + EXPIRATION_PERIOD } + expires_at: expires_at } end end end diff --git a/app/services/releases/create_service.rb b/app/services/releases/create_service.rb index 034cb66c8b9..0e105ca3575 100644 --- a/app/services/releases/create_service.rb +++ b/app/services/releases/create_service.rb @@ -18,12 +18,6 @@ module Releases return tag unless tag.is_a?(Gitlab::Git::Tag) - if project.catalog_resource - response = Ci::Catalog::Resources::ValidateService.new(project, ref).execute - - return error(response.message) if response.error? - end - create_release(tag, evidence_pipeline) end @@ -56,6 +50,12 @@ module Releases def create_release(tag, evidence_pipeline) release = build_release(tag) + if project.catalog_resource && release.valid? + response = Ci::Catalog::Resources::ReleaseService.new(release).execute + + return error(response.message) if response.error? + end + release.save! notify_create_release(release) diff --git a/app/views/layouts/devise.html.haml b/app/views/layouts/devise.html.haml index 366a51ef29e..dcc239a2700 100644 --- a/app/views/layouts/devise.html.haml +++ b/app/views/layouts/devise.html.haml @@ -3,7 +3,7 @@ !!! 5 %html.html-devise-layout{ class: user_application_theme, lang: I18n.locale } = render "layouts/head", { startup_filename: 'signin' } - %body.gl-h-full.login-page.navless{ class: "#{system_message_class} #{client_class_list}", data: { page: body_data_page, qa_selector: 'login_page' } } + %body.gl-h-full.login-page.navless{ class: "#{system_message_class} #{client_class_list}", data: { page: body_data_page, qa_selector: 'login_page', testid: 'login-page' } } = header_message = render "layouts/init_client_detection_flags" - if Feature.enabled?(:restyle_login_page, @project) diff --git a/app/views/projects/ci/builds/_build.html.haml b/app/views/projects/ci/builds/_build.html.haml index 6ec9b4a233d..76d6b0a042d 100644 --- a/app/views/projects/ci/builds/_build.html.haml +++ b/app/views/projects/ci/builds/_build.html.haml @@ -104,10 +104,10 @@ .btn-group - if can?(current_user, :read_job_artifacts, job) && job.artifacts? = link_button_to nil, download_project_job_artifacts_path(job.project, job), rel: 'nofollow', download: '', title: _('Download artifacts'), icon: 'download' + - if can?(current_user, :cancel_build, job) && job.active? + = link_button_to nil, cancel_project_job_path(job.project, job, continue: { to: request.fullpath }), method: :post, title: _('Cancel'), icon: 'cancel' - if can?(current_user, :update_build, job) - - if job.active? && can?(current_user, :cancel_build, job) - = link_button_to nil, cancel_project_job_path(job.project, job, continue: { to: request.fullpath }), method: :post, title: _('Cancel'), icon: 'cancel' - - elsif job.scheduled? + - if job.scheduled? = render Pajamas::ButtonComponent.new(disabled: true, icon: 'planning') do %time.js-remaining-time{ datetime: job.scheduled_at.utc.iso8601 } = duration_in_numbers(job.execute_in) @@ -124,7 +124,7 @@ class: 'has-tooltip', icon: 'time-out' - elsif allow_retry - - if job.playable? && !admin && can?(current_user, :update_build, job) + - if job.playable? && !admin = link_button_to nil, play_project_job_path(job.project, job, return_to: request.original_url), method: :post, title: _('Play'), icon: 'play' - elsif job.retryable? = link_button_to nil, retry_project_job_path(job.project, job, return_to: request.original_url), method: :post, title: _('Retry'), icon: 'retry' diff --git a/app/workers/ci/initial_pipeline_process_worker.rb b/app/workers/ci/initial_pipeline_process_worker.rb index 703cae8bf88..8d7a62e5b09 100644 --- a/app/workers/ci/initial_pipeline_process_worker.rb +++ b/app/workers/ci/initial_pipeline_process_worker.rb @@ -17,24 +17,10 @@ module Ci def perform(pipeline_id) Ci::Pipeline.find_by_id(pipeline_id).try do |pipeline| - create_deployments!(pipeline) - Ci::PipelineCreation::StartPipelineService .new(pipeline) .execute end end - - private - - def create_deployments!(pipeline) - return if Feature.enabled?(:create_deployment_only_for_processable_jobs, pipeline.project) - - pipeline.stages.flat_map(&:statuses).each { |build| create_deployment(build) } - end - - def create_deployment(build) - ::Deployments::CreateForJobService.new.execute(build) - end end end diff --git a/config/feature_flags/development/create_deployment_only_for_processable_jobs.yml b/config/feature_flags/development/create_deployment_only_for_processable_jobs.yml deleted file mode 100644 index f721dd8265c..00000000000 --- a/config/feature_flags/development/create_deployment_only_for_processable_jobs.yml +++ /dev/null @@ -1,8 +0,0 @@ ---- -name: create_deployment_only_for_processable_jobs -introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/132835 -rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/427062 -milestone: '16.5' -type: development -group: group::environments -default_enabled: false diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md index 65aa3bdd5e7..98c5d13e75a 100644 --- a/doc/api/graphql/reference/index.md +++ b/doc/api/graphql/reference/index.md @@ -22829,6 +22829,7 @@ Represents pipeline counts for the project. | Name | Type | Description | | ---- | ---- | ----------- | | <a id="pipelinepermissionsadminpipeline"></a>`adminPipeline` | [`Boolean!`](#boolean) | If `true`, the user can perform `admin_pipeline` on this resource. | +| <a id="pipelinepermissionscancelpipeline"></a>`cancelPipeline` | [`Boolean!`](#boolean) | If `true`, the user can perform `cancel_pipeline` on this resource. | | <a id="pipelinepermissionsdestroypipeline"></a>`destroyPipeline` | [`Boolean!`](#boolean) | If `true`, the user can perform `destroy_pipeline` on this resource. | | <a id="pipelinepermissionsupdatepipeline"></a>`updatePipeline` | [`Boolean!`](#boolean) | If `true`, the user can perform `update_pipeline` on this resource. | diff --git a/doc/ci/testing/browser_performance_testing.md b/doc/ci/testing/browser_performance_testing.md index 9e81f243e50..2b6c2067c35 100644 --- a/doc/ci/testing/browser_performance_testing.md +++ b/doc/ci/testing/browser_performance_testing.md @@ -91,6 +91,7 @@ You can also customize the jobs with CI/CD variables: - `SITESPEED_IMAGE`: Configure the Docker image to use for the job (default `sitespeedio/sitespeed.io`), but not the image version. - `SITESPEED_VERSION`: Configure the version of the Docker image to use for the job (default `14.1.0`). - `SITESPEED_OPTIONS`: Configure any additional sitespeed.io options as required (default `nil`). Refer to the [sitespeed.io documentation](https://www.sitespeed.io/documentation/sitespeed.io/configuration/) for more details. +- `SITESPEED_DOCKER_OPTIONS`: Configure any additional Docker options (default `nil`). Refer to the [Docker options documentation](https://docs.docker.com/engine/reference/commandline/run/#options) for more details. For example, you can override the number of runs sitespeed.io makes on the given URL, and change the version: diff --git a/doc/topics/autodevops/cicd_variables.md b/doc/topics/autodevops/cicd_variables.md index 21d9dd0b3d3..4fa2ee10c75 100644 --- a/doc/topics/autodevops/cicd_variables.md +++ b/doc/topics/autodevops/cicd_variables.md @@ -31,6 +31,9 @@ Use these variables to customize and deploy your build. | `AUTO_DEVOPS_CHART_REPOSITORY_USERNAME` | Used to set a username to connect to the Helm repository. Defaults to no credentials. Also set `AUTO_DEVOPS_CHART_REPOSITORY_PASSWORD`. | | `AUTO_DEVOPS_CHART_REPOSITORY_PASSWORD` | Used to set a password to connect to the Helm repository. Defaults to no credentials. Also set `AUTO_DEVOPS_CHART_REPOSITORY_USERNAME`. | | `AUTO_DEVOPS_CHART_REPOSITORY_PASS_CREDENTIALS` | From GitLab 14.2, set to a non-empty value to enable forwarding of the Helm repository credentials to the chart server when the chart artifacts are on a different host than repository. | +| `AUTO_DEVOPS_CHART_REPOSITORY_INSECURE` | Set to a non-empty value to add a `--insecure-skip-tls-verify` argument to the Helm commands. By default, Helm uses TLS verification. | +| `AUTO_DEVOPS_CHART_CUSTOM_ONLY` | Set to a non-empty value to use only a custom chart. By default, the latest chart is downloaded from GitLab. | +| `AUTO_DEVOPS_CHART_VERSION` | Set the version of the deployment chart. Defaults to the latest available version. | | `AUTO_DEVOPS_COMMON_NAME` | From GitLab 15.5, set to a valid domain name to customize the common name used for the TLS certificate. Defaults to `le-$CI_PROJECT_ID.$KUBE_INGRESS_BASE_DOMAIN`. Set to `false` to not set this alternative host on the Ingress. | | `AUTO_DEVOPS_DEPLOY_DEBUG` | From GitLab 13.1, if this variable is present, Helm outputs debug logs. | | `AUTO_DEVOPS_ALLOW_TO_FORCE_DEPLOY_V<N>` | From [auto-deploy-image](https://gitlab.com/gitlab-org/cluster-integration/auto-deploy-image) v1.0.0, if this variable is present, a new major version of chart is forcibly deployed. For more information, see [Ignore warnings and continue deploying](upgrading_auto_deploy_dependencies.md#ignore-warnings-and-continue-deploying). | diff --git a/doc/topics/autodevops/customize.md b/doc/topics/autodevops/customize.md index e920ae5e5e1..2e6672e3ab0 100644 --- a/doc/topics/autodevops/customize.md +++ b/doc/topics/autodevops/customize.md @@ -208,11 +208,14 @@ repository or by specifying a project CI/CD variable: file in it, Auto DevOps detects the chart and uses it instead of the [default chart](https://gitlab.com/gitlab-org/cluster-integration/auto-deploy-image/-/tree/master/assets/auto-deploy-app). - **Project variable** - Create a [project CI/CD variable](../../ci/variables/index.md) - `AUTO_DEVOPS_CHART` with the URL of a custom chart. You can also create two project + `AUTO_DEVOPS_CHART` with the URL of a custom chart. You can also create five project variables: - `AUTO_DEVOPS_CHART_REPOSITORY` - The URL of a custom chart repository. - `AUTO_DEVOPS_CHART` - The path to the chart. + - `AUTO_DEVOPS_CHART_REPOSITORY_INSECURE` - Set to a non-empty value to add a `--insecure-skip-tls-verify` argument to the Helm commands. + - `AUTO_DEVOPS_CHART_CUSTOM_ONLY` - Set to a non-empty value to use only a custom chart. By default, the latest chart is downloaded from GitLab. + - `AUTO_DEVOPS_CHART_VERSION` - The version of the deployment chart. ### Customize Helm chart values diff --git a/doc/user/infrastructure/clusters/connect/new_gke_cluster.md b/doc/user/infrastructure/clusters/connect/new_gke_cluster.md index 96819860a2f..5412ced3e6d 100644 --- a/doc/user/infrastructure/clusters/connect/new_gke_cluster.md +++ b/doc/user/infrastructure/clusters/connect/new_gke_cluster.md @@ -95,7 +95,7 @@ Use CI/CD environment variables to configure your project. 1. On the left sidebar, select **Settings > CI/CD**. 1. Expand **Variables**. 1. Set the variable `BASE64_GOOGLE_CREDENTIALS` to the `base64` encoded JSON file you just created. -1. Set the variable `TF_VAR_gcp_project` to your GCP `project` name. +1. Set the variable `TF_VAR_gcp_project` to your GCP `project` ID. 1. Set the variable `TF_VAR_agent_token` to the agent token displayed in the previous task. 1. Set the variable `TF_VAR_kas_address` to the agent server address displayed in the previous task. @@ -113,6 +113,10 @@ contains other variables that you can override according to your needs: Refer to the [Google Terraform provider](https://registry.terraform.io/providers/hashicorp/google/latest/docs/guides/provider_reference) and the [Kubernetes Terraform provider](https://registry.terraform.io/providers/hashicorp/kubernetes/latest/docs) documentation for further resource options. +## Enable Kubernetes Engine API + +From the Google Cloud console, enable the [Kubernetes Engine API](https://console.cloud.google.com/apis/library/container.googleapis.com). + ## Provision your cluster After configuring your project, manually trigger the provisioning of your cluster. In GitLab: diff --git a/lib/api/ci/pipelines.rb b/lib/api/ci/pipelines.rb index 3361f4564b2..b5123ab49dc 100644 --- a/lib/api/ci/pipelines.rb +++ b/lib/api/ci/pipelines.rb @@ -352,7 +352,7 @@ module API requires :pipeline_id, type: Integer, desc: 'The pipeline ID', documentation: { example: 18 } end post ':id/pipelines/:pipeline_id/cancel', urgency: :low, feature_category: :continuous_integration do - authorize! :update_pipeline, pipeline + authorize! :cancel_pipeline, pipeline # TODO: inconsistent behavior: when pipeline is not cancelable we should return an error ::Ci::CancelPipelineService.new(pipeline: pipeline, current_user: current_user).execute diff --git a/lib/api/personal_access_tokens.rb b/lib/api/personal_access_tokens.rb index 9d234ca0593..de00b66ead3 100644 --- a/lib/api/personal_access_tokens.rb +++ b/lib/api/personal_access_tokens.rb @@ -72,11 +72,17 @@ module API detail 'Roates a personal access token.' success Entities::PersonalAccessTokenWithToken end + params do + optional :expires_at, + type: Date, + desc: "The expiration date of the token", + documentation: { example: '2021-01-31' } + end post ':id/rotate' do token = PersonalAccessToken.find_by_id(params[:id]) if Ability.allowed?(current_user, :manage_user_personal_access_token, token&.user) - response = ::PersonalAccessTokens::RotateService.new(current_user, token).execute + response = ::PersonalAccessTokens::RotateService.new(current_user, token).execute(declared_params) if response.success? status :ok diff --git a/lib/api/resource_access_tokens.rb b/lib/api/resource_access_tokens.rb index 1ad5bc8d421..752feb1455f 100644 --- a/lib/api/resource_access_tokens.rb +++ b/lib/api/resource_access_tokens.rb @@ -141,6 +141,10 @@ module API params do requires :id, type: String, desc: "The #{source_type} ID" requires :token_id, type: String, desc: "The ID of the token" + optional :expires_at, + type: Date, + desc: "The expiration date of the token", + documentation: { example: '2021-01-31' } end post ':id/access_tokens/:token_id/rotate' do resource = find_source(source_type, params[:id]) @@ -149,7 +153,7 @@ module API token = find_token(resource, params[:token_id]) if resource_accessible if token - response = ::PersonalAccessTokens::RotateService.new(current_user, token).execute + response = ::PersonalAccessTokens::RotateService.new(current_user, token).execute(declared_params) if response.success? status :ok diff --git a/lib/gitlab/ci/ansi2json/line.rb b/lib/gitlab/ci/ansi2json/line.rb index 21fc2980cdc..791b8a963e9 100644 --- a/lib/gitlab/ci/ansi2json/line.rb +++ b/lib/gitlab/ci/ansi2json/line.rb @@ -35,13 +35,15 @@ module Gitlab end attr_reader :offset, :sections, :segments, :current_segment, - :section_header, :section_duration, :section_options + :section_header, :section_footer, :section_duration, + :section_options def initialize(offset:, style:, sections: []) @offset = offset @segments = [] @sections = sections @section_header = false + @section_footer = false @duration = nil @current_segment = Segment.new(style: style) end @@ -79,6 +81,10 @@ module Gitlab @section_header = true end + def set_as_section_footer + @section_footer = true + end + def set_section_duration(duration_in_seconds) normalized_duration_in_seconds = duration_in_seconds.to_i.clamp(0, 1.year) duration = ActiveSupport::Duration.build(normalized_duration_in_seconds) @@ -103,6 +109,7 @@ module Gitlab { offset: offset, content: @segments }.tap do |result| result[:section] = sections.last if sections.any? result[:section_header] = true if @section_header + result[:section_footer] = true if @section_footer result[:section_duration] = @section_duration if @section_duration result[:section_options] = @section_options if @section_options end diff --git a/lib/gitlab/ci/ansi2json/state.rb b/lib/gitlab/ci/ansi2json/state.rb index 3aec1cde1bc..6cf76fbbb51 100644 --- a/lib/gitlab/ci/ansi2json/state.rb +++ b/lib/gitlab/ci/ansi2json/state.rb @@ -49,6 +49,7 @@ module Gitlab duration = timestamp.to_i - @open_sections[section].to_i @current_line.set_section_duration(duration) + @current_line.set_as_section_footer @open_sections.delete(section) end diff --git a/lib/gitlab/ci/templates/Verify/Browser-Performance.gitlab-ci.yml b/lib/gitlab/ci/templates/Verify/Browser-Performance.gitlab-ci.yml index c1a90955f7f..8c9e0a329dd 100644 --- a/lib/gitlab/ci/templates/Verify/Browser-Performance.gitlab-ci.yml +++ b/lib/gitlab/ci/templates/Verify/Browser-Performance.gitlab-ci.yml @@ -19,6 +19,7 @@ browser_performance: SITESPEED_IMAGE: sitespeedio/sitespeed.io SITESPEED_VERSION: 26.1.0 SITESPEED_OPTIONS: '' + SITESPEED_DOCKER_OPTIONS: '' services: - docker:dind script: @@ -48,7 +49,7 @@ browser_performance: HTTP_PROXY \ NO_PROXY \ ) \ - --shm-size=1g --rm -v "$(pwd)":/sitespeed.io $SITESPEED_IMAGE:$SITESPEED_VERSION --plugins.add ./gitlab-exporter --cpu --outputFolder sitespeed-results $URL $SITESPEED_OPTIONS + $SITESPEED_DOCKER_OPTIONS --shm-size=1g --rm -v "$(pwd)":/sitespeed.io $SITESPEED_IMAGE:$SITESPEED_VERSION --plugins.add ./gitlab-exporter --cpu --outputFolder sitespeed-results $URL $SITESPEED_OPTIONS - mv sitespeed-results/data/performance.json browser-performance.json artifacts: paths: diff --git a/lib/gitlab/ci/templates/Verify/Browser-Performance.latest.gitlab-ci.yml b/lib/gitlab/ci/templates/Verify/Browser-Performance.latest.gitlab-ci.yml index adc92fde5ae..3f4c0c53850 100644 --- a/lib/gitlab/ci/templates/Verify/Browser-Performance.latest.gitlab-ci.yml +++ b/lib/gitlab/ci/templates/Verify/Browser-Performance.latest.gitlab-ci.yml @@ -19,6 +19,7 @@ browser_performance: SITESPEED_IMAGE: sitespeedio/sitespeed.io SITESPEED_VERSION: latest SITESPEED_OPTIONS: '' + SITESPEED_DOCKER_OPTIONS: '' services: - docker:dind script: @@ -48,7 +49,7 @@ browser_performance: HTTP_PROXY \ NO_PROXY \ ) \ - --shm-size=1g --rm -v "$(pwd)":/sitespeed.io $SITESPEED_IMAGE:$SITESPEED_VERSION --plugins.add ./gitlab-exporter --cpu --outputFolder sitespeed-results $URL $SITESPEED_OPTIONS + $SITESPEED_DOCKER_OPTIONS --shm-size=1g --rm -v "$(pwd)":/sitespeed.io $SITESPEED_IMAGE:$SITESPEED_VERSION --plugins.add ./gitlab-exporter --cpu --outputFolder sitespeed-results $URL $SITESPEED_OPTIONS - mv sitespeed-results/data/performance.json browser-performance.json artifacts: paths: diff --git a/lib/tasks/gitlab/tw/codeowners.rake b/lib/tasks/gitlab/tw/codeowners.rake index 7415e6c920c..de1401feb8a 100644 --- a/lib/tasks/gitlab/tw/codeowners.rake +++ b/lib/tasks/gitlab/tw/codeowners.rake @@ -33,20 +33,18 @@ namespace :tw do CodeOwnerRule.new('Code Review', '@aqualls'), CodeOwnerRule.new('Compliance', '@eread'), CodeOwnerRule.new('Composition Analysis', '@rdickenson'), - CodeOwnerRule.new('Environments', '@phillipwells'), CodeOwnerRule.new('Container Registry', '@marcel.amirault'), CodeOwnerRule.new('Contributor Experience', '@eread'), CodeOwnerRule.new('Database', '@aqualls'), CodeOwnerRule.new('DataOps', '@sselhorn'), # CodeOwnerRule.new('Delivery', ''), - CodeOwnerRule.new('Development', '@sselhorn'), CodeOwnerRule.new('Distribution', '@axil'), CodeOwnerRule.new('Distribution (Charts)', '@axil'), CodeOwnerRule.new('Distribution (Omnibus)', '@eread'), - CodeOwnerRule.new('Documentation Guidelines', '@sselhorn'), CodeOwnerRule.new('Duo Chat', '@sselhorn'), CodeOwnerRule.new('Dynamic Analysis', '@rdickenson'), CodeOwnerRule.new('Editor Extensions', '@aqualls'), + CodeOwnerRule.new('Environments', '@phillipwells'), CodeOwnerRule.new('Foundations', '@sselhorn'), # CodeOwnerRule.new('Fulfillment Platform', ''), CodeOwnerRule.new('Fuzz Testing', '@rdickenson'), @@ -79,7 +77,6 @@ namespace :tw do CodeOwnerRule.new('Solutions Architecture', '@jfullam @brianwald @Darwinjs'), CodeOwnerRule.new('Source Code', '@msedlakjakubowski'), CodeOwnerRule.new('Static Analysis', '@rdickenson'), - CodeOwnerRule.new('Style Guide', '@sselhorn'), CodeOwnerRule.new('Tenant Scale', '@lciutacu'), CodeOwnerRule.new('Testing', '@eread'), CodeOwnerRule.new('Threat Insights', '@rdickenson'), @@ -89,6 +86,33 @@ namespace :tw do # CodeOwnerRule.new('Vulnerability Research', '') ].freeze + CONTRIBUTOR_DOCS_PATH = '/doc/development/' + CONTRIBUTOR_DOCS_CODE_OWNER_RULES = [ + CodeOwnerRule.new('Analytics Instrumentation', + '@gitlab-org/analytics-section/product-analytics/engineers/frontend ' \ + '@gitlab-org/analytics-section/analytics-instrumentation/engineers'), + CodeOwnerRule.new('Authentication', '@gitlab-org/govern/authentication/approvers'), + CodeOwnerRule.new('Authorization', '@gitlab-org/govern/authorization/approvers'), + CodeOwnerRule.new('Compliance', + '@gitlab-org/govern/security-policies-frontend @gitlab-org/govern/threat-insights-frontend-team ' \ + '@gitlab-org/govern/threat-insights-backend-team'), + CodeOwnerRule.new('Composition Analysis', + '@gitlab-org/secure/composition-analysis-be @gitlab-org/secure/static-analysis'), + CodeOwnerRule.new('Distribution', '@gitlab-org/distribution'), + CodeOwnerRule.new('Documentation Guidelines', '@sselhorn'), + CodeOwnerRule.new('Engineering Productivity', '@gl-quality/eng-prod'), + CodeOwnerRule.new('Foundations', '@gitlab-org/manage/foundations/engineering'), + CodeOwnerRule.new('Gitaly', '@proglottis @toon'), + CodeOwnerRule.new('Global Search', '@gitlab-org/search-team/migration-maintainers'), + CodeOwnerRule.new('IDE', + '@gitlab-org/maintainers/remote-development/backend @gitlab-org/maintainers/remote-development/frontend'), + CodeOwnerRule.new('Pipeline Authoring', '@gitlab-org/maintainers/cicd-verify'), + CodeOwnerRule.new('Pipeline Execution', '@gitlab-org/maintainers/cicd-verify'), + CodeOwnerRule.new('Product Analytics', '@gitlab-org/analytics-section/product-analytics/engineers/frontend'), + CodeOwnerRule.new('Tenant Scale', '@abdwdd @alexpooley @manojmj'), + CodeOwnerRule.new('Threat Insights', '@gitlab-org/govern/threat-insights-frontend-team') + ].freeze + ERRORS_EXCLUDED_FILES = [ '/doc/architecture' ].freeze @@ -107,7 +131,8 @@ namespace :tw do end def self.writer_for_group(category, path) - writer = CODE_OWNER_RULES.find { |rule| rule.category == category }&.writer + rules = path.start_with?(CONTRIBUTOR_DOCS_PATH) ? CONTRIBUTOR_DOCS_CODE_OWNER_RULES : CODE_OWNER_RULES + writer = rules.find { |rule| rule.category == category }&.writer if writer.is_a?(String) || writer.nil? writer diff --git a/qa/qa/page/admin/overview/users/index.rb b/qa/qa/page/admin/overview/users/index.rb index c444b728f5a..fb1a7c29008 100644 --- a/qa/qa/page/admin/overview/users/index.rb +++ b/qa/qa/page/admin/overview/users/index.rb @@ -11,7 +11,7 @@ module QA element 'pending-approval-tab' end - view 'app/assets/javascripts/admin/users/components/users_table.vue' do + view 'app/assets/javascripts/vue_shared/components/users_table/users_table.vue' do element 'user-row-content' end diff --git a/qa/qa/runtime/path.rb b/qa/qa/runtime/path.rb index ae1b26ca84a..d122240225c 100644 --- a/qa/qa/runtime/path.rb +++ b/qa/qa/runtime/path.rb @@ -15,6 +15,10 @@ module QA def fixture(*args) ::File.join(fixtures_path, *args) end + + def qa_tmp(*args) + ::File.join([qa_root, 'tmp', *args].compact) + end end end end diff --git a/qa/qa/service/docker_run/base.rb b/qa/qa/service/docker_run/base.rb index 3bd7912958f..bcddfc4b3a9 100644 --- a/qa/qa/service/docker_run/base.rb +++ b/qa/qa/service/docker_run/base.rb @@ -120,6 +120,14 @@ module QA # If the host could not be resolved, fallback on localhost '127.0.0.1' end + + # Copy files to/from the Docker container and the host + # + # @param from the source path to copy files from + # @param to the destination path to copy files to + def copy(from:, to:) + shell("docker cp #{from} #{to}") + end end end end diff --git a/qa/qa/service/docker_run/gitlab.rb b/qa/qa/service/docker_run/gitlab.rb index ce8ab17f2b5..c39f4c22865 100644 --- a/qa/qa/service/docker_run/gitlab.rb +++ b/qa/qa/service/docker_run/gitlab.rb @@ -32,6 +32,11 @@ module QA CMD end + # Copy logs for GitLab services from the Docker container to the test framework's tmp folder + def extract_service_logs + copy(from: "#{@name}:/var/log/gitlab", to: Runtime::Path.qa_tmp(@name)) + end + private def release_variables_available? diff --git a/qa/qa/specs/features/browser_ui/10_govern/login/login_via_oauth_and_oidc_with_gitlab_as_idp_spec.rb b/qa/qa/specs/features/browser_ui/10_govern/login/login_via_oauth_and_oidc_with_gitlab_as_idp_spec.rb index 7ac34d86b62..6d5a2aef76c 100644 --- a/qa/qa/specs/features/browser_ui/10_govern/login/login_via_oauth_and_oidc_with_gitlab_as_idp_spec.rb +++ b/qa/qa/specs/features/browser_ui/10_govern/login/login_via_oauth_and_oidc_with_gitlab_as_idp_spec.rb @@ -14,6 +14,7 @@ module QA after do instance_oauth_app.remove_via_api! + save_gitlab_logs(consumer_name) remove_gitlab_service(consumer_name) end @@ -28,6 +29,11 @@ module QA end end + # Copy GitLab logs from inside the named Docker container running the GitLab OAuth instance + def save_gitlab_logs(name) + Service::DockerRun::Gitlab.new(name: name).extract_service_logs + end + def remove_gitlab_service(name) Service::DockerRun::Gitlab.new(name: name).remove! end diff --git a/spec/frontend/admin/users/components/app_spec.js b/spec/frontend/admin/users/components/app_spec.js index d40089edc82..4b224947303 100644 --- a/spec/frontend/admin/users/components/app_spec.js +++ b/spec/frontend/admin/users/components/app_spec.js @@ -1,14 +1,42 @@ -import { shallowMount } from '@vue/test-utils'; - +import { mount } from '@vue/test-utils'; +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; import AdminUsersApp from '~/admin/users/components/app.vue'; -import AdminUsersTable from '~/admin/users/components/users_table.vue'; -import { users, paths } from '../mock_data'; +import UserActions from '~/admin/users/components/user_actions.vue'; +import getUsersGroupCountsQuery from '~/admin/users/graphql/queries/get_users_group_counts.query.graphql'; +import UsersTable from '~/vue_shared/components/users_table/users_table.vue'; +import { createAlert } from '~/alert'; +import { users, paths, createGroupCountResponse } from '../mock_data'; + +Vue.use(VueApollo); + +jest.mock('~/alert'); describe('AdminUsersApp component', () => { let wrapper; + const user = users[0]; + + const mockSuccessData = [{ id: user.id, groupCount: 5 }]; + const mockParsedGroupCount = { 2177: 5 }; + const mockError = new Error(); + + const createFetchGroupCount = (data) => + jest.fn().mockResolvedValue(createGroupCountResponse(data)); + const loadingResolver = jest.fn().mockResolvedValue(new Promise(() => {})); + const errorResolver = jest.fn().mockRejectedValueOnce(mockError); + const successfulResolver = createFetchGroupCount(mockSuccessData); - const initComponent = (props = {}) => { - wrapper = shallowMount(AdminUsersApp, { + function createMockApolloProvider(resolverMock) { + const requestHandlers = [[getUsersGroupCountsQuery, resolverMock]]; + + return createMockApollo(requestHandlers); + } + + const initComponent = (props = {}, resolverMock = successfulResolver) => { + wrapper = mount(AdminUsersApp, { + apolloProvider: createMockApolloProvider(resolverMock), propsData: { users, paths, @@ -17,16 +45,47 @@ describe('AdminUsersApp component', () => { }); }; - describe('when initialized', () => { - beforeEach(() => { + const findUsersTable = () => wrapper.findComponent(UsersTable); + const findAllUserActions = () => wrapper.findAllComponents(UserActions); + + describe.each` + description | mockResolver | loading | groupCounts | error + ${'when API call is loading'} | ${loadingResolver} | ${true} | ${{}} | ${false} + ${'when API returns successful with results'} | ${successfulResolver} | ${false} | ${mockParsedGroupCount} | ${false} + ${'when API returns error'} | ${errorResolver} | ${false} | ${{}} | ${true} + `('$description', ({ mockResolver, loading, groupCounts, error }) => { + beforeEach(async () => { + initComponent({}, mockResolver); + await waitForPromises(); + }); + + it(`renders the UsersTable with group-counts-loading set to ${loading}`, () => { + expect(findUsersTable().props('groupCountsLoading')).toBe(loading); + }); + + it('renders the UsersTable with the correct group-counts data', () => { + expect(findUsersTable().props('groupCounts')).toStrictEqual(groupCounts); + }); + + it(`does ${error ? '' : 'not '}render an error message`, () => { + return error + ? expect(createAlert).toHaveBeenCalledWith({ + message: 'Could not load user group counts. Please refresh the page to try again.', + error: mockError, + captureError: true, + }) + : expect(createAlert).not.toHaveBeenCalled(); + }); + }); + + describe('UserActions', () => { + beforeEach(async () => { initComponent(); + await waitForPromises(); }); - it('renders the admin users table with props', () => { - expect(wrapper.findComponent(AdminUsersTable).props()).toEqual({ - users, - paths, - }); + it('renders a UserActions component for each user', () => { + expect(findAllUserActions().wrappers.map((w) => w.props('user'))).toStrictEqual(users); }); }); }); diff --git a/spec/frontend/admin/users/components/users_table_spec.js b/spec/frontend/admin/users/components/users_table_spec.js deleted file mode 100644 index 6f658fd2e59..00000000000 --- a/spec/frontend/admin/users/components/users_table_spec.js +++ /dev/null @@ -1,141 +0,0 @@ -import { GlTable, GlSkeletonLoader } from '@gitlab/ui'; -import Vue from 'vue'; -import VueApollo from 'vue-apollo'; - -import createMockApollo from 'helpers/mock_apollo_helper'; -import waitForPromises from 'helpers/wait_for_promises'; -import { mountExtended } from 'helpers/vue_test_utils_helper'; - -import AdminUserActions from '~/admin/users/components/user_actions.vue'; -import AdminUserAvatar from '~/admin/users/components/user_avatar.vue'; -import AdminUsersTable from '~/admin/users/components/users_table.vue'; -import getUsersGroupCountsQuery from '~/admin/users/graphql/queries/get_users_group_counts.query.graphql'; -import { createAlert } from '~/alert'; -import AdminUserDate from '~/vue_shared/components/user_date.vue'; - -import { users, paths, createGroupCountResponse } from '../mock_data'; - -jest.mock('~/alert'); - -Vue.use(VueApollo); - -describe('AdminUsersTable component', () => { - let wrapper; - const user = users[0]; - - const createFetchGroupCount = (data) => - jest.fn().mockResolvedValue(createGroupCountResponse(data)); - const fetchGroupCountsLoading = jest.fn().mockResolvedValue(new Promise(() => {})); - const fetchGroupCountsError = jest.fn().mockRejectedValue(new Error('Network error')); - const fetchGroupCountsResponse = createFetchGroupCount([{ id: user.id, groupCount: 5 }]); - - const findUserGroupCount = (id) => wrapper.findByTestId(`user-group-count-${id}`); - const findUserGroupCountLoader = (id) => findUserGroupCount(id).findComponent(GlSkeletonLoader); - const getCellByLabel = (trIdx, label) => { - return wrapper - .findComponent(GlTable) - .find('tbody') - .findAll('tr') - .at(trIdx) - .find(`[data-label="${label}"][role="cell"]`); - }; - - function createMockApolloProvider(resolverMock) { - const requestHandlers = [[getUsersGroupCountsQuery, resolverMock]]; - - return createMockApollo(requestHandlers); - } - - const initComponent = (props = {}, resolverMock = fetchGroupCountsResponse) => { - wrapper = mountExtended(AdminUsersTable, { - apolloProvider: createMockApolloProvider(resolverMock), - propsData: { - users, - paths, - ...props, - }, - }); - }; - - describe('when there are users', () => { - beforeEach(() => { - initComponent(); - }); - - it('renders the projects count', () => { - expect(getCellByLabel(0, 'Projects').text()).toContain(`${user.projectsCount}`); - }); - - it('renders the user actions', () => { - expect(wrapper.findComponent(AdminUserActions).exists()).toBe(true); - }); - - it.each` - component | label - ${AdminUserAvatar} | ${'Name'} - ${AdminUserDate} | ${'Created on'} - ${AdminUserDate} | ${'Last activity'} - `('renders the component for column $label', ({ component, label }) => { - expect(getCellByLabel(0, label).findComponent(component).exists()).toBe(true); - }); - }); - - describe('when users is an empty array', () => { - beforeEach(() => { - initComponent({ users: [] }); - }); - - it('renders a "No users found" message', () => { - expect(wrapper.text()).toContain('No users found'); - }); - }); - - describe('group counts', () => { - describe('when fetching the data', () => { - beforeEach(() => { - initComponent({}, fetchGroupCountsLoading); - }); - - it('renders a loader for each user', () => { - expect(findUserGroupCountLoader(user.id).exists()).toBe(true); - }); - }); - - describe('when the data has been fetched', () => { - beforeEach(async () => { - initComponent(); - await waitForPromises(); - }); - - it("renders the user's group count", () => { - expect(findUserGroupCount(user.id).text()).toBe('5'); - }); - - describe("and a user's group count is null", () => { - beforeEach(async () => { - initComponent({}, createFetchGroupCount([{ id: user.id, groupCount: null }])); - await waitForPromises(); - }); - - it("renders the user's group count as 0", () => { - expect(findUserGroupCount(user.id).text()).toBe('0'); - }); - }); - }); - - describe('when there is an error while fetching the data', () => { - beforeEach(async () => { - initComponent({}, fetchGroupCountsError); - await waitForPromises(); - }); - - it('creates an alert message and captures the error', () => { - expect(createAlert).toHaveBeenCalledWith({ - message: 'Could not load user group counts. Please refresh the page to try again.', - captureError: true, - error: expect.any(Error), - }); - }); - }); - }); -}); diff --git a/spec/frontend/ci/job_details/components/log/line_header_spec.js b/spec/frontend/ci/job_details/components/log/line_header_spec.js index 45296e4b6c2..c75f5fa30d5 100644 --- a/spec/frontend/ci/job_details/components/log/line_header_spec.js +++ b/spec/frontend/ci/job_details/components/log/line_header_spec.js @@ -1,3 +1,4 @@ +import { GlIcon } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; import { nextTick } from 'vue'; import setWindowLocation from 'helpers/set_window_location_helper'; @@ -30,6 +31,8 @@ describe('Job Log Header Line', () => { }); }; + const findIcon = () => wrapper.findComponent(GlIcon); + describe('line', () => { beforeEach(() => { createComponent(); @@ -48,23 +51,33 @@ describe('Job Log Header Line', () => { }); }); - describe('when isCloses is true', () => { + describe('when isClosed is true', () => { beforeEach(() => { createComponent({ ...defaultProps, isClosed: true }); }); it('sets icon name to be chevron-lg-right', () => { - expect(wrapper.vm.iconName).toEqual('chevron-lg-right'); + expect(findIcon().props('name')).toEqual('chevron-lg-right'); }); }); - describe('when isCloses is false', () => { + describe('when isClosed is false', () => { beforeEach(() => { createComponent({ ...defaultProps, isClosed: false }); }); it('sets icon name to be chevron-lg-down', () => { - expect(wrapper.vm.iconName).toEqual('chevron-lg-down'); + expect(findIcon().props('name')).toEqual('chevron-lg-down'); + }); + }); + + describe('when isClosed is not defined', () => { + beforeEach(() => { + createComponent({ ...defaultProps, isClosed: undefined }); + }); + + it('sets icon name to be chevron-lg-right', () => { + expect(findIcon().props('name')).toEqual('chevron-lg-down'); }); }); diff --git a/spec/frontend/ci/job_details/components/log/mock_data.js b/spec/frontend/ci/job_details/components/log/mock_data.js index 14669872cc1..d9b1354f475 100644 --- a/spec/frontend/ci/job_details/components/log/mock_data.js +++ b/spec/frontend/ci/job_details/components/log/mock_data.js @@ -1,67 +1,73 @@ -export const mockJobLog = [ +export const mockJobLines = [ { - offset: 1000, - content: [{ text: 'Running with gitlab-runner 12.1.0 (de7731dd)' }], + offset: 0, + content: [ + { + text: 'Running with gitlab-runner 12.1.0 (de7731dd)', + style: 'term-fg-l-cyan term-bold', + }, + ], }, { offset: 1001, content: [{ text: ' on docker-auto-scale-com 8a6210b8' }], }, +]; + +export const mockEmptySection = [ { offset: 1002, content: [ { - text: 'Using Docker executor with image dev.gitlab.org3', + text: 'Resolving secrets', + style: 'term-fg-l-cyan term-bold', }, ], - section: 'prepare-executor', + section: 'resolve-secrets', section_header: true, }, { offset: 1003, - content: [{ text: 'Docker executor with image registry.gitlab.com ...' }], - section: 'prepare-executor', - }, - { - offset: 1004, - content: [{ text: 'Starting service ...', style: 'term-fg-l-green' }], - section: 'prepare-executor', - }, - { - offset: 1005, content: [], - section: 'prepare-executor', - section_duration: '00:09', + section: 'resolve-secrets', + section_footer: true, + section_duration: '00:00', }, +]; + +export const mockContentSection = [ { - offset: 1006, + offset: 1004, content: [ { - text: 'Getting source from Git repository', + text: 'Using Docker executor with image dev.gitlab.org3', }, ], - section: 'get-sources', + section: 'prepare-executor', section_header: true, }, { - offset: 1007, - content: [{ text: 'Fetching changes with git depth set to 20...' }], - section: 'get-sources', + offset: 1005, + content: [{ text: 'Docker executor with image registry.gitlab.com ...' }], + section: 'prepare-executor', }, { - offset: 1008, - content: [{ text: 'Initialized empty Git repository', style: 'term-fg-l-green' }], - section: 'get-sources', + offset: 1006, + content: [{ text: 'Starting service ...', style: 'term-fg-l-green' }], + section: 'prepare-executor', }, { - offset: 1009, + offset: 1007, content: [], - section: 'get-sources', - section_duration: '00:19', + section: 'prepare-executor', + section_footer: true, + section_duration: '00:09', }, ]; -export const mockJobLogLineCount = 8; // `text` entries in mockJobLog +export const mockJobLog = [...mockJobLines, ...mockEmptySection, ...mockContentSection]; + +export const mockJobLogLineCount = 6; // `text` entries in mockJobLog export const originalTrace = [ { diff --git a/spec/frontend/ci/job_details/job_app_spec.js b/spec/frontend/ci/job_details/job_app_spec.js index ff84b2d0283..2bd0429ef56 100644 --- a/spec/frontend/ci/job_details/job_app_spec.js +++ b/spec/frontend/ci/job_details/job_app_spec.js @@ -311,6 +311,8 @@ describe('Job App', () => { it('should render job log', () => { expect(findJobLog().exists()).toBe(true); + + expect(findJobLog().props()).toEqual({ searchResults: [] }); }); }); diff --git a/spec/frontend/ci/job_details/store/actions_spec.js b/spec/frontend/ci/job_details/store/actions_spec.js index 2799bc9578c..849f55ac444 100644 --- a/spec/frontend/ci/job_details/store/actions_spec.js +++ b/spec/frontend/ci/job_details/store/actions_spec.js @@ -284,7 +284,7 @@ describe('Job State actions', () => { }); }); - describe('error', () => { + describe('server error', () => { beforeEach(() => { mock.onGet(`${TEST_HOST}/endpoint/trace.json`).reply(HTTP_STATUS_INTERNAL_SERVER_ERROR); }); @@ -303,6 +303,28 @@ describe('Job State actions', () => { ); }); }); + + describe('unexpected error', () => { + beforeEach(() => { + mock.onGet(`${TEST_HOST}/endpoint/trace.json`).reply(() => { + throw new Error('an error'); + }); + }); + + it('dispatches requestJobLog and receiveJobLogError', () => { + return testAction( + fetchJobLog, + null, + mockedState, + [], + [ + { + type: 'receiveJobLogError', + }, + ], + ); + }); + }); }); describe('startPollingJobLog', () => { diff --git a/spec/frontend/ci/job_details/store/mutations_spec.js b/spec/frontend/ci/job_details/store/mutations_spec.js index 78b29efed68..601dff47584 100644 --- a/spec/frontend/ci/job_details/store/mutations_spec.js +++ b/spec/frontend/ci/job_details/store/mutations_spec.js @@ -1,6 +1,7 @@ import * as types from '~/ci/job_details/store/mutation_types'; import mutations from '~/ci/job_details/store/mutations'; import state from '~/ci/job_details/store/state'; +import * as utils from '~/ci/job_details/store/utils'; describe('Jobs Store Mutations', () => { let stateCopy; @@ -87,50 +88,91 @@ describe('Jobs Store Mutations', () => { }); describe('with new job log', () => { + const mockLog = { + append: false, + size: 511846, + complete: true, + lines: [ + { + offset: 1, + content: [{ text: 'Line content' }], + }, + ], + }; + + beforeEach(() => { + jest.spyOn(utils, 'logLinesParser'); + }); + + afterEach(() => { + utils.logLinesParser.mockRestore(); + }); + describe('log.lines', () => { - describe('when append is true', () => { + describe('when it is defined', () => { it('sets the parsed log', () => { - mutations[types.RECEIVE_JOB_LOG_SUCCESS](stateCopy, { - append: true, - size: 511846, - complete: true, - lines: [ - { - offset: 1, - content: [{ text: 'Running with gitlab-runner 11.12.1 (5a147c92)' }], - }, - ], - }); + mutations[types.RECEIVE_JOB_LOG_SUCCESS](stateCopy, mockLog); + + expect(utils.logLinesParser).toHaveBeenCalledWith(mockLog.lines, [], ''); expect(stateCopy.jobLog).toEqual([ { offset: 1, - content: [{ text: 'Running with gitlab-runner 11.12.1 (5a147c92)' }], + content: [{ text: 'Line content' }], lineNumber: 1, }, ]); }); }); - describe('when it is defined', () => { + describe('when it is defined and location.hash is set', () => { + beforeEach(() => { + window.location.hash = '#L1'; + }); + it('sets the parsed log', () => { - mutations[types.RECEIVE_JOB_LOG_SUCCESS](stateCopy, { - append: false, - size: 511846, - complete: true, - lines: [ - { offset: 0, content: [{ text: 'Running with gitlab-runner 11.11.1 (5a147c92)' }] }, - ], - }); + mutations[types.RECEIVE_JOB_LOG_SUCCESS](stateCopy, mockLog); + + expect(utils.logLinesParser).toHaveBeenCalledWith(mockLog.lines, [], '#L1'); expect(stateCopy.jobLog).toEqual([ { - offset: 0, - content: [{ text: 'Running with gitlab-runner 11.11.1 (5a147c92)' }], + offset: 1, + content: [{ text: 'Line content' }], lineNumber: 1, }, ]); }); + + describe('when append is true', () => { + it('sets the parsed log', () => { + stateCopy.jobLog = [ + { + offset: 0, + content: [{ text: 'Previous line content' }], + lineNumber: 1, + }, + ]; + + mutations[types.RECEIVE_JOB_LOG_SUCCESS](stateCopy, { + ...mockLog, + append: true, + }); + + expect(stateCopy.jobLog).toEqual([ + { + offset: 0, + content: [{ text: 'Previous line content' }], + lineNumber: 1, + }, + { + offset: 1, + content: [{ text: 'Line content' }], + lineNumber: 2, + }, + ]); + }); + }); }); describe('when it is null', () => { diff --git a/spec/frontend/ci/job_details/store/utils_spec.js b/spec/frontend/ci/job_details/store/utils_spec.js index 394ce0ab737..8fc4eeb0ca8 100644 --- a/spec/frontend/ci/job_details/store/utils_spec.js +++ b/spec/frontend/ci/job_details/store/utils_spec.js @@ -195,11 +195,9 @@ describe('Jobs Store Utils', () => { expect(result[0].lineNumber).toEqual(1); expect(result[1].lineNumber).toEqual(2); expect(result[2].line.lineNumber).toEqual(3); - expect(result[2].lines[0].lineNumber).toEqual(4); - expect(result[2].lines[1].lineNumber).toEqual(5); - expect(result[3].line.lineNumber).toEqual(6); - expect(result[3].lines[0].lineNumber).toEqual(7); - expect(result[3].lines[1].lineNumber).toEqual(8); + expect(result[3].line.lineNumber).toEqual(4); + expect(result[3].lines[0].lineNumber).toEqual(5); + expect(result[3].lines[1].lineNumber).toEqual(6); }); }); @@ -215,16 +213,16 @@ describe('Jobs Store Utils', () => { }); it('creates a lines array property with the content of the collapsible section', () => { - expect(result[2].lines.length).toEqual(2); - expect(result[2].lines[0].content).toEqual(mockJobLog[3].content); - expect(result[2].lines[1].content).toEqual(mockJobLog[4].content); + expect(result[3].lines.length).toEqual(2); + expect(result[3].lines[0].content).toEqual(mockJobLog[5].content); + expect(result[3].lines[1].content).toEqual(mockJobLog[6].content); }); }); describe('section duration', () => { it('adds the section information to the header section', () => { - expect(result[2].line.section_duration).toEqual(mockJobLog[5].section_duration); - expect(result[3].line.section_duration).toEqual(mockJobLog[9].section_duration); + expect(result[2].line.section_duration).toEqual(mockJobLog[3].section_duration); + expect(result[3].line.section_duration).toEqual(mockJobLog[7].section_duration); }); it('does not add section duration as a line', () => { diff --git a/spec/frontend/vue_shared/components/users_table/mock_data.js b/spec/frontend/vue_shared/components/users_table/mock_data.js new file mode 100644 index 00000000000..c763ca2ca9b --- /dev/null +++ b/spec/frontend/vue_shared/components/users_table/mock_data.js @@ -0,0 +1,23 @@ +export const MOCK_USERS = [ + { + id: 2177, + name: 'Nikki', + createdAt: '2020-11-13T12:26:54.177Z', + email: 'nikki@example.com', + username: 'nikki', + lastActivityOn: '2020-12-09', + avatarUrl: + 'https://secure.gravatar.com/avatar/054f062d8b1a42b123f17e13a173cda8?s=80\\u0026d=identicon', + badges: [ + { text: 'Admin', variant: 'success' }, + { text: "It's you!", variant: 'muted' }, + ], + projectsCount: 0, + actions: [], + note: 'Create per issue #999', + }, +]; + +export const MOCK_ADMIN_USER_PATH = 'admin/users/:id'; + +export const MOCK_GROUP_COUNTS = { 2177: 5 }; diff --git a/spec/frontend/admin/users/components/user_avatar_spec.js b/spec/frontend/vue_shared/components/users_table/user_avatar_spec.js index 02e648d2b77..035778530af 100644 --- a/spec/frontend/admin/users/components/user_avatar_spec.js +++ b/spec/frontend/vue_shared/components/users_table/user_avatar_spec.js @@ -2,15 +2,14 @@ import { GlAvatarLabeled, GlBadge, GlIcon } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; -import AdminUserAvatar from '~/admin/users/components/user_avatar.vue'; -import { LENGTH_OF_USER_NOTE_TOOLTIP } from '~/admin/users/constants'; +import AdminUserAvatar from '~/vue_shared/components/users_table/user_avatar.vue'; +import { LENGTH_OF_USER_NOTE_TOOLTIP } from '~/vue_shared/components/users_table/constants'; import { truncate } from '~/lib/utils/text_utility'; -import { users, paths } from '../mock_data'; +import { MOCK_USERS, MOCK_ADMIN_USER_PATH } from './mock_data'; describe('AdminUserAvatar component', () => { let wrapper; - const user = users[0]; - const adminUserPath = paths.adminUser; + const user = MOCK_USERS[0]; const findNote = () => wrapper.findComponent(GlIcon); const findAvatar = () => wrapper.findComponent(GlAvatarLabeled); @@ -22,7 +21,7 @@ describe('AdminUserAvatar component', () => { wrapper = shallowMount(AdminUserAvatar, { propsData: { user, - adminUserPath, + adminUserPath: MOCK_ADMIN_USER_PATH, ...props, }, directives: { @@ -50,14 +49,7 @@ describe('AdminUserAvatar component', () => { const avatar = findAvatar(); expect(avatar.props('label')).toBe(user.name); - expect(avatar.props('labelLink')).toBe(adminUserPath.replace('id', user.username)); - }); - - it("renders the user's email with a mailto link", () => { - const avatar = findAvatar(); - - expect(avatar.props('subLabel')).toBe(user.email); - expect(avatar.props('subLabelLink')).toBe(`mailto:${user.email}`); + expect(avatar.props('labelLink')).toBe(MOCK_ADMIN_USER_PATH.replace('id', user.username)); }); it("renders the user's avatar image", () => { @@ -118,4 +110,30 @@ describe('AdminUserAvatar component', () => { }); }); }); + + describe('when user has an email address', () => { + beforeEach(() => { + initComponent(); + }); + + it("renders the user's email with a mailto link", () => { + const avatar = findAvatar(); + + expect(avatar.props('subLabel')).toBe(user.email); + expect(avatar.props('subLabelLink')).toBe(`mailto:${user.email}`); + }); + }); + + describe('when user does not have an email address', () => { + beforeEach(() => { + initComponent({ user: { ...MOCK_USERS[0], email: null } }); + }); + + it("renders the user's username without a link", () => { + const avatar = findAvatar(); + + expect(avatar.props('subLabel')).toBe(`@${user.username}`); + expect(avatar.props('subLabelLink')).toBe(''); + }); + }); }); diff --git a/spec/frontend/vue_shared/components/users_table/users_table_spec.js b/spec/frontend/vue_shared/components/users_table/users_table_spec.js new file mode 100644 index 00000000000..45d1d291d47 --- /dev/null +++ b/spec/frontend/vue_shared/components/users_table/users_table_spec.js @@ -0,0 +1,95 @@ +import { GlTable, GlSkeletonLoader } from '@gitlab/ui'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import UsersTable from '~/vue_shared/components/users_table/users_table.vue'; +import UserAvatar from '~/vue_shared/components/users_table/user_avatar.vue'; +import UserDate from '~/vue_shared/components/user_date.vue'; +import { MOCK_USERS, MOCK_ADMIN_USER_PATH, MOCK_GROUP_COUNTS } from './mock_data'; + +describe('UsersTable component', () => { + let wrapper; + const user = MOCK_USERS[0]; + + const findUserGroupCount = (id) => wrapper.findByTestId(`user-group-count-${id}`); + const findUserGroupCountLoader = (id) => findUserGroupCount(id).findComponent(GlSkeletonLoader); + const getCellByLabel = (trIdx, label) => { + return wrapper + .findComponent(GlTable) + .find('tbody') + .findAll('tr') + .at(trIdx) + .find(`[data-label="${label}"][role="cell"]`); + }; + + const initComponent = (props = {}) => { + wrapper = mountExtended(UsersTable, { + propsData: { + users: MOCK_USERS, + adminUserPath: MOCK_ADMIN_USER_PATH, + groupCounts: MOCK_GROUP_COUNTS, + groupCountsLoading: false, + ...props, + }, + }); + }; + + describe('when there are users', () => { + beforeEach(() => { + initComponent(); + }); + + it('renders the projects count', () => { + expect(getCellByLabel(0, 'Projects').text()).toContain(`${user.projectsCount}`); + }); + + it.each` + component | label + ${UserAvatar} | ${'Name'} + ${UserDate} | ${'Created on'} + ${UserDate} | ${'Last activity'} + `('renders the component for column $label', ({ component, label }) => { + expect(getCellByLabel(0, label).findComponent(component).exists()).toBe(true); + }); + }); + + describe('when users is an empty array', () => { + beforeEach(() => { + initComponent({ users: [] }); + }); + + it('renders a "No users found" message', () => { + expect(wrapper.text()).toContain('No users found'); + }); + }); + + describe('group counts', () => { + describe('when groupCountsLoading is true', () => { + beforeEach(() => { + initComponent({ groupCountsLoading: true }); + }); + + it('renders a loader for each user', () => { + expect(findUserGroupCountLoader(user.id).exists()).toBe(true); + }); + }); + + describe('when groupCounts has data', () => { + beforeEach(() => { + initComponent(); + }); + + it("renders the user's group count", () => { + expect(findUserGroupCount(user.id).text()).toBe('5'); + }); + }); + + describe('when groupCounts has no data', () => { + beforeEach(() => { + initComponent({ groupCounts: {} }); + }); + + it("renders the user's group count as 0", () => { + expect(findUserGroupCount(user.id).text()).toBe('0'); + }); + }); + }); +}); diff --git a/spec/graphql/types/permission_types/ci/pipeline_spec.rb b/spec/graphql/types/permission_types/ci/pipeline_spec.rb new file mode 100644 index 00000000000..6830b659b12 --- /dev/null +++ b/spec/graphql/types/permission_types/ci/pipeline_spec.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Types::PermissionTypes::Ci::Pipeline, feature_category: :continuous_integration do + it 'has expected permission fields' do + expected_permissions = [ + :admin_pipeline, :destroy_pipeline, :update_pipeline, :cancel_pipeline + ] + + expect(described_class).to have_graphql_fields(expected_permissions).only + end +end diff --git a/spec/lib/gitlab/ci/ansi2json/line_spec.rb b/spec/lib/gitlab/ci/ansi2json/line_spec.rb index b8563bb1d1c..475a54b275d 100644 --- a/spec/lib/gitlab/ci/ansi2json/line_spec.rb +++ b/spec/lib/gitlab/ci/ansi2json/line_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe Gitlab::Ci::Ansi2json::Line do +RSpec.describe Gitlab::Ci::Ansi2json::Line, feature_category: :continuous_integration do let(:offset) { 0 } let(:style) { Gitlab::Ci::Ansi2json::Style.new } @@ -75,6 +75,14 @@ RSpec.describe Gitlab::Ci::Ansi2json::Line do end end + describe '#set_as_section_footer' do + it 'change the section_footer to true' do + expect { subject.set_as_section_footer } + .to change { subject.section_footer } + .to be_truthy + end + end + describe '#set_section_duration' do using RSpec::Parameterized::TableSyntax @@ -178,6 +186,23 @@ RSpec.describe Gitlab::Ci::Ansi2json::Line do expect(subject.to_h).to eq(result) end end + + context 'when section footer is set' do + before do + subject.set_as_section_footer + end + + it 'serializes the attributes set' do + result = { + offset: 0, + content: [{ text: 'some data', style: 'term-bold' }], + section: 'section_2', + section_footer: true + } + + expect(subject.to_h).to eq(result) + end + end end context 'when there are no sections' do diff --git a/spec/lib/gitlab/ci/ansi2json/state_spec.rb b/spec/lib/gitlab/ci/ansi2json/state_spec.rb index 8dd4092f3d8..07e6579829a 100644 --- a/spec/lib/gitlab/ci/ansi2json/state_spec.rb +++ b/spec/lib/gitlab/ci/ansi2json/state_spec.rb @@ -8,7 +8,7 @@ RSpec.describe Gitlab::Ci::Ansi2json::State, feature_category: :continuous_integ state.offset = 1 state.new_line!(style: { fg: 'some-fg', bg: 'some-bg', mask: 1234 }) state.set_last_line_offset - state.open_section('hello', 111, {}) + state.open_section('hello', 100, {}) end end @@ -24,7 +24,7 @@ RSpec.describe Gitlab::Ci::Ansi2json::State, feature_category: :continuous_integ fg: 'some-fg', mask: 1234 }) - expect(new_state.open_sections).to eq({ 'hello' => 111 }) + expect(new_state.open_sections).to eq({ 'hello' => 100 }) end it 'ignores unsigned prior state', :aggregate_failures do @@ -44,6 +44,23 @@ RSpec.describe Gitlab::Ci::Ansi2json::State, feature_category: :continuous_integ expect(new_state.open_sections).to eq({}) end + it 'opens and closes a section', :aggregate_failures do + new_state = described_class.new('', 1000) + + new_state.new_line!(style: {}) + new_state.open_section('hello', 100, {}) + + expect(new_state.current_line.section_header).to eq(true) + expect(new_state.current_line.section_footer).to eq(false) + + new_state.new_line!(style: {}) + new_state.close_section('hello', 101) + + expect(new_state.current_line.section_header).to eq(false) + expect(new_state.current_line.section_duration).to eq('00:01') + expect(new_state.current_line.section_footer).to eq(true) + end + it 'ignores bad input', :aggregate_failures do expect(::Gitlab::AppLogger).to( receive(:warn).with( diff --git a/spec/lib/gitlab/ci/ansi2json_spec.rb b/spec/lib/gitlab/ci/ansi2json_spec.rb index 98fca40e8ea..23be3209171 100644 --- a/spec/lib/gitlab/ci/ansi2json_spec.rb +++ b/spec/lib/gitlab/ci/ansi2json_spec.rb @@ -145,6 +145,7 @@ RSpec.describe Gitlab::Ci::Ansi2json, feature_category: :continuous_integration offset: 63, content: [], section_duration: '01:03', + section_footer: true, section: 'prepare-script' } ]) @@ -163,7 +164,8 @@ RSpec.describe Gitlab::Ci::Ansi2json, feature_category: :continuous_integration offset: 56, content: [], section: 'prepare-script', - section_duration: '01:03' + section_duration: '01:03', + section_footer: true } ]) end @@ -181,7 +183,8 @@ RSpec.describe Gitlab::Ci::Ansi2json, feature_category: :continuous_integration offset: 49, content: [], section: 'prepare-script', - section_duration: '01:03' + section_duration: '01:03', + section_footer: true }, { offset: 91, @@ -262,7 +265,8 @@ RSpec.describe Gitlab::Ci::Ansi2json, feature_category: :continuous_integration offset: 75, content: [], section: 'prepare-script', - section_duration: '01:03' + section_duration: '01:03', + section_footer: true } ]) end @@ -300,7 +304,8 @@ RSpec.describe Gitlab::Ci::Ansi2json, feature_category: :continuous_integration offset: 106, content: [], section: 'prepare-script-nested', - section_duration: '00:02' + section_duration: '00:02', + section_footer: true }, { offset: 155, @@ -311,7 +316,8 @@ RSpec.describe Gitlab::Ci::Ansi2json, feature_category: :continuous_integration offset: 158, content: [], section: 'prepare-script', - section_duration: '01:03' + section_duration: '01:03', + section_footer: true }, { offset: 200, @@ -345,13 +351,15 @@ RSpec.describe Gitlab::Ci::Ansi2json, feature_category: :continuous_integration offset: 115, content: [], section: 'prepare-script-nested', - section_duration: '00:02' + section_duration: '00:02', + section_footer: true }, { offset: 164, content: [], section: 'prepare-script', - section_duration: '01:03' + section_duration: '01:03', + section_footer: true } ]) end @@ -378,7 +386,8 @@ RSpec.describe Gitlab::Ci::Ansi2json, feature_category: :continuous_integration offset: 83, content: [], section: 'prepare-script', - section_duration: '01:03' + section_duration: '01:03', + section_footer: true } ]) end @@ -554,7 +563,8 @@ RSpec.describe Gitlab::Ci::Ansi2json, feature_category: :continuous_integration offset: 77, content: [], section: 'prepare-script', - section_duration: '01:03' + section_duration: '01:03', + section_footer: true } ] end diff --git a/spec/lib/gitlab/redis/multi_store_spec.rb b/spec/lib/gitlab/redis/multi_store_spec.rb index 3feb09bea02..6b1c0fb2e81 100644 --- a/spec/lib/gitlab/redis/multi_store_spec.rb +++ b/spec/lib/gitlab/redis/multi_store_spec.rb @@ -3,7 +3,6 @@ require 'spec_helper' RSpec.describe Gitlab::Redis::MultiStore, feature_category: :redis do - using RSpec::Parameterized::TableSyntax include RedisHelpers let_it_be(:redis_store_class) { define_helper_redis_store_class } @@ -81,113 +80,16 @@ RSpec.describe Gitlab::Redis::MultiStore, feature_category: :redis do multi_store.send(name, *args, **kwargs) end - let_it_be(:key1) { "redis:{1}:key_a" } - let_it_be(:key2) { "redis:{1}:key_b" } - let_it_be(:value1) { "redis_value1" } - let_it_be(:value2) { "redis_value2" } - let_it_be(:skey) { "redis:set:key" } - let_it_be(:skey2) { "redis:set:key2" } - let_it_be(:smemberargs) { [skey, value1] } - let_it_be(:hkey) { "redis:hash:key" } - let_it_be(:hkey2) { "redis:hash:key2" } - let_it_be(:zkey) { "redis:sortedset:key" } - let_it_be(:zkey2) { "redis:sortedset:key2" } - let_it_be(:hitem1) { "item1" } - let_it_be(:hitem2) { "item2" } - let_it_be(:keys) { [key1, key2] } - let_it_be(:values) { [value1, value2] } - let_it_be(:svalues) { [value2, value1] } - let_it_be(:hgetargs) { [hkey, hitem1] } - let_it_be(:hmgetval) { [value1] } - let_it_be(:mhmgetargs) { [hkey, hitem1] } - let_it_be(:hvalmapped) { { "item1" => value1 } } - let_it_be(:sscanargs) { [skey2, 0] } - let_it_be(:sscanval) { ["0", [value1]] } - let_it_be(:scanargs) { ["0"] } - let_it_be(:scankwargs) { { match: '*:set:key2*' } } - let_it_be(:scanval) { ["0", [skey2]] } - let_it_be(:sscan_eachval) { [value1] } - let_it_be(:sscan_each_arg) { { match: '*1*' } } - let_it_be(:hscan_eachval) { [[hitem1, value1]] } - let_it_be(:zscan_eachval) { [[value1, 1.0]] } - let_it_be(:scan_each_arg) { { match: 'redis*' } } - let_it_be(:scan_each_val) { [key1, key2, skey, skey2, hkey, hkey2, zkey, zkey2] } - - # rubocop:disable Layout/LineLength - where(:case_name, :name, :args, :value, :kwargs, :block) do - 'execute :get command' | :get | ref(:key1) | ref(:value1) | {} | nil - 'execute :mget command' | :mget | ref(:keys) | ref(:values) | {} | nil - 'execute :mget with block' | :mget | ref(:keys) | ref(:values) | {} | ->(value) { value } - 'execute :smembers command' | :smembers | ref(:skey) | ref(:svalues) | {} | nil - 'execute :scard command' | :scard | ref(:skey) | 2 | {} | nil - 'execute :sismember command' | :sismember | ref(:smemberargs) | true | {} | nil - 'execute :exists command' | :exists | ref(:key1) | 1 | {} | nil - 'execute :exists? command' | :exists? | ref(:key1) | true | {} | nil - 'execute :hget command' | :hget | ref(:hgetargs) | ref(:value1) | {} | nil - 'execute :hlen command' | :hlen | ref(:hkey) | 1 | {} | nil - 'execute :hgetall command' | :hgetall | ref(:hkey) | ref(:hvalmapped) | {} | nil - 'execute :hexists command' | :hexists | ref(:hgetargs) | true | {} | nil - 'execute :hmget command' | :hmget | ref(:hgetargs) | ref(:hmgetval) | {} | nil - 'execute :mapped_hmget command' | :mapped_hmget | ref(:mhmgetargs) | ref(:hvalmapped) | {} | nil - 'execute :sscan command' | :sscan | ref(:sscanargs) | ref(:sscanval) | {} | nil - 'execute :scan command' | :scan | ref(:scanargs) | ref(:scanval) | ref(:scankwargs) | nil - - # we run *scan_each here as they are reads too - 'execute :scan_each command' | :scan_each | nil | ref(:scan_each_val) | ref(:scan_each_arg) | nil - 'execute :sscan_each command' | :sscan_each | ref(:skey2) | ref(:sscan_eachval) | {} | nil - 'execute :sscan_each w block' | :sscan_each | ref(:skey) | ref(:sscan_eachval) | ref(:sscan_each_arg) | nil - 'execute :hscan_each command' | :hscan_each | ref(:hkey) | ref(:hscan_eachval) | {} | nil - 'execute :hscan_each w block' | :hscan_each | ref(:hkey2) | ref(:hscan_eachval) | ref(:sscan_each_arg) | nil - 'execute :zscan_each command' | :zscan_each | ref(:zkey) | ref(:zscan_eachval) | {} | nil - 'execute :zscan_each w block' | :zscan_each | ref(:zkey2) | ref(:zscan_eachval) | ref(:sscan_each_arg) | nil - end - # rubocop:enable Layout/LineLength - - before do - primary_store.set(key1, value1) - primary_store.set(key2, value2) - primary_store.sadd?(skey, [value1, value2]) - primary_store.sadd?(skey2, [value1]) - primary_store.hset(hkey, hitem1, value1) - primary_store.hset(hkey2, hitem1, value1, hitem2, value2) - primary_store.zadd(zkey, 1, value1) - primary_store.zadd(zkey2, [[1, value1], [2, value2]]) - - secondary_store.set(key1, value1) - secondary_store.set(key2, value2) - secondary_store.sadd?(skey, [value1, value2]) - secondary_store.sadd?(skey2, [value1]) - secondary_store.hset(hkey, hitem1, value1) - secondary_store.hset(hkey2, hitem1, value1, hitem2, value2) - secondary_store.zadd(zkey, 1, value1) - secondary_store.zadd(zkey2, [[1, value1], [2, value2]]) - end - - after do - primary_store.flushdb - secondary_store.flushdb - end - - RSpec.shared_examples_for 'reads correct value' do - it 'returns the correct value' do - if value.is_a?(Array) - # :smembers does not guarantee the order it will return the values (unsorted set) - is_expected.to match_array(value) - else - is_expected.to eq(value) - end - end - end + let(:args) { 'args' } + let(:kwargs) { { match: '*:set:key2*' } } RSpec.shared_examples_for 'secondary store' do it 'execute on the secondary instance' do - expect(secondary_store).to receive(name).with(*expected_args).and_call_original + expect(secondary_store).to receive(name).with(*expected_args) subject end - include_examples 'reads correct value' - it 'does not execute on the primary store' do expect(primary_store).not_to receive(name) @@ -195,23 +97,22 @@ RSpec.describe Gitlab::Redis::MultiStore, feature_category: :redis do end end - with_them do + described_class::READ_COMMANDS.each do |name| describe name.to_s do - let(:expected_args) { kwargs&.present? ? [*args, { **kwargs }] : Array(args) } + let(:expected_args) { [*args, { **kwargs }] } + let(:name) { name } before do - allow(primary_store).to receive(name).and_call_original - allow(secondary_store).to receive(name).and_call_original + allow(primary_store).to receive(name) + allow(secondary_store).to receive(name) end context 'when reading from the primary is successful' do it 'returns the correct value' do - expect(primary_store).to receive(name).with(*expected_args).and_call_original + expect(primary_store).to receive(name).with(*expected_args) subject end - - include_examples 'reads correct value' end context 'when reading from default instance is raising an exception' do @@ -229,17 +130,6 @@ RSpec.describe Gitlab::Redis::MultiStore, feature_category: :redis do end end - context 'when reading from empty default instance' do - before do - # this ensures a cache miss without having to stub the default store - multi_store.default_store.flushdb - end - - it 'does not call the non_default_store' do - expect(multi_store.non_default_store).not_to receive(name) - end - end - context 'when the command is executed within pipelined block' do subject do multi_store.pipelined do |pipeline| @@ -253,7 +143,7 @@ RSpec.describe Gitlab::Redis::MultiStore, feature_category: :redis do 2.times do expect_next_instance_of(Redis::PipelinedConnection) do |pipeline| - expect(pipeline).to receive(name).with(*expected_args).once.and_call_original + expect(pipeline).to receive(name).with(*expected_args).once end end @@ -261,27 +151,16 @@ RSpec.describe Gitlab::Redis::MultiStore, feature_category: :redis do end end - if params[:block] + context 'when block provided' do subject do - multi_store.send(name, *expected_args, &block) + multi_store.send(name, expected_args) { nil } end - context 'when block is provided' do - it 'only default store yields to the block' do - expect(primary_store).to receive(name).and_yield(value) - expect(secondary_store).not_to receive(name).and_yield(value) + it 'only default store to execute' do + expect(primary_store).to receive(:send).with(name, expected_args) + expect(secondary_store).not_to receive(:send) - subject - end - - it 'only default store to execute' do - expect(primary_store).to receive(name).with(*expected_args).and_call_original - expect(secondary_store).not_to receive(name).with(*expected_args).and_call_original - - subject - end - - include_examples 'reads correct value' + subject end end @@ -304,8 +183,8 @@ RSpec.describe Gitlab::Redis::MultiStore, feature_category: :redis do end it 'executes only on secondary redis store', :aggregate_failures do - expect(secondary_store).to receive(name).with(*expected_args).and_call_original - expect(primary_store).not_to receive(name).with(*expected_args).and_call_original + expect(secondary_store).to receive(name).with(*expected_args) + expect(primary_store).not_to receive(name).with(*expected_args) subject end @@ -313,8 +192,8 @@ RSpec.describe Gitlab::Redis::MultiStore, feature_category: :redis do context 'when using primary store as default' do it 'executes only on primary redis store', :aggregate_failures do - expect(primary_store).to receive(name).with(*expected_args).and_call_original - expect(secondary_store).not_to receive(name).with(*expected_args).and_call_original + expect(primary_store).to receive(name).with(*expected_args) + expect(secondary_store).not_to receive(name).with(*expected_args) subject end @@ -406,110 +285,24 @@ RSpec.describe Gitlab::Redis::MultiStore, feature_category: :redis do end end - RSpec.shared_examples_for 'verify that store contains values' do |store| - it "#{store} redis store contains correct values", :aggregate_failures do - subject - - redis_store = multi_store.send(store) - - if expected_value.is_a?(Array) - # :smembers does not guarantee the order it will return the values - expect(redis_store.send(verification_name, *verification_args)).to match_array(expected_value) - else - expect(redis_store.send(verification_name, *verification_args)).to eq(expected_value) - end - end - end - - # rubocop:disable RSpec/MultipleMemoizedHelpers context 'with WRITE redis commands' do - let_it_be(:ikey1) { "counter1" } - let_it_be(:ikey2) { "counter2" } - let_it_be(:iargs) { [ikey2, 3] } - let_it_be(:ivalue1) { "1" } - let_it_be(:ivalue2) { "3" } - let_it_be(:key1) { "redis:{1}:key_a" } - let_it_be(:key2) { "redis:{1}:key_b" } - let_it_be(:key3) { "redis:{1}:key_c" } - let_it_be(:key4) { "redis:{1}:key_d" } - let_it_be(:value1) { "redis_value1" } - let_it_be(:value2) { "redis_value2" } - let_it_be(:key1_value1) { [key1, value1] } - let_it_be(:key1_value2) { [key1, value2] } - let_it_be(:ttl) { 10 } - let_it_be(:key1_ttl_value1) { [key1, ttl, value1] } - let_it_be(:skey) { "redis:set:key" } - let_it_be(:svalues1) { [value2, value1] } - let_it_be(:svalues2) { [value1] } - let_it_be(:skey_value1) { [skey, [value1]] } - let_it_be(:skey_value2) { [skey, [value2]] } - let_it_be(:script) { %(redis.call("set", "#{key1}", "#{value1}")) } - let_it_be(:hkey1) { "redis:{1}:hash_a" } - let_it_be(:hkey2) { "redis:{1}:hash_b" } - let_it_be(:item) { "item" } - let_it_be(:hdelarg) { [hkey1, item] } - let_it_be(:hsetarg) { [hkey2, item, value1] } - let_it_be(:mhsetarg) { [hkey2, { "item" => value1 }] } - let_it_be(:hgetarg) { [hkey2, item] } - let_it_be(:expireargs) { [key3, ttl] } - - # rubocop:disable Layout/LineLength - where(:case_name, :name, :args, :expected_value, :verification_name, :verification_args) do - 'execute :set command' | :set | ref(:key1_value1) | ref(:value1) | :get | ref(:key1) - 'execute :setnx command' | :setnx | ref(:key1_value2) | ref(:value1) | :get | ref(:key2) - 'execute :setex command' | :setex | ref(:key1_ttl_value1) | ref(:ttl) | :ttl | ref(:key1) - 'execute :sadd command' | :sadd | ref(:skey_value2) | ref(:svalues1) | :smembers | ref(:skey) - 'execute :sadd? command' | :sadd? | ref(:skey_value2) | ref(:svalues1) | :smembers | ref(:skey) - 'execute :srem command' | :srem | ref(:skey_value1) | [] | :smembers | ref(:skey) - 'execute :del command' | :del | ref(:key2) | nil | :get | ref(:key2) - 'execute :unlink command' | :unlink | ref(:key3) | nil | :get | ref(:key3) - 'execute :flushdb command' | :flushdb | nil | 0 | :dbsize | nil - 'execute :eval command' | :eval | ref(:script) | ref(:value1) | :get | ref(:key1) - 'execute :incr command' | :incr | ref(:ikey1) | ref(:ivalue1) | :get | ref(:ikey1) - 'execute :incrby command' | :incrby | ref(:iargs) | ref(:ivalue2) | :get | ref(:ikey2) - 'execute :hset command' | :hset | ref(:hsetarg) | ref(:value1) | :hget | ref(:hgetarg) - 'execute :hdel command' | :hdel | ref(:hdelarg) | nil | :hget | ref(:hdelarg) - 'execute :expire command' | :expire | ref(:expireargs) | ref(:ttl) | :ttl | ref(:key3) - 'execute :mapped_hmset command' | :mapped_hmset | ref(:mhsetarg) | ref(:value1) | :hget | ref(:hgetarg) - end - # rubocop:enable Layout/LineLength - - before do - primary_store.flushdb - secondary_store.flushdb - - primary_store.set(key2, value1) - primary_store.set(key3, value1) - primary_store.set(key4, value1) - primary_store.sadd?(skey, value1) - primary_store.hset(hkey2, item, value1) - - secondary_store.set(key2, value1) - secondary_store.set(key3, value1) - secondary_store.set(key4, value1) - secondary_store.sadd?(skey, value1) - secondary_store.hset(hkey2, item, value1) - end - - with_them do + described_class::WRITE_COMMANDS.each do |name| describe name.to_s do - let(:expected_args) { args || no_args } + let(:args) { "dummy_args" } + let(:name) { name } before do - allow(primary_store).to receive(name).and_call_original - allow(secondary_store).to receive(name).and_call_original + allow(primary_store).to receive(name) + allow(secondary_store).to receive(name) end context 'when executing on primary instance is successful' do it 'executes on both primary and secondary redis store', :aggregate_failures do - expect(primary_store).to receive(name).with(*expected_args).and_call_original - expect(secondary_store).to receive(name).with(*expected_args).and_call_original + expect(primary_store).to receive(name).with(*args) + expect(secondary_store).to receive(name).with(*args) subject end - - include_examples 'verify that store contains values', :primary_store - include_examples 'verify that store contains values', :secondary_store end context 'when use_primary_and_secondary_stores feature flag is disabled' do @@ -523,8 +316,8 @@ RSpec.describe Gitlab::Redis::MultiStore, feature_category: :redis do end it 'executes only on secondary redis store', :aggregate_failures do - expect(secondary_store).to receive(name).with(*expected_args).and_call_original - expect(primary_store).not_to receive(name).with(*expected_args).and_call_original + expect(secondary_store).to receive(name).with(*args) + expect(primary_store).not_to receive(name).with(*args) subject end @@ -532,8 +325,8 @@ RSpec.describe Gitlab::Redis::MultiStore, feature_category: :redis do context 'when using primary store as default' do it 'executes only on primary redis store', :aggregate_failures do - expect(primary_store).to receive(name).with(*expected_args).and_call_original - expect(secondary_store).not_to receive(name).with(*expected_args).and_call_original + expect(primary_store).to receive(name).with(*args) + expect(secondary_store).not_to receive(name).with(*args) subject end @@ -542,19 +335,19 @@ RSpec.describe Gitlab::Redis::MultiStore, feature_category: :redis do context 'when executing on the default instance is raising an exception' do before do - allow(multi_store.default_store).to receive(name).with(*expected_args).and_raise(StandardError) + allow(multi_store.default_store).to receive(name).with(*args).and_raise(StandardError) allow(Gitlab::ErrorTracking).to receive(:log_exception) end it 'raises error and does not execute on non default instance', :aggregate_failures do - expect(multi_store.non_default_store).not_to receive(name).with(*expected_args) + expect(multi_store.non_default_store).not_to receive(name).with(*args) expect { subject }.to raise_error(StandardError) end end context 'when executing on the non default instance is raising an exception' do before do - allow(multi_store.non_default_store).to receive(name).with(*expected_args).and_raise(StandardError) + allow(multi_store.non_default_store).to receive(name).with(*args).and_raise(StandardError) allow(Gitlab::ErrorTracking).to receive(:log_exception) end @@ -562,12 +355,10 @@ RSpec.describe Gitlab::Redis::MultiStore, feature_category: :redis do expect(Gitlab::ErrorTracking).to receive(:log_exception).with(an_instance_of(StandardError), hash_including(:multi_store_error_message, command_name: name, instance_name: instance_name)) - expect(multi_store.default_store).to receive(name).with(*expected_args).and_call_original + expect(multi_store.default_store).to receive(name).with(*args) subject end - - include_examples 'verify that store contains values', :default_store end context 'when the command is executed within pipelined block' do @@ -580,103 +371,32 @@ RSpec.describe Gitlab::Redis::MultiStore, feature_category: :redis do it 'is executed only 1 time on each instance', :aggregate_failures do expect(primary_store).to receive(:pipelined).and_call_original expect_next_instance_of(Redis::PipelinedConnection) do |pipeline| - expect(pipeline).to receive(name).with(*expected_args).once.and_call_original + expect(pipeline).to receive(name).with(*args).once end expect(secondary_store).to receive(:pipelined).and_call_original expect_next_instance_of(Redis::PipelinedConnection) do |pipeline| - expect(pipeline).to receive(name).with(*expected_args).once.and_call_original + expect(pipeline).to receive(name).with(*args).once end subject end - - include_examples 'verify that store contains values', :primary_store - include_examples 'verify that store contains values', :secondary_store end end end end - # rubocop:enable RSpec/MultipleMemoizedHelpers - - context 'with mocked redis commands' do - let(:args) { [1, 2, 3] } - let(:kwargs) { { foo: 'bar' } } - - subject do - multi_store.send(command, *args, **kwargs) - end - - context 'for read commands' do - described_class::READ_COMMANDS.each do |command| - describe command.to_s do - let(:command) { command } - - where( - :use_primary_and_secondary_stores_ff, - :use_primary_store_as_default_ff, - :executed_store, - :non_executed_store, - :executed_store_name - ) do - false | false | ref(:secondary_store) | ref(:primary_store) | 'secondary_store' - true | false | ref(:secondary_store) | ref(:primary_store) | 'secondary_store' - true | true | ref(:primary_store) | ref(:secondary_store) | 'primary_store' - false | true | ref(:primary_store) | ref(:secondary_store) | 'primary_store' - end - - with_them do - before do - stub_feature_flags( - use_primary_and_secondary_stores_for_test_store: use_primary_and_secondary_stores_ff, - use_primary_store_as_default_for_test_store: use_primary_store_as_default_ff - ) - end - - it "executes on #{params[:executed_store_name]}" do - expect(executed_store).to receive(command).with(*args, **kwargs) - expect(non_executed_store).not_to receive(command) - subject - end - end - end - end - end - - context 'for write commands' do - described_class::WRITE_COMMANDS.each do |command| - describe command.to_s do - let(:command) { command } - - where( - :use_primary_and_secondary_stores_ff, - :use_primary_store_as_default_ff, - :executed_stores, - :non_executed_store - ) do - false | false | [ref(:secondary_store)] | ref(:primary_store) - true | false | [ref(:secondary_store), ref(:primary_store)] | [] - true | true | [ref(:primary_store), ref(:secondary_store)] | [] - false | true | [ref(:primary_store)] | ref(:secondary_store) - end - - with_them do - before do - stub_feature_flags( - use_primary_and_secondary_stores_for_test_store: use_primary_and_secondary_stores_ff, - use_primary_store_as_default_for_test_store: use_primary_store_as_default_ff - ) - end + RSpec.shared_examples_for 'verify that store contains values' do |store| + it "#{store} redis store contains correct values", :aggregate_failures do + subject - it "executes on executed_stores" do - expect(executed_stores).to all(receive(command).with(*args, **kwargs).ordered) - expect(non_executed_store).not_to receive(command) + redis_store = multi_store.send(store) - subject - end - end - end + if expected_value.is_a?(Array) + # :smembers does not guarantee the order it will return the values + expect(redis_store.send(verification_name, *verification_args)).to match_array(expected_value) + else + expect(redis_store.send(verification_name, *verification_args)).to eq(expected_value) end end end diff --git a/spec/policies/ci/build_policy_spec.rb b/spec/policies/ci/build_policy_spec.rb index ab92936440c..ad568e60d5c 100644 --- a/spec/policies/ci/build_policy_spec.rb +++ b/spec/policies/ci/build_policy_spec.rb @@ -109,7 +109,7 @@ RSpec.describe Ci::BuildPolicy, feature_category: :continuous_integration do allow(project).to receive(:branch_allows_collaboration?).and_return(true) end - it 'enables update_build if user is maintainer' do + it 'enables updates if user is maintainer', :aggregate_failures do expect(policy).to be_allowed :cancel_build expect(policy).to be_allowed :update_build expect(policy).to be_allowed :update_commit_status diff --git a/spec/requests/api/ci/jobs_spec.rb b/spec/requests/api/ci/jobs_spec.rb index 2ab112a8527..382aabd45a1 100644 --- a/spec/requests/api/ci/jobs_spec.rb +++ b/spec/requests/api/ci/jobs_spec.rb @@ -791,14 +791,14 @@ RSpec.describe API::Ci::Jobs, feature_category: :continuous_integration do end context 'authorized user' do - context 'user with :update_build persmission' do + context 'user with :cancel_build permission' do it 'cancels running or pending job' do expect(response).to have_gitlab_http_status(:created) expect(project.builds.first.status).to eq('success') end end - context 'user without :update_build permission' do + context 'user without :cancel_build permission' do let(:api_user) { reporter } it 'does not cancel job' do diff --git a/spec/requests/api/personal_access_tokens_spec.rb b/spec/requests/api/personal_access_tokens_spec.rb index 166768ea605..a1d29c4a935 100644 --- a/spec/requests/api/personal_access_tokens_spec.rb +++ b/spec/requests/api/personal_access_tokens_spec.rb @@ -461,6 +461,18 @@ RSpec.describe API::PersonalAccessTokens, :aggregate_failures, feature_category: expect(json_response['expires_at']).to eq((Date.today + 1.week).to_s) end + context 'when expiry is defined' do + it "rotates user's own token", :freeze_time do + expiry_date = Date.today + 1.month + + post(api(path, token.user), params: { expires_at: expiry_date }) + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response['token']).not_to eq(token.token) + expect(json_response['expires_at']).to eq(expiry_date.to_s) + end + end + context 'without permission' do it 'returns an error message' do another_user = create(:user) diff --git a/spec/requests/api/resource_access_tokens_spec.rb b/spec/requests/api/resource_access_tokens_spec.rb index dcb6572d413..01e02651a64 100644 --- a/spec/requests/api/resource_access_tokens_spec.rb +++ b/spec/requests/api/resource_access_tokens_spec.rb @@ -477,6 +477,7 @@ RSpec.describe API::ResourceAccessTokens, feature_category: :system_access do let_it_be(:token) { create(:personal_access_token, user: project_bot) } let_it_be(:resource_id) { resource.id } let_it_be(:token_id) { token.id } + let(:params) { {} } let(:path) { "/#{source_type}s/#{resource_id}/access_tokens/#{token_id}/rotate" } @@ -485,7 +486,7 @@ RSpec.describe API::ResourceAccessTokens, feature_category: :system_access do resource.add_owner(user) end - subject(:rotate_token) { post api(path, user) } + subject(:rotate_token) { post(api(path, user), params: params) } it "allows owner to rotate token", :freeze_time do rotate_token @@ -495,6 +496,19 @@ RSpec.describe API::ResourceAccessTokens, feature_category: :system_access do expect(json_response['expires_at']).to eq((Date.today + 1.week).to_s) end + context 'when expiry is defined' do + let(:expiry_date) { Date.today + 1.month } + let(:params) { { expires_at: expiry_date } } + + it "allows owner to rotate token", :freeze_time do + rotate_token + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response['token']).not_to eq(token.token) + expect(json_response['expires_at']).to eq(expiry_date.to_s) + end + end + context 'without permission' do it 'returns an error message' do another_user = create(:user) diff --git a/spec/serializers/ci/job_entity_spec.rb b/spec/serializers/ci/job_entity_spec.rb index 6dce87a1fc5..c3d0de11405 100644 --- a/spec/serializers/ci/job_entity_spec.rb +++ b/spec/serializers/ci/job_entity_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe Ci::JobEntity do +RSpec.describe Ci::JobEntity, feature_category: :continuous_integration do let(:user) { create(:user) } let(:job) { create(:ci_build, :running) } let(:project) { job.project } diff --git a/spec/services/ci/catalog/resources/release_service_spec.rb b/spec/services/ci/catalog/resources/release_service_spec.rb new file mode 100644 index 00000000000..1901485d402 --- /dev/null +++ b/spec/services/ci/catalog/resources/release_service_spec.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Ci::Catalog::Resources::ReleaseService, feature_category: :pipeline_composition do + describe '#execute' do + context 'with a valid catalog resource and release' do + it 'validates the catalog resource and creates a version' do + project = create(:project, :catalog_resource_with_components) + catalog_resource = create(:ci_catalog_resource, project: project) + release = create(:release, project: project, sha: project.repository.root_ref_sha) + + response = described_class.new(release).execute + + version = Ci::Catalog::Resources::Version.last + + expect(response).to be_success + expect(version.release).to eq(release) + expect(version.catalog_resource).to eq(catalog_resource) + expect(version.catalog_resource.project).to eq(project) + end + end + + context 'when the validation of the catalog resource fails' do + it 'returns an error and does not create a version' do + project = create(:project, :repository) + create(:ci_catalog_resource, project: project) + release = create(:release, project: project, sha: project.repository.root_ref_sha) + + response = described_class.new(release).execute + + expect(Ci::Catalog::Resources::Version.count).to be(0) + expect(response).to be_error + expect(response.message).to eq('Project must have a description, Project must contain components') + end + end + + context 'when the creation of a version fails' do + it 'returns an error and does not create a version' do + project = + create( + :project, :custom_repo, + description: 'Component project', + files: { + 'templates/secret-detection.yml' => 'image: agent: coop', + 'README.md' => 'Read me' + } + ) + create(:ci_catalog_resource, project: project) + release = create(:release, project: project, sha: project.repository.root_ref_sha) + + response = described_class.new(release).execute + + expect(Ci::Catalog::Resources::Version.count).to be(0) + expect(response).to be_error + expect(response.message).to include('mapping values are not allowed in this context') + end + end + end +end diff --git a/spec/services/ci/retry_job_service_spec.rb b/spec/services/ci/retry_job_service_spec.rb index 80fbfc04f9b..1646afde21d 100644 --- a/spec/services/ci/retry_job_service_spec.rb +++ b/spec/services/ci/retry_job_service_spec.rb @@ -270,14 +270,6 @@ RSpec.describe Ci::RetryJobService, feature_category: :continuous_integration do it_behaves_like 'creates associations for a deployable job', :ci_bridge end - context 'when `create_deployment_only_for_processable_jobs` FF is disabled' do - before do - stub_feature_flags(create_deployment_only_for_processable_jobs: false) - end - - it_behaves_like 'creates associations for a deployable job', :ci_bridge - end - context 'when given variables' do let(:new_job) { service.clone!(job, variables: job_variables_attributes) } @@ -302,14 +294,6 @@ RSpec.describe Ci::RetryJobService, feature_category: :continuous_integration do it_behaves_like 'creates associations for a deployable job', :ci_build end - context 'when `create_deployment_only_for_processable_jobs` FF is disabled' do - before do - stub_feature_flags(create_deployment_only_for_processable_jobs: false) - end - - it_behaves_like 'creates associations for a deployable job', :ci_build - end - context 'when given variables' do let(:new_job) { service.clone!(job, variables: job_variables_attributes) } diff --git a/spec/services/releases/create_service_spec.rb b/spec/services/releases/create_service_spec.rb index ab578f19d5f..b28d7549fbb 100644 --- a/spec/services/releases/create_service_spec.rb +++ b/spec/services/releases/create_service_spec.rb @@ -56,16 +56,17 @@ RSpec.describe Releases::CreateService, feature_category: :continuous_integratio end context 'when project is a catalog resource' do - let(:ref) { 'master' } + let(:project) { create(:project, :catalog_resource_with_components, create_tag: 'final') } let!(:ci_catalog_resource) { create(:ci_catalog_resource, project: project) } + let(:ref) { 'master' } context 'and it is valid' do - let_it_be(:project) { create(:project, :catalog_resource_with_components, create_tag: 'final') } - it_behaves_like 'a successful release creation' end - context 'and it is invalid' do + context 'and it is an invalid resource' do + let_it_be(:project) { create(:project, :repository) } + it 'raises an error and does not update the release' do result = service.execute diff --git a/spec/support/shared_contexts/policies/project_policy_shared_context.rb b/spec/support/shared_contexts/policies/project_policy_shared_context.rb index 5014a810f35..68eb3539813 100644 --- a/spec/support/shared_contexts/policies/project_policy_shared_context.rb +++ b/spec/support/shared_contexts/policies/project_policy_shared_context.rb @@ -54,7 +54,7 @@ RSpec.shared_context 'ProjectPolicy context' do create_environment create_merge_request_from admin_metrics_dashboard_annotation create_pipeline create_release create_wiki destroy_container_image push_code read_pod_logs - read_terraform_state resolve_note update_build update_commit_status + read_terraform_state resolve_note update_build cancel_build update_commit_status update_container_image update_deployment update_environment update_merge_request update_pipeline update_release destroy_release read_resource_group update_resource_group update_escalation_status diff --git a/spec/workers/ci/initial_pipeline_process_worker_spec.rb b/spec/workers/ci/initial_pipeline_process_worker_spec.rb index 9a94f1cbb4c..fcdd0a2a33b 100644 --- a/spec/workers/ci/initial_pipeline_process_worker_spec.rb +++ b/spec/workers/ci/initial_pipeline_process_worker_spec.rb @@ -70,32 +70,6 @@ RSpec.describe Ci::InitialPipelineProcessWorker, feature_category: :continuous_i subject end - - context 'when `create_deployment_only_for_processable_jobs` FF is disabled' do - before do - stub_feature_flags(create_deployment_only_for_processable_jobs: false) - end - - it 'creates a deployment record' do - expect { subject }.to change { Deployment.count }.by(1) - - expect(job.deployment).to have_attributes( - project: job.project, - ref: job.ref, - sha: job.sha, - deployable: job, - deployable_type: 'CommitStatus', - environment: job.persisted_environment - ) - end - - it 'a deployment is created before atomic processing is kicked off' do - expect(::Deployments::CreateForJobService).to receive(:new).ordered - expect(::Ci::PipelineProcessing::AtomicProcessingService).to receive(:new).ordered - - subject - end - end end end end |