diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2020-07-17 06:09:11 +0000 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2020-07-17 06:09:11 +0000 |
commit | 6110935892876a26d8dfcb919d8c955c92ecc1e5 (patch) | |
tree | 09c4393c8eb9d3807df842d6d707fb319ffbd7ea | |
parent | 4bc0e064023a13d90da5acc4fd152fca66926ea2 (diff) | |
download | gitlab-ce-6110935892876a26d8dfcb919d8c955c92ecc1e5.tar.gz |
Add latest changes from gitlab-org/gitlab@master
135 files changed, 10944 insertions, 429 deletions
diff --git a/.gitlab/ci/docs.gitlab-ci.yml b/.gitlab/ci/docs.gitlab-ci.yml index 5a6f2aacf93..8745e7d8e9e 100644 --- a/.gitlab/ci/docs.gitlab-ci.yml +++ b/.gitlab/ci/docs.gitlab-ci.yml @@ -59,6 +59,15 @@ docs lint: # Check the internal anchor links - bundle exec nanoc check internal_anchors +ui-docs-links lint: + extends: + - .docs:rules:docs-lint + - .static-analysis-base + stage: test + needs: [] + script: + - bundle exec haml-lint -i DocumentationLinks + graphql-reference-verify: extends: - .default-retry diff --git a/.haml-lint.yml b/.haml-lint.yml index b6b0c63f286..4adb5e62f88 100644 --- a/.haml-lint.yml +++ b/.haml-lint.yml @@ -28,7 +28,7 @@ linters: max_consecutive: 2 DocumentationLinks: - enabled: false + enabled: true include: - 'app/views/**/*.haml' - 'ee/app/views/**/*.haml' diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml index 99754b77322..43801e2733b 100644 --- a/.rubocop_todo.yml +++ b/.rubocop_todo.yml @@ -790,8 +790,8 @@ Style/RedundantSelf: # Cop supports --auto-correct. Style/RedundantSort: Exclude: - - 'ee/app/presenters/packages/nuget/search_results_presenter.rb' - - 'ee/spec/presenters/packages/nuget/search_results_presenter_spec.rb' + - 'app/presenters/packages/nuget/search_results_presenter.rb' + - 'spec/presenters/packages/nuget/search_results_presenter_spec.rb' # Offense count: 120 # Cop supports --auto-correct. @@ -976,21 +976,17 @@ Rails/SaveBang: - 'ee/spec/policies/vulnerabilities/feedback_policy_spec.rb' - 'ee/spec/presenters/audit_event_presenter_spec.rb' - 'ee/spec/presenters/epic_presenter_spec.rb' - - 'ee/spec/presenters/packages/conan/package_presenter_spec.rb' - 'ee/spec/requests/api/boards_spec.rb' - - 'ee/spec/requests/api/conan_packages_spec.rb' - 'ee/spec/requests/api/epic_issues_spec.rb' - 'ee/spec/requests/api/epic_links_spec.rb' - 'ee/spec/requests/api/epics_spec.rb' - 'ee/spec/requests/api/geo_nodes_spec.rb' - 'ee/spec/requests/api/geo_spec.rb' - - 'ee/spec/requests/api/go_proxy_spec.rb' - 'ee/spec/requests/api/graphql/group/epics_spec.rb' - 'ee/spec/requests/api/graphql/mutations/epic_tree/reorder_spec.rb' - 'ee/spec/requests/api/groups_spec.rb' - 'ee/spec/requests/api/issues_spec.rb' - 'ee/spec/requests/api/ldap_group_links_spec.rb' - - 'ee/spec/requests/api/maven_packages_spec.rb' - 'ee/spec/requests/api/merge_request_approval_rules_spec.rb' - 'ee/spec/requests/api/merge_request_approvals_spec.rb' - 'ee/spec/requests/api/merge_requests_spec.rb' @@ -1059,9 +1055,7 @@ Rails/SaveBang: - 'ee/spec/support/shared_examples/models/mentionable_shared_examples.rb' - 'ee/spec/support/shared_examples/policies/protected_environments_shared_examples.rb' - 'ee/spec/support/shared_examples/requests/api/graphql/geo/registries_shared_examples.rb' - - 'ee/spec/support/shared_examples/requests/api/nuget_packages_shared_examples.rb' - 'ee/spec/support/shared_examples/requests/api/project_approval_rules_api_shared_examples.rb' - - 'ee/spec/support/shared_examples/requests/api/pypi_packages_shared_examples.rb' - 'ee/spec/support/shared_examples/services/build_execute_shared_examples.rb' - 'ee/spec/support/shared_examples/services/issue_epic_shared_examples.rb' - 'ee/spec/views/layouts/nav/sidebar/_project.html.haml_spec.rb' @@ -1434,13 +1428,16 @@ Rails/SaveBang: - 'spec/policies/project_policy_spec.rb' - 'spec/presenters/ci/build_runner_presenter_spec.rb' - 'spec/presenters/ci/trigger_presenter_spec.rb' + - 'spec/presenters/packages/conan/package_presenter_spec.rb' - 'spec/requests/api/access_requests_spec.rb' - 'spec/requests/api/boards_spec.rb' - 'spec/requests/api/branches_spec.rb' - 'spec/requests/api/ci/runner_spec.rb' - 'spec/requests/api/commit_statuses_spec.rb' + - 'spec/requests/api/conan_packages_spec.rb' - 'spec/requests/api/deployments_spec.rb' - 'spec/requests/api/environments_spec.rb' + - 'spec/requests/api/go_proxy_spec.rb' - 'spec/requests/api/graphql/mutations/merge_requests/set_labels_spec.rb' - 'spec/requests/api/graphql/user_query_spec.rb' - 'spec/requests/api/graphql_spec.rb' @@ -1451,6 +1448,7 @@ Rails/SaveBang: - 'spec/requests/api/issues/post_projects_issues_spec.rb' - 'spec/requests/api/jobs_spec.rb' - 'spec/requests/api/labels_spec.rb' + - 'spec/requests/api/maven_packages_spec.rb' - 'spec/requests/api/members_spec.rb' - 'spec/requests/api/merge_request_diffs_spec.rb' - 'spec/requests/api/merge_requests_spec.rb' @@ -1573,6 +1571,8 @@ Rails/SaveBang: - 'spec/support/shared_examples/requests/api/award_emoji_todo_shared_examples.rb' - 'spec/support/shared_examples/requests/api/boards_shared_examples.rb' - 'spec/support/shared_examples/requests/api/custom_attributes_shared_examples.rb' + - 'spec/support/shared_examples/requests/api/nuget_packages_shared_examples.rb' + - 'spec/support/shared_examples/requests/api/pypi_packages_shared_examples.rb' - 'spec/support/shared_examples/serializers/note_entity_shared_examples.rb' - 'spec/support/shared_examples/services/common_system_notes_shared_examples.rb' - 'spec/support/shared_examples/services/issuable_shared_examples.rb' diff --git a/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals.vue b/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals.vue new file mode 100644 index 00000000000..0f9d1b8395b --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals.vue @@ -0,0 +1,212 @@ +<script> +import { GlButton } from '@gitlab/ui'; +import createFlash from '~/flash'; +import { s__ } from '~/locale'; +import eventHub from '../../event_hub'; +import approvalsMixin from '../../mixins/approvals'; +import MrWidgetContainer from '../mr_widget_container.vue'; +import MrWidgetIcon from '../mr_widget_icon.vue'; +import ApprovalsSummary from './approvals_summary.vue'; +import ApprovalsSummaryOptional from './approvals_summary_optional.vue'; +import { FETCH_LOADING, FETCH_ERROR, APPROVE_ERROR, UNAPPROVE_ERROR } from './messages'; + +export default { + name: 'MRWidgetApprovals', + components: { + MrWidgetContainer, + MrWidgetIcon, + ApprovalsSummary, + ApprovalsSummaryOptional, + GlButton, + }, + mixins: [approvalsMixin], + props: { + mr: { + type: Object, + required: true, + }, + service: { + type: Object, + required: true, + }, + isOptionalDefault: { + type: Boolean, + required: false, + default: null, + }, + approveDefault: { + type: Function, + required: false, + default: null, + }, + modalId: { + type: String, + required: false, + default: null, + }, + requirePasswordToApprove: { + type: Boolean, + required: false, + default: false, + }, + }, + data() { + return { + fetchingApprovals: true, + hasApprovalAuthError: false, + isApproving: false, + }; + }, + computed: { + isBasic() { + return this.mr.approvalsWidgetType === 'base'; + }, + isApproved() { + return Boolean(this.approvals.approved); + }, + isOptional() { + return this.isOptionalDefault !== null ? this.isOptionalDefault : !this.approvedBy.length; + }, + hasAction() { + return Boolean(this.action); + }, + approvals() { + return this.mr.approvals || {}; + }, + approvedBy() { + return this.approvals.approved_by ? this.approvals.approved_by.map(x => x.user) : []; + }, + userHasApproved() { + return Boolean(this.approvals.user_has_approved); + }, + userCanApprove() { + return Boolean(this.approvals.user_can_approve); + }, + showApprove() { + return !this.userHasApproved && this.userCanApprove && this.mr.isOpen; + }, + showUnapprove() { + return this.userHasApproved && !this.userCanApprove && this.mr.state !== 'merged'; + }, + approvalText() { + return this.isApproved && this.approvedBy.length > 0 + ? s__('mrWidget|Approve additionally') + : s__('mrWidget|Approve'); + }, + action() { + // Use the default approve action, only if we aren't using the auth component for it + if (this.showApprove) { + return { + text: this.approvalText, + category: this.isApproved ? 'secondary' : 'primary', + variant: 'info', + action: () => this.approve(), + }; + } else if (this.showUnapprove) { + return { + text: s__('mrWidget|Revoke approval'), + variant: 'warning', + category: 'secondary', + action: () => this.unapprove(), + }; + } + + return null; + }, + }, + created() { + this.refreshApprovals() + .then(() => { + this.fetchingApprovals = false; + }) + .catch(() => createFlash(FETCH_ERROR)); + }, + methods: { + approve() { + if (this.requirePasswordToApprove) { + this.$root.$emit('bv::show::modal', this.modalId); + return; + } + + this.updateApproval( + () => this.service.approveMergeRequest(), + () => createFlash(APPROVE_ERROR), + ); + }, + approveWithAuth(data) { + this.updateApproval( + () => this.service.approveMergeRequestWithAuth(data), + error => { + if (error && error.response && error.response.status === 401) { + this.hasApprovalAuthError = true; + return; + } + createFlash(APPROVE_ERROR); + }, + ); + }, + unapprove() { + this.updateApproval( + () => this.service.unapproveMergeRequest(), + () => createFlash(UNAPPROVE_ERROR), + ); + }, + updateApproval(serviceFn, errFn) { + this.isApproving = true; + this.clearError(); + return serviceFn() + .then(data => { + this.mr.setApprovals(data); + eventHub.$emit('MRWidgetUpdateRequested'); + this.$emit('updated'); + }) + .catch(errFn) + .then(() => { + this.isApproving = false; + }); + }, + }, + FETCH_LOADING, +}; +</script> +<template> + <mr-widget-container> + <div class="js-mr-approvals d-flex align-items-start align-items-md-center"> + <mr-widget-icon name="approval" /> + <div v-if="fetchingApprovals">{{ $options.FETCH_LOADING }}</div> + <template v-else> + <gl-button + v-if="action" + :variant="action.variant" + :category="action.category" + :loading="isApproving" + class="mr-3" + data-qa-selector="approve_button" + @click="action.action" + > + {{ action.text }} + </gl-button> + <approvals-summary-optional + v-if="isOptional" + :can-approve="hasAction" + :help-path="mr.approvalsHelpPath" + /> + <approvals-summary + v-else + :approved="isApproved" + :approvals-left="approvals.approvals_left || 0" + :rules-left="approvals.approvalRuleNamesLeft" + :approvers="approvedBy" + /> + <slot + :is-approving="isApproving" + :approve-with-auth="approveWithAuth" + :hasApproval-auth-error="hasApprovalAuthError" + ></slot> + </template> + </div> + <template #footer> + <slot name="footer"></slot> + </template> + </mr-widget-container> +</template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals_summary.vue b/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals_summary.vue new file mode 100644 index 00000000000..fb342a5d340 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals_summary.vue @@ -0,0 +1,70 @@ +<script> +import { n__, sprintf } from '~/locale'; +import { toNounSeriesText } from '~/lib/utils/grammar'; +import UserAvatarList from '~/vue_shared/components/user_avatar/user_avatar_list.vue'; +import { APPROVED_MESSAGE } from '~/vue_merge_request_widget/components/approvals/messages'; + +export default { + components: { + UserAvatarList, + }, + props: { + approved: { + type: Boolean, + required: true, + }, + approvalsLeft: { + type: Number, + required: true, + }, + rulesLeft: { + type: Array, + required: false, + default: () => [], + }, + approvers: { + type: Array, + required: false, + default: () => [], + }, + }, + computed: { + message() { + if (this.approved) { + return APPROVED_MESSAGE; + } + + if (!this.rulesLeft.length) { + return n__('Requires approval.', 'Requires %d more approvals.', this.approvalsLeft); + } + + return sprintf( + n__( + 'Requires approval from %{names}.', + 'Requires %{count} more approvals from %{names}.', + this.approvalsLeft, + ), + { + names: toNounSeriesText(this.rulesLeft), + count: this.approvalsLeft, + }, + false, + ); + }, + hasApprovers() { + return Boolean(this.approvers.length); + }, + }, + APPROVED_MESSAGE, +}; +</script> + +<template> + <div data-qa-selector="approvals_summary_content"> + <strong>{{ message }}</strong> + <template v-if="hasApprovers"> + <span>{{ s__('mrWidget|Approved by') }}</span> + <user-avatar-list class="d-inline-block align-middle" :items="approvers" /> + </template> + </div> +</template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals_summary_optional.vue b/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals_summary_optional.vue new file mode 100644 index 00000000000..66af0c5a83e --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals_summary_optional.vue @@ -0,0 +1,50 @@ +<script> +import { GlTooltipDirective, GlLink } from '@gitlab/ui'; +import Icon from '~/vue_shared/components/icon.vue'; +import { + OPTIONAL, + OPTIONAL_CAN_APPROVE, +} from '~/vue_merge_request_widget/components/approvals/messages'; + +export default { + components: { + GlLink, + Icon, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + canApprove: { + type: Boolean, + required: true, + }, + helpPath: { + type: String, + required: false, + default: '', + }, + }, + computed: { + message() { + return this.canApprove ? OPTIONAL_CAN_APPROVE : OPTIONAL; + }, + }, +}; +</script> + +<template> + <div class="d-flex align-items-center"> + <span class="text-muted">{{ message }}</span> + <gl-link + v-if="canApprove && helpPath" + v-gl-tooltip + :href="helpPath" + :title="__('About this feature')" + target="_blank" + class="d-flex-center pl-1" + > + <icon name="question" /> + </gl-link> + </div> +</template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/approvals/messages.js b/app/assets/javascripts/vue_merge_request_widget/components/approvals/messages.js new file mode 100644 index 00000000000..1d9368f71aa --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/approvals/messages.js @@ -0,0 +1,11 @@ +import { __, s__ } from '~/locale'; + +export const FETCH_LOADING = __('Checking approval status'); +export const FETCH_ERROR = s__( + 'mrWidget|An error occurred while retrieving approval data for this merge request.', +); +export const APPROVE_ERROR = s__('mrWidget|An error occurred while submitting your approval.'); +export const UNAPPROVE_ERROR = s__('mrWidget|An error occurred while removing your approval.'); +export const APPROVED_MESSAGE = s__('mrWidget|Merge request approved.'); +export const OPTIONAL_CAN_APPROVE = s__('mrWidget|No approval required; you can still approve'); +export const OPTIONAL = s__('mrWidget|No approval required'); diff --git a/app/assets/javascripts/vue_merge_request_widget/mixins/approvals.js b/app/assets/javascripts/vue_merge_request_widget/mixins/approvals.js new file mode 100644 index 00000000000..e50555ca875 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/mixins/approvals.js @@ -0,0 +1,19 @@ +import { hideFlash } from '~/flash'; + +export default { + methods: { + clearError() { + this.$emit('clearError'); + this.hasApprovalAuthError = false; + const flashEl = document.querySelector('.flash-alert'); + if (flashEl) { + hideFlash(flashEl); + } + }, + refreshApprovals() { + return this.service.fetchApprovals().then(data => { + this.mr.setApprovals(data); + }); + }, + }, +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue index 9fc40ce48cd..cff85fe232d 100644 --- a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue +++ b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue @@ -2,6 +2,7 @@ import { isEmpty } from 'lodash'; import MRWidgetStore from 'ee_else_ce/vue_merge_request_widget/stores/mr_widget_store'; import MRWidgetService from 'ee_else_ce/vue_merge_request_widget/services/mr_widget_service'; +import MrWidgetApprovals from 'ee_else_ce/vue_merge_request_widget/components/approvals/approvals.vue'; import stateMaps from 'ee_else_ce/vue_merge_request_widget/stores/state_maps'; import { sprintf, s__, __ } from '~/locale'; import Project from '~/pages/projects/project'; @@ -80,6 +81,7 @@ export default { GroupedTestReportsApp, TerraformPlan, GroupedAccessibilityReportsApp, + MrWidgetApprovals, }, props: { mrData: { @@ -98,6 +100,9 @@ export default { }; }, computed: { + shouldRenderApprovals() { + return this.mr.state !== 'nothingToMerge'; + }, componentName() { return stateMaps.stateToComponentMap[this.mr.state]; }, @@ -221,6 +226,9 @@ export default { mergeRequestCachedWidgetPath: store.mergeRequestCachedWidgetPath, mergeActionsContentPath: store.mergeActionsContentPath, rebasePath: store.rebasePath, + apiApprovalsPath: store.apiApprovalsPath, + apiApprovePath: store.apiApprovePath, + apiUnapprovePath: store.apiUnapprovePath, }; }, createService(store) { @@ -384,6 +392,12 @@ export default { class="mr-widget-workflow" :mr="mr" /> + <mr-widget-approvals + v-if="shouldRenderApprovals" + class="mr-widget-workflow" + :mr="mr" + :service="service" + /> <div class="mr-section-container mr-widget-workflow"> <grouped-codequality-reports-app v-if="shouldRenderCodeQuality" diff --git a/app/assets/javascripts/vue_merge_request_widget/services/mr_widget_service.js b/app/assets/javascripts/vue_merge_request_widget/services/mr_widget_service.js index c620023a6d6..ee9e3cc6d08 100644 --- a/app/assets/javascripts/vue_merge_request_widget/services/mr_widget_service.js +++ b/app/assets/javascripts/vue_merge_request_widget/services/mr_widget_service.js @@ -3,6 +3,10 @@ import axios from '../../lib/utils/axios_utils'; export default class MRWidgetService { constructor(endpoints) { this.endpoints = endpoints; + + this.apiApprovalsPath = endpoints.apiApprovalsPath; + this.apiApprovePath = endpoints.apiApprovePath; + this.apiUnapprovePath = endpoints.apiUnapprovePath; } merge(data) { @@ -54,6 +58,18 @@ export default class MRWidgetService { return axios.post(this.endpoints.rebasePath); } + fetchApprovals() { + return axios.get(this.apiApprovalsPath).then(res => res.data); + } + + approveMergeRequest() { + return axios.post(this.apiApprovePath).then(res => res.data); + } + + unapproveMergeRequest() { + return axios.post(this.apiUnapprovePath).then(res => res.data); + } + static executeInlineAction(url) { return axios.post(url); } diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js b/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js index 3ca92fc64c6..8b9799d9775 100644 --- a/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js +++ b/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js @@ -9,12 +9,19 @@ export default class MergeRequestStore { this.sha = data.diff_head_sha; this.gitlabLogo = data.gitlabLogo; + this.apiApprovalsPath = data.api_approvals_path; + this.apiApprovePath = data.api_approve_path; + this.apiUnapprovePath = data.api_unapprove_path; + this.hasApprovalsAvailable = data.has_approvals_available; + this.setPaths(data); this.setData(data); } setData(data, isRebased) { + this.initApprovals(); + if (isRebased) { this.sha = data.diff_head_sha; } @@ -52,6 +59,7 @@ export default class MergeRequestStore { this.squashCommitMessage = data.default_squash_commit_message; this.rebaseInProgress = data.rebase_in_progress; this.mergeRequestDiffsPath = data.diffs_path; + this.approvalsWidgetType = data.approvals_widget_type; if (data.issues_links) { const links = data.issues_links; @@ -181,6 +189,7 @@ export default class MergeRequestStore { this.securityApprovalsHelpPagePath = data.security_approvals_help_page_path; this.eligibleApproversDocsPath = data.eligible_approvers_docs_path; this.mergeImmediatelyDocsPath = data.merge_immediately_docs_path; + this.approvalsHelpPath = data.approvals_help_path; this.mergeRequestAddCiConfigPath = data.merge_request_add_ci_config_path; this.pipelinesEmptySvgPath = data.pipelines_empty_svg_path; this.humanAccess = data.human_access; @@ -251,4 +260,14 @@ export default class MergeRequestStore { return undefined; } + + initApprovals() { + this.isApproved = this.isApproved || false; + this.approvals = this.approvals || null; + } + + setApprovals(data) { + this.approvals = data; + this.isApproved = data.approved || false; + } } diff --git a/app/assets/stylesheets/utilities.scss b/app/assets/stylesheets/utilities.scss index 9085af0ff1c..38842ec167e 100644 --- a/app/assets/stylesheets/utilities.scss +++ b/app/assets/stylesheets/utilities.scss @@ -113,3 +113,11 @@ .gl-top-66vh { top: 66vh; } + +// Remove when https://gitlab.com/gitlab-org/gitlab-ui/-/issues/871 +// gets fixed on GitLab UI +.gl-sm-w-auto\! { + @media (min-width: $breakpoint-sm) { + width: auto !important; + } +} diff --git a/app/controllers/concerns/issuable_collections.rb b/app/controllers/concerns/issuable_collections.rb index e44a32c2702..4f61e5ed711 100644 --- a/app/controllers/concerns/issuable_collections.rb +++ b/app/controllers/concerns/issuable_collections.rb @@ -149,7 +149,10 @@ module IssuableCollections when 'Issue' common_attributes + [:project, project: :namespace] when 'MergeRequest' - common_attributes + [:target_project, :latest_merge_request_diff, source_project: :route, head_pipeline: :project, target_project: :namespace] + common_attributes + [ + :target_project, :latest_merge_request_diff, :approvals, :approved_by_users, + source_project: :route, head_pipeline: :project, target_project: :namespace + ] end end # rubocop:enable Gitlab/ModuleWithInstanceVariables diff --git a/app/graphql/resolvers/packages_resolver.rb b/app/graphql/resolvers/packages_resolver.rb new file mode 100644 index 00000000000..519fb87183e --- /dev/null +++ b/app/graphql/resolvers/packages_resolver.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Resolvers + class PackagesResolver < BaseResolver + type Types::PackageType, null: true + + def resolve(**args) + return unless packages_available? + + ::Packages::PackagesFinder.new(object).execute + end + + private + + def packages_available? + ::Gitlab.config.packages.enabled + end + end +end diff --git a/app/graphql/types/package_type.rb b/app/graphql/types/package_type.rb new file mode 100644 index 00000000000..0604bf827a5 --- /dev/null +++ b/app/graphql/types/package_type.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module Types + class PackageType < BaseObject + graphql_name 'Package' + description 'Represents a package' + authorize :read_package + + field :id, GraphQL::ID_TYPE, null: false, description: 'The ID of the package' + field :name, GraphQL::STRING_TYPE, null: false, description: 'The name of the package' + field :created_at, Types::TimeType, null: false, description: 'The created date' + field :updated_at, Types::TimeType, null: false, description: 'The update date' + field :version, GraphQL::STRING_TYPE, null: true, description: 'The version of the package' + field :package_type, Types::PackageTypeEnum, null: false, description: 'The type of the package' + end +end diff --git a/app/graphql/types/package_type_enum.rb b/app/graphql/types/package_type_enum.rb new file mode 100644 index 00000000000..bc03b8f5f8b --- /dev/null +++ b/app/graphql/types/package_type_enum.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module Types + class PackageTypeEnum < BaseEnum + ::Packages::Package.package_types.keys.each do |package_type| + value package_type.to_s.upcase, "Packages from the #{package_type} package manager", value: package_type.to_s + end + end +end diff --git a/app/graphql/types/project_type.rb b/app/graphql/types/project_type.rb index 1cff9cb7ac7..2251a0f4e0c 100644 --- a/app/graphql/types/project_type.rb +++ b/app/graphql/types/project_type.rb @@ -169,6 +169,10 @@ module Types description: 'A single issue of the project', resolver: Resolvers::IssuesResolver.single + field :packages, Types::PackageType.connection_type, null: true, + description: 'Packages of the project', + resolver: Resolvers::PackagesResolver + field :pipelines, Types::Ci::PipelineType.connection_type, null: true, diff --git a/app/models/ci/job_artifact.rb b/app/models/ci/job_artifact.rb index 1f207eaf370..dbeba1ece31 100644 --- a/app/models/ci/job_artifact.rb +++ b/app/models/ci/job_artifact.rb @@ -245,6 +245,12 @@ module Ci self.update_column(:file_store, file.object_store) end + def self.associated_file_types_for(file_type) + return unless file_types.include?(file_type) + + [file_type] + end + def self.total_size self.sum(:size) end diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb index fc144ac5b86..d4b439d648f 100644 --- a/app/models/ci/pipeline.rb +++ b/app/models/ci/pipeline.rb @@ -624,6 +624,38 @@ module Ci end end + def batch_lookup_report_artifact_for_file_type(file_type) + latest_report_artifacts + .values_at(*::Ci::JobArtifact.associated_file_types_for(file_type.to_s)) + .flatten + .compact + .last + end + + # This batch loads the latest reports for each CI job artifact + # type (e.g. sast, dast, etc.) in a single SQL query to eliminate + # the need to do N different `job_artifacts.where(file_type: + # X).last` calls. + # + # Return a hash of file type => array of 1 job artifact + def latest_report_artifacts + ::Gitlab::SafeRequestStore.fetch("pipeline:#{self.id}:latest_report_artifacts") do + # Note we use read_attribute(:project_id) to read the project + # ID instead of self.project_id. The latter appears to load + # the Project model. This extra filter doesn't appear to + # affect query plan but included to ensure we don't leak the + # wrong informaiton. + ::Ci::JobArtifact.where( + id: job_artifacts.with_reports + .select('max(ci_job_artifacts.id) as id') + .where(project_id: self.read_attribute(:project_id)) + .group(:file_type) + ) + .preload(:job) + .group_by(&:file_type) + end + end + def has_kubernetes_active? project.deployment_platform&.active? end diff --git a/app/models/packages/package_file.rb b/app/models/packages/package_file.rb index 9b412cd6d6a..567b5a14603 100644 --- a/app/models/packages/package_file.rb +++ b/app/models/packages/package_file.rb @@ -45,7 +45,7 @@ class Packages::PackageFile < ApplicationRecord end def download_path - Gitlab::Routing.url_helpers.download_project_package_file_path(project, self) + Gitlab::Routing.url_helpers.download_project_package_file_path(project, self) if ::Gitlab.ee? end def local? diff --git a/app/presenters/ci/pipeline_presenter.rb b/app/presenters/ci/pipeline_presenter.rb index 395eaeea8de..da610f13899 100644 --- a/app/presenters/ci/pipeline_presenter.rb +++ b/app/presenters/ci/pipeline_presenter.rb @@ -110,6 +110,17 @@ module Ci merge_request_presenter&.target_branch_link end + def downloadable_path_for_report_type(file_type) + if (job_artifact = batch_lookup_report_artifact_for_file_type(file_type)) && + can?(current_user, :read_build, job_artifact.job) + download_project_job_artifacts_path( + job_artifact.project, + job_artifact.job, + file_type: file_type, + proxy: true) + end + end + private def plain_ref_name diff --git a/app/presenters/packages/composer/packages_presenter.rb b/app/presenters/packages/composer/packages_presenter.rb new file mode 100644 index 00000000000..84f266989e9 --- /dev/null +++ b/app/presenters/packages/composer/packages_presenter.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +module Packages + module Composer + class PackagesPresenter + include API::Helpers::RelatedResourcesHelpers + + def initialize(group, packages) + @group = group + @packages = packages + end + + def root + path = api_v4_group___packages_composer_package_name_path({ id: @group.id, package_name: '%package%', format: '.json' }, true) + { 'packages' => [], 'provider-includes' => { 'p/%hash%.json' => { 'sha256' => provider_sha } }, 'providers-url' => path } + end + + def provider + { 'providers' => providers_map } + end + + def package_versions(packages = @packages) + { 'packages' => { packages.first.name => package_versions_map(packages) } } + end + + private + + def package_versions_map(packages) + packages.each_with_object({}) do |package, map| + map[package.version] = package_metadata(package) + end + end + + def package_metadata(package) + json = package.composer_metadatum.composer_json + + json.merge('dist' => package_dist(package), 'uid' => package.id, 'version' => package.version) + end + + def package_dist(package) + sha = package.composer_metadatum.target_sha + archive_api_path = api_v4_projects_packages_composer_archives_package_name_path({ id: package.project_id, package_name: package.name, format: '.zip' }, true) + + { + 'type' => 'zip', + 'url' => expose_url(archive_api_path) + "?sha=#{sha}", + 'reference' => sha, + 'shasum' => '' + } + end + + def providers_map + map = {} + + @packages.group_by(&:name).each_pair do |package_name, packages| + map[package_name] = { 'sha256' => package_versions_sha(packages) } + end + + map + end + + def package_versions_sha(packages) + Digest::SHA256.hexdigest(package_versions(packages).to_json) + end + + def provider_sha + Digest::SHA256.hexdigest(provider.to_json) + end + end + end +end diff --git a/app/presenters/packages/conan/package_presenter.rb b/app/presenters/packages/conan/package_presenter.rb new file mode 100644 index 00000000000..5141c450412 --- /dev/null +++ b/app/presenters/packages/conan/package_presenter.rb @@ -0,0 +1,114 @@ +# frozen_string_literal: true + +module Packages + module Conan + class PackagePresenter + include API::Helpers::RelatedResourcesHelpers + include Gitlab::Utils::StrongMemoize + + attr_reader :params + + def initialize(recipe, user, project, params = {}) + @recipe = recipe + @user = user + @project = project + @params = params + end + + def recipe_urls + map_package_files do |package_file| + build_recipe_file_url(package_file) if package_file.conan_file_metadatum.recipe_file? + end + end + + def recipe_snapshot + map_package_files do |package_file| + package_file.file_md5 if package_file.conan_file_metadatum.recipe_file? + end + end + + def package_urls + map_package_files do |package_file| + next unless package_file.conan_file_metadatum.package_file? && matching_reference?(package_file) + + build_package_file_url(package_file) + end + end + + def package_snapshot + map_package_files do |package_file| + next unless package_file.conan_file_metadatum.package_file? && matching_reference?(package_file) + + package_file.file_md5 + end + end + + private + + def build_recipe_file_url(package_file) + expose_url( + api_v4_packages_conan_v1_files_export_path( + package_name: package.name, + package_version: package.version, + package_username: package.conan_metadatum.package_username, + package_channel: package.conan_metadatum.package_channel, + recipe_revision: package_file.conan_file_metadatum.recipe_revision, + file_name: package_file.file_name + ) + ) + end + + def build_package_file_url(package_file) + expose_url( + api_v4_packages_conan_v1_files_package_path( + package_name: package.name, + package_version: package.version, + package_username: package.conan_metadatum.package_username, + package_channel: package.conan_metadatum.package_channel, + recipe_revision: package_file.conan_file_metadatum.recipe_revision, + conan_package_reference: package_file.conan_file_metadatum.conan_package_reference, + package_revision: package_file.conan_file_metadatum.package_revision, + file_name: package_file.file_name + ) + ) + end + + def map_package_files + package_files.to_a.map do |package_file| + key = package_file.file_name + value = yield(package_file) + next unless key && value + + [key, value] + end.compact.to_h + end + + def package_files + return unless package + + @package_files ||= package.package_files.preload_conan_file_metadata + end + + def package + strong_memoize(:package) do + name, version = @recipe.split('@')[0].split('/') + + @project.packages + .conan + .with_name(name) + .with_version(version) + .order_created + .last + end + end + + def matching_reference?(package_file) + package_file.conan_file_metadatum.conan_package_reference == conan_package_reference + end + + def conan_package_reference + params[:conan_package_reference] + end + end + end +end diff --git a/app/presenters/packages/detail/package_presenter.rb b/app/presenters/packages/detail/package_presenter.rb new file mode 100644 index 00000000000..f6e068302c1 --- /dev/null +++ b/app/presenters/packages/detail/package_presenter.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +module Packages + module Detail + class PackagePresenter + def initialize(package) + @package = package + end + + def detail_view + package_detail = { + id: @package.id, + created_at: @package.created_at, + name: @package.name, + package_files: @package.package_files.map { |pf| build_package_file_view(pf) }, + package_type: @package.package_type, + project_id: @package.project_id, + tags: @package.tags.as_json, + updated_at: @package.updated_at, + version: @package.version + } + + package_detail[:maven_metadatum] = @package.maven_metadatum if @package.maven_metadatum + package_detail[:nuget_metadatum] = @package.nuget_metadatum if @package.nuget_metadatum + package_detail[:dependency_links] = @package.dependency_links.map(&method(:build_dependency_links)) + package_detail[:pipeline] = build_pipeline_info(@package.build_info.pipeline) if @package.build_info + + package_detail + end + + private + + def build_package_file_view(package_file) + { + created_at: package_file.created_at, + download_path: package_file.download_path, + file_name: package_file.file_name, + size: package_file.size + } + end + + def build_pipeline_info(pipeline_info) + { + created_at: pipeline_info.created_at, + id: pipeline_info.id, + sha: pipeline_info.sha, + ref: pipeline_info.ref, + git_commit_message: pipeline_info.git_commit_message, + user: build_user_info(pipeline_info.user), + project: { + name: pipeline_info.project.name, + web_url: pipeline_info.project.web_url + } + } + end + + def build_user_info(user) + return unless user + + { + avatar_url: user.avatar_url, + name: user.name + } + end + + def build_dependency_links(link) + { + name: link.dependency.name, + version_pattern: link.dependency.version_pattern, + target_framework: link.nuget_metadatum&.target_framework + }.compact + end + end + end +end diff --git a/app/presenters/packages/go/module_version_presenter.rb b/app/presenters/packages/go/module_version_presenter.rb new file mode 100644 index 00000000000..4c86eae46cd --- /dev/null +++ b/app/presenters/packages/go/module_version_presenter.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Packages + module Go + class ModuleVersionPresenter + def initialize(version) + @version = version + end + + def name + @version.name + end + + def time + @version.commit.committed_date + end + end + end +end diff --git a/app/presenters/packages/npm/package_presenter.rb b/app/presenters/packages/npm/package_presenter.rb new file mode 100644 index 00000000000..a3ab10d3913 --- /dev/null +++ b/app/presenters/packages/npm/package_presenter.rb @@ -0,0 +1,87 @@ +# frozen_string_literal: true + +module Packages + module Npm + class PackagePresenter + include API::Helpers::RelatedResourcesHelpers + + attr_reader :name, :packages + + NPM_VALID_DEPENDENCY_TYPES = %i[dependencies devDependencies bundleDependencies peerDependencies].freeze + + def initialize(name, packages) + @name = name + @packages = packages + end + + def versions + package_versions = {} + + packages.each do |package| + package_file = package.package_files.last + + next unless package_file + + package_versions[package.version] = build_package_version(package, package_file) + end + + package_versions + end + + def dist_tags + build_package_tags.tap { |t| t["latest"] ||= sorted_versions.last } + end + + private + + def build_package_tags + Hash[ + package_tags.map { |tag| [tag.name, tag.package.version] } + ] + end + + def build_package_version(package, package_file) + { + name: package.name, + version: package.version, + dist: { + shasum: package_file.file_sha1, + tarball: tarball_url(package, package_file) + } + }.tap do |package_version| + package_version.merge!(build_package_dependencies(package)) + end + end + + def tarball_url(package, package_file) + expose_url "#{api_v4_projects_path(id: package.project_id)}" \ + "/packages/npm/#{package.name}" \ + "/-/#{package_file.file_name}" + end + + def build_package_dependencies(package) + dependencies = Hash.new { |h, key| h[key] = {} } + dependency_links = package.dependency_links + .with_dependency_type(NPM_VALID_DEPENDENCY_TYPES) + .includes_dependency + + dependency_links.find_each do |dependency_link| + dependency = dependency_link.dependency + dependencies[dependency_link.dependency_type][dependency.name] = dependency.version_pattern + end + + dependencies + end + + def sorted_versions + versions = packages.map(&:version).compact + VersionSorter.sort(versions) + end + + def package_tags + Packages::Tag.for_packages(packages) + .preload_package + end + end + end +end diff --git a/app/presenters/packages/nuget/package_metadata_presenter.rb b/app/presenters/packages/nuget/package_metadata_presenter.rb new file mode 100644 index 00000000000..500fc982e11 --- /dev/null +++ b/app/presenters/packages/nuget/package_metadata_presenter.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Packages + module Nuget + class PackageMetadataPresenter + include Packages::Nuget::PresenterHelpers + + def initialize(package) + @package = package + end + + def json_url + json_url_for(@package) + end + + def archive_url + archive_url_for(@package) + end + + def catalog_entry + catalog_entry_for(@package) + end + end + end +end diff --git a/app/presenters/packages/nuget/packages_metadata_presenter.rb b/app/presenters/packages/nuget/packages_metadata_presenter.rb new file mode 100644 index 00000000000..5f22d5dd8a1 --- /dev/null +++ b/app/presenters/packages/nuget/packages_metadata_presenter.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +module Packages + module Nuget + class PackagesMetadataPresenter + include Packages::Nuget::PresenterHelpers + include Gitlab::Utils::StrongMemoize + + COUNT = 1.freeze + + def initialize(packages) + @packages = packages + end + + def count + COUNT + end + + def items + [summary] + end + + private + + def summary + { + json_url: json_url, + lower_version: lower_version, + upper_version: upper_version, + packages_count: @packages.count, + packages: @packages.map { |pkg| metadata_for(pkg) } + } + end + + def metadata_for(package) + { + json_url: json_url_for(package), + archive_url: archive_url_for(package), + catalog_entry: catalog_entry_for(package) + } + end + + def json_url + json_url_for(@packages.first) + end + + def lower_version + sorted_versions.first + end + + def upper_version + sorted_versions.last + end + + def sorted_versions + strong_memoize(:sorted_versions) do + versions = @packages.map(&:version).compact + VersionSorter.sort(versions) + end + end + end + end +end diff --git a/app/presenters/packages/nuget/packages_versions_presenter.rb b/app/presenters/packages/nuget/packages_versions_presenter.rb new file mode 100644 index 00000000000..7f4ce4dbb2f --- /dev/null +++ b/app/presenters/packages/nuget/packages_versions_presenter.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Packages + module Nuget + class PackagesVersionsPresenter + def initialize(packages) + @packages = packages + end + + def versions + @packages.pluck_versions.sort + end + end + end +end diff --git a/app/presenters/packages/nuget/presenter_helpers.rb b/app/presenters/packages/nuget/presenter_helpers.rb new file mode 100644 index 00000000000..cc7e8619220 --- /dev/null +++ b/app/presenters/packages/nuget/presenter_helpers.rb @@ -0,0 +1,113 @@ +# frozen_string_literal: true + +module Packages + module Nuget + module PresenterHelpers + include ::API::Helpers::RelatedResourcesHelpers + + BLANK_STRING = '' + PACKAGE_DEPENDENCY_GROUP = 'PackageDependencyGroup' + PACKAGE_DEPENDENCY = 'PackageDependency' + + private + + def json_url_for(package) + path = api_v4_projects_packages_nuget_metadata_package_name_package_version_path( + { + id: package.project_id, + package_name: package.name, + package_version: package.version, + format: '.json' + }, + true + ) + + expose_url(path) + end + + def archive_url_for(package) + path = api_v4_projects_packages_nuget_download_package_name_package_version_package_filename_path( + { + id: package.project_id, + package_name: package.name, + package_version: package.version, + package_filename: package.package_files.last&.file_name + }, + true + ) + + expose_url(path) + end + + def catalog_entry_for(package) + { + json_url: json_url_for(package), + authors: BLANK_STRING, + dependency_groups: dependency_groups_for(package), + package_name: package.name, + package_version: package.version, + archive_url: archive_url_for(package), + summary: BLANK_STRING, + tags: tags_for(package), + metadatum: metadatum_for(package) + } + end + + def dependency_groups_for(package) + base_nuget_id = "#{json_url_for(package)}#dependencyGroup" + + dependency_links_grouped_by_target_framework(package).map do |target_framework, dependency_links| + nuget_id = target_framework_nuget_id(base_nuget_id, target_framework) + { + id: nuget_id, + type: PACKAGE_DEPENDENCY_GROUP, + target_framework: target_framework, + dependencies: dependencies_for(nuget_id, dependency_links) + }.compact + end + end + + def dependency_links_grouped_by_target_framework(package) + package + .dependency_links + .includes_dependency + .preload_nuget_metadatum + .group_by { |dependency_link| dependency_link.nuget_metadatum&.target_framework } + end + + def dependencies_for(nuget_id, dependency_links) + return [] if dependency_links.empty? + + dependency_links.map do |dependency_link| + dependency = dependency_link.dependency + { + id: "#{nuget_id}/#{dependency.name.downcase}", + type: PACKAGE_DEPENDENCY, + name: dependency.name, + range: dependency.version_pattern + } + end + end + + def target_framework_nuget_id(base_nuget_id, target_framework) + target_framework.blank? ? base_nuget_id : "#{base_nuget_id}/#{target_framework.downcase}" + end + + def metadatum_for(package) + metadatum = package.nuget_metadatum + return {} unless metadatum + + metadatum.slice(:project_url, :license_url, :icon_url) + .compact + end + + def base_path_for(package) + api_v4_projects_packages_nuget_path(id: package.project_id) + end + + def tags_for(package) + package.tag_names.join(::Packages::Tag::NUGET_TAGS_SEPARATOR) + end + end + end +end diff --git a/app/presenters/packages/nuget/search_results_presenter.rb b/app/presenters/packages/nuget/search_results_presenter.rb new file mode 100644 index 00000000000..96c8fe7dd2a --- /dev/null +++ b/app/presenters/packages/nuget/search_results_presenter.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +module Packages + module Nuget + class SearchResultsPresenter + include Packages::Nuget::PresenterHelpers + include Gitlab::Utils::StrongMemoize + + delegate :total_count, to: :@search + + def initialize(search) + @search = search + @package_versions = {} + end + + def data + strong_memoize(:data) do + @search.results.group_by(&:name).map do |package_name, packages| + latest_version = latest_version(packages) + latest_package = packages.find { |pkg| pkg.version == latest_version } + + { + type: 'Package', + authors: '', + name: package_name, + version: latest_version, + versions: build_package_versions(packages), + summary: '', + total_downloads: 0, + verified: true, + tags: tags_for(latest_package), + metadatum: metadatum_for(latest_package) + } + end + end + end + + private + + def build_package_versions(packages) + packages.map do |pkg| + { + json_url: json_url_for(pkg), + downloads: 0, + version: pkg.version + } + end + end + + def latest_version(packages) + versions = packages.map(&:version).compact + VersionSorter.sort(versions).last # rubocop: disable Style/UnneededSort + end + end + end +end diff --git a/app/presenters/packages/nuget/service_index_presenter.rb b/app/presenters/packages/nuget/service_index_presenter.rb new file mode 100644 index 00000000000..ed00b36b362 --- /dev/null +++ b/app/presenters/packages/nuget/service_index_presenter.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +module Packages + module Nuget + class ServiceIndexPresenter + include API::Helpers::RelatedResourcesHelpers + + SERVICE_VERSIONS = { + download: %w[PackageBaseAddress/3.0.0], + search: %w[SearchQueryService SearchQueryService/3.0.0-beta SearchQueryService/3.0.0-rc], + publish: %w[PackagePublish/2.0.0], + metadata: %w[RegistrationsBaseUrl RegistrationsBaseUrl/3.0.0-beta RegistrationsBaseUrl/3.0.0-rc] + }.freeze + + SERVICE_COMMENTS = { + download: 'Get package content (.nupkg).', + search: 'Filter and search for packages by keyword.', + publish: 'Push and delete (or unlist) packages.', + metadata: 'Get package metadata.' + }.freeze + + VERSION = '3.0.0'.freeze + + def initialize(project) + @project = project + end + + def version + VERSION + end + + def resources + [ + build_service(:download), + build_service(:search), + build_service(:publish), + build_service(:metadata) + ].flatten + end + + private + + def build_service(service_type) + url = build_service_url(service_type) + comment = SERVICE_COMMENTS[service_type] + + SERVICE_VERSIONS[service_type].map do |version| + { :@id => url, :@type => version, :comment => comment } + end + end + + def build_service_url(service_type) + base_path = api_v4_projects_packages_nuget_path(id: @project.id) + + full_path = case service_type + when :download + api_v4_projects_packages_nuget_download_package_name_package_version_package_filename_path( + { + id: @project.id, + package_name: nil, + package_version: nil, + package_filename: nil + }, + true + ) + when :search + "#{base_path}/query" + when :metadata + api_v4_projects_packages_nuget_metadata_package_name_package_version_path( + { + id: @project.id, + package_name: nil, + package_version: nil + }, + true + ) + when :publish + base_path + end + + expose_url(full_path) + end + end + end +end diff --git a/app/presenters/packages/pypi/package_presenter.rb b/app/presenters/packages/pypi/package_presenter.rb new file mode 100644 index 00000000000..4192e974645 --- /dev/null +++ b/app/presenters/packages/pypi/package_presenter.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +# Display package version data acording to PyPi +# Simple API: https://warehouse.pypa.io/api-reference/legacy/#simple-project-api +module Packages + module Pypi + class PackagePresenter + include API::Helpers::RelatedResourcesHelpers + + def initialize(packages, project) + @packages = packages + @project = project + end + + # Returns the HTML body for PyPi simple API. + # Basically a list of package download links for a specific + # package + def body + <<-HTML + <!DOCTYPE html> + <html> + <head> + <title>Links for #{escape(name)}</title> + </head> + <body> + <h1>Links for #{escape(name)}</h1> + #{links} + </body> + </html> + HTML + end + + private + + def links + refs = [] + + @packages.map do |package| + package.package_files.each do |file| + url = build_pypi_package_path(file) + + refs << package_link(url, package.pypi_metadatum.required_python, file.file_name) + end + end + + refs.join + end + + def package_link(url, required_python, filename) + "<a href=\"#{url}\" data-requires-python=\"#{escape(required_python)}\">#{filename}</a><br>" + end + + def build_pypi_package_path(file) + expose_url( + api_v4_projects_packages_pypi_files_file_identifier_path( + { + id: @project.id, + sha256: file.file_sha256, + file_identifier: file.file_name + }, + true + ) + ) + "#sha256=#{file.file_sha256}" + end + + def name + @packages.first.name + end + + def escape(str) + ERB::Util.html_escape(str) + end + end + end +end diff --git a/app/serializers/merge_request_widget_entity.rb b/app/serializers/merge_request_widget_entity.rb index 9a759fcfbb3..2a7afb57314 100644 --- a/app/serializers/merge_request_widget_entity.rb +++ b/app/serializers/merge_request_widget_entity.rb @@ -85,6 +85,26 @@ class MergeRequestWidgetEntity < Grape::Entity end end + expose :blob_path do + expose :head_path, if: -> (mr, _) { mr.source_branch_sha } do |merge_request| + project_blob_path(merge_request.project, merge_request.source_branch_sha) + end + + expose :base_path, if: -> (mr, _) { mr.diff_base_sha } do |merge_request| + project_blob_path(merge_request.project, merge_request.diff_base_sha) + end + end + + expose :codeclimate, if: -> (mr, _) { head_pipeline_downloadable_path_for_report_type(:codequality) } do + expose :head_path do |merge_request| + head_pipeline_downloadable_path_for_report_type(:codequality) + end + + expose :base_path do |merge_request| + base_pipeline_downloadable_path_for_report_type(:codequality) + end + end + private delegate :current_user, to: :request @@ -103,6 +123,16 @@ class MergeRequestWidgetEntity < Grape::Entity can?(current_user, :read_build, merge_request.source_project) && can?(current_user, :create_pipeline, merge_request.source_project) end + + def head_pipeline_downloadable_path_for_report_type(file_type) + object.head_pipeline&.present(current_user: current_user) + &.downloadable_path_for_report_type(file_type) + end + + def base_pipeline_downloadable_path_for_report_type(file_type) + object.base_pipeline&.present(current_user: current_user) + &.downloadable_path_for_report_type(file_type) + end end MergeRequestWidgetEntity.prepend_if_ee('EE::MergeRequestWidgetEntity') diff --git a/app/services/alert_management/alerts/update_service.rb b/app/services/alert_management/alerts/update_service.rb index c6bdbd5a15e..0b7216cd9f8 100644 --- a/app/services/alert_management/alerts/update_service.rb +++ b/app/services/alert_management/alerts/update_service.rb @@ -150,6 +150,8 @@ module AlertManagement end def duplicate_alert? + return if alert.fingerprint.blank? + open_alerts.any? && open_alerts.exclude?(alert) end diff --git a/app/services/labels/transfer_service.rb b/app/services/labels/transfer_service.rb index b1163d81898..a05090d6bfb 100644 --- a/app/services/labels/transfer_service.rb +++ b/app/services/labels/transfer_service.rb @@ -53,7 +53,7 @@ module Labels @group_labels_applied_to_issues ||= Label.joins(:issues) .where( issues: { project_id: project.id }, - labels: { type: 'GroupLabel', group_id: old_group.self_and_ancestors } + labels: { group_id: old_group.self_and_ancestors } ) end # rubocop: enable CodeReuse/ActiveRecord @@ -63,7 +63,7 @@ module Labels @group_labels_applied_to_merge_requests ||= Label.joins(:merge_requests) .where( merge_requests: { target_project_id: project.id }, - labels: { type: 'GroupLabel', group_id: old_group.self_and_ancestors } + labels: { group_id: old_group.self_and_ancestors } ) end # rubocop: enable CodeReuse/ActiveRecord diff --git a/app/views/projects/merge_requests/_approvals_count.html.haml b/app/views/projects/merge_requests/_approvals_count.html.haml new file mode 100644 index 00000000000..464cba1bb2d --- /dev/null +++ b/app/views/projects/merge_requests/_approvals_count.html.haml @@ -0,0 +1,13 @@ +- merge_request = local_assigns.fetch(:merge_request) +- self_approved = merge_request.approved_by?(current_user) +- total = merge_request.approvals.size + +- if total > 0 + - final_text = n_("%d approver", "%d approvers", total) % total + - final_self_text = n_("%d approver (you've approved)", "%d approvers (you've approved)", total) % total + + - approval_icon = sprite_icon((self_approved ? 'approval-solid' : 'approval'), size: 16, css_class: 'align-middle') + + %li.d-none.d-sm-inline-block.has-tooltip.text-success{ title: self_approved ? final_self_text : final_text } + = approval_icon + = _("Approved") diff --git a/app/views/projects/merge_requests/_merge_request.html.haml b/app/views/projects/merge_requests/_merge_request.html.haml index a753ee50c43..d3e98bac7f9 100644 --- a/app/views/projects/merge_requests/_merge_request.html.haml +++ b/app/views/projects/merge_requests/_merge_request.html.haml @@ -55,7 +55,7 @@ - if merge_request.assignees.any? %li.d-flex = render 'shared/issuable/assignees', project: merge_request.project, issuable: merge_request - = render_if_exists 'projects/merge_requests/approvals_count', merge_request: merge_request + = render 'projects/merge_requests/approvals_count', merge_request: merge_request = render 'shared/issuable_meta_data', issuable: merge_request diff --git a/app/views/projects/merge_requests/_widget.html.haml b/app/views/projects/merge_requests/_widget.html.haml index df1fd2a4975..16b08cbf648 100644 --- a/app/views/projects/merge_requests/_widget.html.haml +++ b/app/views/projects/merge_requests/_widget.html.haml @@ -12,6 +12,7 @@ window.gl.mrWidgetData.pipeline_must_succeed_docs_path = '#{help_page_path('user/project/merge_requests/merge_when_pipeline_succeeds.md', anchor: 'only-allow-merge-requests-to-be-merged-if-the-pipeline-succeeds')}'; window.gl.mrWidgetData.security_approvals_help_page_path = '#{help_page_path('user/application_security/index.md', anchor: 'security-approvals-in-merge-requests-ultimate')}'; window.gl.mrWidgetData.eligible_approvers_docs_path = '#{help_page_path('user/project/merge_requests/merge_request_approvals', anchor: 'eligible-approvers')}'; + window.gl.mrWidgetData.approvals_help_path = '#{help_page_path("user/project/merge_requests/merge_request_approvals")}'; window.gl.mrWidgetData.pipelines_empty_svg_path = '#{image_path('illustrations/pipelines_empty.svg')}'; window.gl.mrWidgetData.codequality_help_path = '#{help_page_path("user/project/merge_requests/code_quality", anchor: "code-quality-reports")}'; diff --git a/app/views/projects/services/prometheus/_external_alerts.html.haml b/app/views/projects/services/prometheus/_external_alerts.html.haml index ffc7c37fff0..b27b1ab8723 100644 --- a/app/views/projects/services/prometheus/_external_alerts.html.haml +++ b/app/views/projects/services/prometheus/_external_alerts.html.haml @@ -3,6 +3,6 @@ - notify_url = notify_project_prometheus_alerts_url(@project, format: :json) - authorization_key = @project.alerting_setting.try(:token) -- learn_more_url = help_page_path('operations/metrics/index.md', anchor: 'external-prometheus-instances') +- learn_more_url = help_page_path('operations/metrics/alerts.md', anchor: 'external-prometheus-instances') #js-settings-prometheus-alerts{ data: { notify_url: notify_url, authorization_key: authorization_key, change_key_url: reset_alerting_token_project_settings_operations_path(@project), learn_more_url: learn_more_url, disabled: true } } diff --git a/app/workers/all_queues.yml b/app/workers/all_queues.yml index e9dc138a850..5148772c881 100644 --- a/app/workers/all_queues.yml +++ b/app/workers/all_queues.yml @@ -5,7 +5,7 @@ --- - :name: authorized_project_update:authorized_project_update_project_create :feature_category: :authentication_and_authorization - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 @@ -13,7 +13,7 @@ :tags: [] - :name: authorized_project_update:authorized_project_update_project_group_link_create :feature_category: :authentication_and_authorization - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 @@ -21,7 +21,7 @@ :tags: [] - :name: authorized_project_update:authorized_project_update_user_refresh_over_user_range :feature_category: :authentication_and_authorization - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 @@ -29,7 +29,7 @@ :tags: [] - :name: authorized_project_update:authorized_project_update_user_refresh_with_low_urgency :feature_category: :authentication_and_authorization - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 @@ -37,87 +37,87 @@ :tags: [] - :name: auto_devops:auto_devops_disable :feature_category: :auto_devops - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 2 - :idempotent: + :idempotent: :tags: [] - :name: auto_merge:auto_merge_process :feature_category: :continuous_delivery - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :cpu :weight: 3 - :idempotent: + :idempotent: :tags: [] - :name: chaos:chaos_cpu_spin :feature_category: :not_owned - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 2 - :idempotent: + :idempotent: :tags: [] - :name: chaos:chaos_db_spin :feature_category: :not_owned - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 2 - :idempotent: + :idempotent: :tags: [] - :name: chaos:chaos_kill :feature_category: :not_owned - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 2 - :idempotent: + :idempotent: :tags: [] - :name: chaos:chaos_leak_mem :feature_category: :not_owned - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 2 - :idempotent: + :idempotent: :tags: [] - :name: chaos:chaos_sleep :feature_category: :not_owned - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 2 - :idempotent: + :idempotent: :tags: [] - :name: container_repository:cleanup_container_repository :feature_category: :container_registry - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: container_repository:delete_container_repository :feature_category: :container_registry - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: cronjob:admin_email :feature_category: :source_code_management - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: cronjob:authorized_project_update_periodic_recalculate :feature_category: :source_code_management - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 @@ -125,79 +125,79 @@ :tags: [] - :name: cronjob:ci_archive_traces_cron :feature_category: :continuous_integration - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: cronjob:container_expiration_policy :feature_category: :container_registry - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: cronjob:environments_auto_stop_cron :feature_category: :continuous_delivery - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: cronjob:expire_build_artifacts :feature_category: :continuous_integration - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: cronjob:gitlab_usage_ping :feature_category: :collection - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: cronjob:import_export_project_cleanup :feature_category: :importers - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: cronjob:import_stuck_project_import_jobs :feature_category: :importers - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :cpu :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: cronjob:issue_due_scheduler :feature_category: :issue_tracking - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: cronjob:jira_import_stuck_jira_import_jobs :feature_category: :importers - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :cpu :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: cronjob:metrics_dashboard_schedule_annotations_prune :feature_category: :metrics - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 @@ -205,39 +205,39 @@ :tags: [] - :name: cronjob:namespaces_prune_aggregation_schedules :feature_category: :source_code_management - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :cpu :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: cronjob:pages_domain_removal_cron :feature_category: :pages - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :cpu :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: cronjob:pages_domain_ssl_renewal_cron :feature_category: :pages - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: cronjob:pages_domain_verification_cron :feature_category: :pages - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: cronjob:partition_creation :feature_category: :database - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 @@ -245,127 +245,127 @@ :tags: [] - :name: cronjob:personal_access_tokens_expiring :feature_category: :authentication_and_authorization - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: cronjob:pipeline_schedule :feature_category: :continuous_integration - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :cpu :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: cronjob:prune_old_events :feature_category: :users - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: cronjob:prune_web_hook_logs :feature_category: :integrations - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: cronjob:remove_expired_group_links :feature_category: :authentication_and_authorization - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: cronjob:remove_expired_members :feature_category: :authentication_and_authorization - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :cpu :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: cronjob:remove_unreferenced_lfs_objects :feature_category: :git_lfs - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: cronjob:repository_archive_cache :feature_category: :source_code_management - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: cronjob:repository_check_dispatch :feature_category: :source_code_management - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: cronjob:requests_profiles :feature_category: :source_code_management - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: cronjob:schedule_migrate_external_diffs :feature_category: :source_code_management - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: cronjob:stuck_ci_jobs :feature_category: :continuous_integration - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :cpu :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: cronjob:stuck_export_jobs :feature_category: :importers - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :cpu :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: cronjob:stuck_merge_jobs :feature_category: :source_code_management - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: cronjob:trending_projects :feature_category: :source_code_management - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: cronjob:update_container_registry_info :feature_category: :container_registry - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 @@ -373,11 +373,11 @@ :tags: [] - :name: cronjob:users_create_statistics :feature_category: :users - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: cronjob:x509_issuer_crl_check :feature_category: :source_code_management @@ -389,27 +389,27 @@ :tags: [] - :name: deployment:deployments_finished :feature_category: :continuous_delivery - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :cpu :weight: 3 - :idempotent: + :idempotent: :tags: [] - :name: deployment:deployments_forward_deployment :feature_category: :continuous_delivery - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 3 - :idempotent: + :idempotent: :tags: [] - :name: deployment:deployments_success :feature_category: :continuous_delivery - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :cpu :weight: 3 - :idempotent: + :idempotent: :tags: [] - :name: gcp_cluster:cluster_configure_istio :feature_category: :kubernetes_management @@ -417,7 +417,7 @@ :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: gcp_cluster:cluster_install_app :feature_category: :kubernetes_management @@ -425,7 +425,7 @@ :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: gcp_cluster:cluster_patch_app :feature_category: :kubernetes_management @@ -433,7 +433,7 @@ :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: gcp_cluster:cluster_provision :feature_category: :kubernetes_management @@ -441,15 +441,15 @@ :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: gcp_cluster:cluster_update_app :feature_category: :kubernetes_management - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: gcp_cluster:cluster_upgrade_app :feature_category: :kubernetes_management @@ -457,7 +457,7 @@ :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: gcp_cluster:cluster_wait_for_app_installation :feature_category: :kubernetes_management @@ -465,15 +465,15 @@ :urgency: :low :resource_boundary: :cpu :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: gcp_cluster:cluster_wait_for_app_update :feature_category: :kubernetes_management - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: gcp_cluster:cluster_wait_for_ingress_ip_address :feature_category: :kubernetes_management @@ -481,23 +481,23 @@ :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: gcp_cluster:clusters_applications_activate_service :feature_category: :kubernetes_management - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: gcp_cluster:clusters_applications_deactivate_service :feature_category: :kubernetes_management - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: gcp_cluster:clusters_applications_uninstall :feature_category: :kubernetes_management @@ -505,7 +505,7 @@ :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: gcp_cluster:clusters_applications_wait_for_uninstall_app :feature_category: :kubernetes_management @@ -513,7 +513,7 @@ :urgency: :low :resource_boundary: :cpu :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: gcp_cluster:clusters_cleanup_app :feature_category: :kubernetes_management @@ -521,7 +521,7 @@ :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: gcp_cluster:clusters_cleanup_project_namespace :feature_category: :kubernetes_management @@ -529,7 +529,7 @@ :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: gcp_cluster:clusters_cleanup_service_account :feature_category: :kubernetes_management @@ -537,7 +537,7 @@ :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: gcp_cluster:wait_for_cluster_creation :feature_category: :kubernetes_management @@ -545,7 +545,7 @@ :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: github_importer:github_import_import_diff_note :feature_category: :importers @@ -553,7 +553,7 @@ :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: github_importer:github_import_import_issue :feature_category: :importers @@ -561,7 +561,7 @@ :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: github_importer:github_import_import_lfs_object :feature_category: :importers @@ -569,7 +569,7 @@ :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: github_importer:github_import_import_note :feature_category: :importers @@ -577,7 +577,7 @@ :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: github_importer:github_import_import_pull_request :feature_category: :importers @@ -585,103 +585,103 @@ :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: github_importer:github_import_refresh_import_jid :feature_category: :importers - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: github_importer:github_import_stage_finish_import :feature_category: :importers - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: github_importer:github_import_stage_import_base_data :feature_category: :importers - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: github_importer:github_import_stage_import_issues_and_diff_notes :feature_category: :importers - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: github_importer:github_import_stage_import_lfs_objects :feature_category: :importers - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: github_importer:github_import_stage_import_notes :feature_category: :importers - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: github_importer:github_import_stage_import_pull_requests :feature_category: :importers - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: github_importer:github_import_stage_import_repository :feature_category: :importers - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: hashed_storage:hashed_storage_migrator :feature_category: :source_code_management - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: hashed_storage:hashed_storage_project_migrate :feature_category: :source_code_management - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: hashed_storage:hashed_storage_project_rollback :feature_category: :source_code_management - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: hashed_storage:hashed_storage_rollbacker :feature_category: :source_code_management - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: incident_management:clusters_applications_check_prometheus_health :feature_category: :incident_management @@ -693,167 +693,175 @@ :tags: [] - :name: incident_management:incident_management_pager_duty_process_incident :feature_category: :incident_management - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 2 - :idempotent: + :idempotent: :tags: [] - :name: incident_management:incident_management_process_alert :feature_category: :incident_management - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 2 - :idempotent: + :idempotent: :tags: [] - :name: incident_management:incident_management_process_prometheus_alert :feature_category: :incident_management - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :cpu :weight: 2 - :idempotent: + :idempotent: :tags: [] - :name: jira_importer:jira_import_advance_stage :feature_category: :importers - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: jira_importer:jira_import_import_issue :feature_category: :importers - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: jira_importer:jira_import_stage_finish_import :feature_category: :importers - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: jira_importer:jira_import_stage_import_attachments :feature_category: :importers - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: jira_importer:jira_import_stage_import_issues :feature_category: :importers - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: jira_importer:jira_import_stage_import_labels :feature_category: :importers - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: jira_importer:jira_import_stage_import_notes :feature_category: :importers - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: jira_importer:jira_import_stage_start_import :feature_category: :importers - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: mail_scheduler:mail_scheduler_issue_due :feature_category: :issue_tracking - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 2 - :idempotent: + :idempotent: :tags: [] - :name: mail_scheduler:mail_scheduler_notification_service :feature_category: :issue_tracking - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :cpu :weight: 2 - :idempotent: + :idempotent: :tags: [] - :name: object_pool:object_pool_create :feature_category: :gitaly - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: object_pool:object_pool_destroy :feature_category: :gitaly - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: object_pool:object_pool_join :feature_category: :gitaly - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :cpu :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: object_pool:object_pool_schedule_join :feature_category: :gitaly - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: object_storage:object_storage_background_move :feature_category: :not_owned - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: object_storage:object_storage_migrate_uploads :feature_category: :not_owned - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: + :tags: [] +- :name: package_repositories:packages_nuget_extraction + :feature_category: :package_registry + :has_external_dependencies: + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: :tags: [] - :name: pipeline_background:archive_trace :feature_category: :continuous_integration - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: pipeline_background:ci_build_report_result :feature_category: :continuous_integration - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 @@ -861,15 +869,15 @@ :tags: [] - :name: pipeline_background:ci_build_trace_chunk_flush :feature_category: :continuous_integration - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: pipeline_background:ci_daily_build_group_report_results :feature_category: :continuous_integration - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 @@ -877,7 +885,7 @@ :tags: [] - :name: pipeline_background:ci_pipeline_success_unlock_artifacts :feature_category: :continuous_integration - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 @@ -885,7 +893,7 @@ :tags: [] - :name: pipeline_background:ci_ref_delete_unlock_artifacts :feature_category: :continuous_integration - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 @@ -893,7 +901,7 @@ :tags: [] - :name: pipeline_cache:expire_job_cache :feature_category: :continuous_integration - :has_external_dependencies: + :has_external_dependencies: :urgency: :high :resource_boundary: :unknown :weight: 3 @@ -901,7 +909,7 @@ :tags: [] - :name: pipeline_cache:expire_pipeline_cache :feature_category: :continuous_integration - :has_external_dependencies: + :has_external_dependencies: :urgency: :high :resource_boundary: :cpu :weight: 3 @@ -909,152 +917,152 @@ :tags: [] - :name: pipeline_creation:create_pipeline :feature_category: :continuous_integration - :has_external_dependencies: + :has_external_dependencies: :urgency: :high :resource_boundary: :cpu :weight: 4 - :idempotent: + :idempotent: :tags: [] - :name: pipeline_creation:run_pipeline_schedule :feature_category: :continuous_integration - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 4 - :idempotent: + :idempotent: :tags: [] - :name: pipeline_default:build_coverage :feature_category: :continuous_integration - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 3 - :idempotent: + :idempotent: :tags: [] - :name: pipeline_default:build_trace_sections :feature_category: :continuous_integration - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 3 - :idempotent: + :idempotent: :tags: [] - :name: pipeline_default:ci_create_cross_project_pipeline :feature_category: :continuous_integration - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :cpu :weight: 3 - :idempotent: + :idempotent: :tags: [] - :name: pipeline_default:ci_pipeline_bridge_status :feature_category: :continuous_integration - :has_external_dependencies: + :has_external_dependencies: :urgency: :high :resource_boundary: :cpu :weight: 3 - :idempotent: + :idempotent: :tags: [] - :name: pipeline_default:pipeline_metrics :feature_category: :continuous_integration - :has_external_dependencies: + :has_external_dependencies: :urgency: :high :resource_boundary: :unknown :weight: 3 - :idempotent: + :idempotent: :tags: [] - :name: pipeline_default:pipeline_notification :feature_category: :continuous_integration - :has_external_dependencies: + :has_external_dependencies: :urgency: :high :resource_boundary: :cpu :weight: 3 - :idempotent: + :idempotent: :tags: [] - :name: pipeline_default:pipeline_update_ci_ref_status :feature_category: :continuous_integration - :has_external_dependencies: + :has_external_dependencies: :urgency: :high :resource_boundary: :cpu :weight: 3 - :idempotent: + :idempotent: :tags: [] - :name: pipeline_hooks:build_hooks :feature_category: :continuous_integration - :has_external_dependencies: + :has_external_dependencies: :urgency: :high :resource_boundary: :unknown :weight: 2 - :idempotent: + :idempotent: :tags: [] - :name: pipeline_hooks:pipeline_hooks :feature_category: :continuous_integration - :has_external_dependencies: + :has_external_dependencies: :urgency: :high :resource_boundary: :cpu :weight: 2 - :idempotent: + :idempotent: :tags: [] - :name: pipeline_processing:build_finished :feature_category: :continuous_integration - :has_external_dependencies: + :has_external_dependencies: :urgency: :high :resource_boundary: :cpu :weight: 5 - :idempotent: + :idempotent: :tags: - :requires_disk_io - :name: pipeline_processing:build_queue :feature_category: :continuous_integration - :has_external_dependencies: + :has_external_dependencies: :urgency: :high :resource_boundary: :cpu :weight: 5 - :idempotent: + :idempotent: :tags: [] - :name: pipeline_processing:build_success :feature_category: :continuous_integration - :has_external_dependencies: + :has_external_dependencies: :urgency: :high :resource_boundary: :unknown :weight: 5 - :idempotent: + :idempotent: :tags: [] - :name: pipeline_processing:ci_build_prepare :feature_category: :continuous_integration - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 5 - :idempotent: + :idempotent: :tags: [] - :name: pipeline_processing:ci_build_schedule :feature_category: :continuous_integration - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :cpu :weight: 5 - :idempotent: + :idempotent: :tags: [] - :name: pipeline_processing:ci_resource_groups_assign_resource_from_resource_group :feature_category: :continuous_delivery - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 5 - :idempotent: + :idempotent: :tags: [] - :name: pipeline_processing:pipeline_process :feature_category: :continuous_integration - :has_external_dependencies: + :has_external_dependencies: :urgency: :high :resource_boundary: :unknown :weight: 5 - :idempotent: + :idempotent: :tags: [] - :name: pipeline_processing:pipeline_update :feature_category: :continuous_integration - :has_external_dependencies: + :has_external_dependencies: :urgency: :high :resource_boundary: :unknown :weight: 5 @@ -1062,7 +1070,7 @@ :tags: [] - :name: pipeline_processing:stage_update :feature_category: :continuous_integration - :has_external_dependencies: + :has_external_dependencies: :urgency: :high :resource_boundary: :unknown :weight: 5 @@ -1070,7 +1078,7 @@ :tags: [] - :name: pipeline_processing:update_head_pipeline_for_merge_request :feature_category: :continuous_integration - :has_external_dependencies: + :has_external_dependencies: :urgency: :high :resource_boundary: :cpu :weight: 5 @@ -1078,71 +1086,71 @@ :tags: [] - :name: repository_check:repository_check_batch :feature_category: :source_code_management - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: repository_check:repository_check_clear :feature_category: :source_code_management - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: repository_check:repository_check_single_repository :feature_category: :source_code_management - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: todos_destroyer:todos_destroyer_confidential_issue :feature_category: :issue_tracking - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: todos_destroyer:todos_destroyer_entity_leave :feature_category: :issue_tracking - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: todos_destroyer:todos_destroyer_group_private :feature_category: :issue_tracking - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: todos_destroyer:todos_destroyer_private_features :feature_category: :issue_tracking - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: todos_destroyer:todos_destroyer_project_private :feature_category: :issue_tracking - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: unassign_issuables:members_destroyer_unassign_issuables :feature_category: :authentication_and_authorization - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 @@ -1150,7 +1158,7 @@ :tags: [] - :name: update_namespace_statistics:namespaces_root_statistics :feature_category: :source_code_management - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 @@ -1158,7 +1166,7 @@ :tags: [] - :name: update_namespace_statistics:namespaces_schedule_aggregation :feature_category: :source_code_management - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 @@ -1166,7 +1174,7 @@ :tags: [] - :name: authorized_keys :feature_category: :source_code_management - :has_external_dependencies: + :has_external_dependencies: :urgency: :high :resource_boundary: :unknown :weight: 2 @@ -1174,7 +1182,7 @@ :tags: [] - :name: authorized_projects :feature_category: :authentication_and_authorization - :has_external_dependencies: + :has_external_dependencies: :urgency: :high :resource_boundary: :unknown :weight: 2 @@ -1182,11 +1190,11 @@ :tags: [] - :name: background_migration :feature_category: :database - :has_external_dependencies: + :has_external_dependencies: :urgency: :throttled :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: chat_notification :feature_category: :chatops @@ -1194,11 +1202,11 @@ :urgency: :low :resource_boundary: :unknown :weight: 2 - :idempotent: + :idempotent: :tags: [] - :name: create_commit_signature :feature_category: :source_code_management - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 2 @@ -1206,91 +1214,91 @@ :tags: [] - :name: create_evidence :feature_category: :release_evidence - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 2 - :idempotent: + :idempotent: :tags: [] - :name: create_note_diff_file :feature_category: :source_code_management - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: default - :feature_category: - :has_external_dependencies: - :urgency: - :resource_boundary: + :feature_category: + :has_external_dependencies: + :urgency: + :resource_boundary: :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: delete_diff_files :feature_category: :source_code_management - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: delete_merged_branches :feature_category: :source_code_management - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: delete_stored_files :feature_category: :not_owned - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: delete_user :feature_category: :authentication_and_authorization - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: design_management_new_version :feature_category: :design_management - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :memory :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: detect_repository_languages :feature_category: :source_code_management - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: email_receiver :feature_category: :issue_tracking - :has_external_dependencies: + :has_external_dependencies: :urgency: :high :resource_boundary: :unknown :weight: 2 - :idempotent: + :idempotent: :tags: [] - :name: emails_on_push :feature_category: :source_code_management - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :cpu :weight: 2 - :idempotent: + :idempotent: :tags: [] - :name: error_tracking_issue_link :feature_category: :error_tracking @@ -1298,23 +1306,23 @@ :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: expire_build_instance_artifacts :feature_category: :continuous_integration - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: export_csv :feature_category: :issue_tracking - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :cpu :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: external_service_reactive_caching :feature_category: :not_owned @@ -1322,107 +1330,107 @@ :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: file_hook :feature_category: :integrations - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: git_garbage_collect :feature_category: :gitaly - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: github_import_advance_stage :feature_category: :importers - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: gitlab_shell :feature_category: :source_code_management - :has_external_dependencies: + :has_external_dependencies: :urgency: :high :resource_boundary: :unknown :weight: 2 - :idempotent: + :idempotent: :tags: [] - :name: group_destroy :feature_category: :subgroups - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: group_export :feature_category: :importers - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: group_import :feature_category: :importers - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: import_issues_csv :feature_category: :issue_tracking - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :cpu :weight: 2 - :idempotent: + :idempotent: :tags: [] - :name: invalid_gpg_signature_update :feature_category: :source_code_management - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 2 - :idempotent: + :idempotent: :tags: [] - :name: irker :feature_category: :integrations - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: mailers - :feature_category: - :has_external_dependencies: - :urgency: - :resource_boundary: + :feature_category: + :has_external_dependencies: + :urgency: + :resource_boundary: :weight: 2 - :idempotent: + :idempotent: :tags: [] - :name: merge :feature_category: :source_code_management - :has_external_dependencies: + :has_external_dependencies: :urgency: :high :resource_boundary: :unknown :weight: 5 - :idempotent: + :idempotent: :tags: [] - :name: merge_request_mergeability_check :feature_category: :source_code_management - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 @@ -1430,7 +1438,7 @@ :tags: [] - :name: metrics_dashboard_prune_old_annotations :feature_category: :metrics - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 @@ -1438,87 +1446,87 @@ :tags: [] - :name: migrate_external_diffs :feature_category: :source_code_management - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: namespaceless_project_destroy :feature_category: :authentication_and_authorization - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: new_issue :feature_category: :issue_tracking - :has_external_dependencies: + :has_external_dependencies: :urgency: :high :resource_boundary: :cpu :weight: 2 - :idempotent: + :idempotent: :tags: [] - :name: new_merge_request :feature_category: :source_code_management - :has_external_dependencies: + :has_external_dependencies: :urgency: :high :resource_boundary: :cpu :weight: 2 - :idempotent: + :idempotent: :tags: [] - :name: new_note :feature_category: :issue_tracking - :has_external_dependencies: + :has_external_dependencies: :urgency: :high :resource_boundary: :cpu :weight: 2 - :idempotent: + :idempotent: :tags: [] - :name: pages :feature_category: :pages - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: pages_domain_ssl_renewal :feature_category: :pages - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: pages_domain_verification :feature_category: :pages - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: phabricator_import_import_tasks :feature_category: :importers - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: post_receive :feature_category: :source_code_management - :has_external_dependencies: + :has_external_dependencies: :urgency: :high :resource_boundary: :cpu :weight: 5 - :idempotent: + :idempotent: :tags: [] - :name: process_commit :feature_category: :source_code_management - :has_external_dependencies: + :has_external_dependencies: :urgency: :high :resource_boundary: :unknown :weight: 3 @@ -1526,35 +1534,35 @@ :tags: [] - :name: project_cache :feature_category: :source_code_management - :has_external_dependencies: + :has_external_dependencies: :urgency: :high :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: project_daily_statistics :feature_category: :source_code_management - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: project_destroy :feature_category: :source_code_management - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: project_export :feature_category: :importers - :has_external_dependencies: + :has_external_dependencies: :urgency: :throttled :resource_boundary: :memory :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: project_service :feature_category: :integrations @@ -1562,11 +1570,11 @@ :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: project_update_repository_storage :feature_category: :gitaly - :has_external_dependencies: + :has_external_dependencies: :urgency: :throttled :resource_boundary: :unknown :weight: 1 @@ -1574,7 +1582,7 @@ :tags: [] - :name: prometheus_create_default_alerts :feature_category: :incident_management - :has_external_dependencies: + :has_external_dependencies: :urgency: :high :resource_boundary: :unknown :weight: 1 @@ -1582,7 +1590,7 @@ :tags: [] - :name: propagate_integration :feature_category: :integrations - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 @@ -1590,51 +1598,51 @@ :tags: [] - :name: propagate_service_template :feature_category: :source_code_management - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: reactive_caching :feature_category: :not_owned - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :cpu :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: rebase :feature_category: :source_code_management - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 2 - :idempotent: + :idempotent: :tags: [] - :name: remote_mirror_notification :feature_category: :source_code_management - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 2 - :idempotent: + :idempotent: :tags: [] - :name: repository_cleanup :feature_category: :source_code_management - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: repository_fork :feature_category: :source_code_management - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: repository_import :feature_category: :importers @@ -1642,15 +1650,15 @@ :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: repository_remove_remote :feature_category: :source_code_management - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: repository_update_remote_mirror :feature_category: :source_code_management @@ -1658,51 +1666,51 @@ :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: self_monitoring_project_create :feature_category: :metrics - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 2 - :idempotent: + :idempotent: :tags: [] - :name: self_monitoring_project_delete :feature_category: :metrics - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 2 - :idempotent: + :idempotent: :tags: [] - :name: service_desk_email_receiver :feature_category: :issue_tracking - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: system_hook_push :feature_category: :source_code_management - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: update_external_pull_requests :feature_category: :source_code_management - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 3 - :idempotent: + :idempotent: :tags: [] - :name: update_highest_role :feature_category: :authentication_and_authorization - :has_external_dependencies: + :has_external_dependencies: :urgency: :high :resource_boundary: :unknown :weight: 2 @@ -1710,27 +1718,27 @@ :tags: [] - :name: update_merge_requests :feature_category: :source_code_management - :has_external_dependencies: + :has_external_dependencies: :urgency: :high :resource_boundary: :cpu :weight: 3 - :idempotent: + :idempotent: :tags: [] - :name: update_project_statistics :feature_category: :source_code_management - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: upload_checksum :feature_category: :geo_replication - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: web_hook :feature_category: :integrations @@ -1738,11 +1746,11 @@ :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: x509_certificate_revoke :feature_category: :source_code_management - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 diff --git a/app/workers/packages/nuget/extraction_worker.rb b/app/workers/packages/nuget/extraction_worker.rb new file mode 100644 index 00000000000..820304a9f3b --- /dev/null +++ b/app/workers/packages/nuget/extraction_worker.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Packages + module Nuget + class ExtractionWorker # rubocop:disable Scalability/IdempotentWorker + include ApplicationWorker + + queue_namespace :package_repositories + feature_category :package_registry + + def perform(package_file_id) + package_file = ::Packages::PackageFile.find_by_id(package_file_id) + + return unless package_file + + ::Packages::Nuget::UpdatePackageFromMetadataService.new(package_file).execute + + rescue ::Packages::Nuget::MetadataExtractionService::ExtractionError, + ::Packages::Nuget::UpdatePackageFromMetadataService::InvalidMetadataError => e + Gitlab::ErrorTracking.log_exception(e, project_id: package_file.project_id) + package_file.package.destroy! + end + end + end +end diff --git a/changelogs/unreleased/213929-move-package-apis-to-core.yml b/changelogs/unreleased/213929-move-package-apis-to-core.yml new file mode 100644 index 00000000000..fd8413875ba --- /dev/null +++ b/changelogs/unreleased/213929-move-package-apis-to-core.yml @@ -0,0 +1,5 @@ +--- +title: Package APIs moved to core +merge_request: 35919 +author: +type: changed diff --git a/changelogs/unreleased/faster-label-transfer-queries.yml b/changelogs/unreleased/faster-label-transfer-queries.yml new file mode 100644 index 00000000000..f5415b00a6d --- /dev/null +++ b/changelogs/unreleased/faster-label-transfer-queries.yml @@ -0,0 +1,5 @@ +--- +title: Remove unindexed condition on label transfer +merge_request: 37060 +author: +type: performance diff --git a/changelogs/unreleased/jsonnet-template.yml b/changelogs/unreleased/jsonnet-template.yml new file mode 100644 index 00000000000..64743e05c0f --- /dev/null +++ b/changelogs/unreleased/jsonnet-template.yml @@ -0,0 +1,5 @@ +--- +title: Add Jsonnet template for GitLab +merge_request: 37058 +author: +type: added diff --git a/changelogs/unreleased/ph-approvalsFEToFoss.yml b/changelogs/unreleased/ph-approvalsFEToFoss.yml new file mode 100644 index 00000000000..20def1c56a8 --- /dev/null +++ b/changelogs/unreleased/ph-approvalsFEToFoss.yml @@ -0,0 +1,5 @@ +--- +title: Show Approve button on merge requests in Core +merge_request: 36449 +author: +type: added diff --git a/doc/README.md b/doc/README.md index 020d9c3fb25..115026dae6e 100644 --- a/doc/README.md +++ b/doc/README.md @@ -123,7 +123,7 @@ The following documentation relates to the DevOps **Plan** stage: | [Related Issues](user/project/issues/related_issues.md) **(STARTER)** | Create a relationship between issues. | | [Requirements Management](user/project/requirements/index.md) **(ULTIMATE)** | Check your products against a set of criteria. | | [Roadmap](user/group/roadmap/index.md) **(ULTIMATE)** | Visualize epic timelines. | -| [Service Desk](user/project/service_desk.md) **(STARTER)** | A simple way to allow people to create issues in your GitLab instance without needing their own user account. | +| [Service Desk](user/project/service_desk.md) | A simple way to allow people to create issues in your GitLab instance without needing their own user account. | | [Time Tracking](user/project/time_tracking.md) | Track time spent on issues and merge requests. | | [Todos](user/todos.md) | Keep track of work requiring attention with a chronological list displayed on a simple dashboard. | diff --git a/doc/administration/incoming_email.md b/doc/administration/incoming_email.md index 3ab9d7f16e4..36156c4a580 100644 --- a/doc/administration/incoming_email.md +++ b/doc/administration/incoming_email.md @@ -17,7 +17,7 @@ GitLab has several features based on receiving incoming emails: allow GitLab users to create a new merge request by sending an email to a user-specific email address. - [Service Desk](../user/project/service_desk.md): provide e-mail support to - your customers through GitLab. **(STARTER)** + your customers through GitLab. ## Requirements diff --git a/doc/api/projects.md b/doc/api/projects.md index 6464b1db06e..6257f37f0e6 100644 --- a/doc/api/projects.md +++ b/doc/api/projects.md @@ -1228,7 +1228,7 @@ PUT /projects/:id | `only_mirror_protected_branches` | boolean | no | **(STARTER)** Only mirror protected branches | | `mirror_overwrites_diverged_branches` | boolean | no | **(STARTER)** Pull mirror overwrites diverged branches | | `packages_enabled` | boolean | no | **(PREMIUM ONLY)** Enable or disable packages repository feature | -| `service_desk_enabled` | boolean | no | **(PREMIUM ONLY)** Enable or disable service desk feature | +| `service_desk_enabled` | boolean | no | Enable or disable service desk feature | NOTE: **Note:** If your HTTP repository is not publicly accessible, diff --git a/doc/development/documentation/site_architecture/global_nav.md b/doc/development/documentation/site_architecture/global_nav.md index 646626eadf1..2625fbe0415 100644 --- a/doc/development/documentation/site_architecture/global_nav.md +++ b/doc/development/documentation/site_architecture/global_nav.md @@ -306,7 +306,7 @@ Examples: docs: - doc_title: Service Desk doc_url: 'user/project/service_desk.html' - ee_only: true + ee_only: false # note that the URL above ends in html and, as the # document is EE-only, the attribute ee_only is set to true. ``` diff --git a/doc/development/emails.md b/doc/development/emails.md index 2477a51f78f..cf7f49ee834 100644 --- a/doc/development/emails.md +++ b/doc/development/emails.md @@ -115,7 +115,7 @@ Examples of valid email keys: - `1234567890abcdef1234567890abcdef-unsubscribe` (unsubscribe from a conversation) - `1234567890abcdef1234567890abcdef` (reply to a conversation) -Please note that the action `-issue-` is used in GitLab Premium as the handler for the Service Desk feature. +Please note that the action `-issue-` is used in GitLab as the handler for the Service Desk feature. ### Legacy format @@ -127,7 +127,7 @@ These are the only valid legacy formats for an email handler: - `namespace` - `namespace+action` -Please note that `path/to/project` is used in GitLab Premium as handler for the Service Desk feature. +Please note that `path/to/project` is used in GitLab as the handler for the Service Desk feature. --- diff --git a/doc/development/telemetry/usage_ping.md b/doc/development/telemetry/usage_ping.md index 4bf2c148da0..5e78e2c5f25 100644 --- a/doc/development/telemetry/usage_ping.md +++ b/doc/development/telemetry/usage_ping.md @@ -702,8 +702,8 @@ appear to be associated to any of the services running, since they all appear to | `projects_jira_active` | `usage_activity_by_stage` | `plan` | | EE | | | `projects_jira_dvcs_server_active` | `usage_activity_by_stage` | `plan` | | EE | | | `projects_jira_dvcs_server_active` | `usage_activity_by_stage` | `plan` | | EE | | -| `service_desk_enabled_projects` | `usage_activity_by_stage` | `plan` | | EE | | -| `service_desk_issues` | `usage_activity_by_stage` | `plan` | | EE | | +| `service_desk_enabled_projects` | `usage_activity_by_stage` | `plan` | | CE+EE | | +| `service_desk_issues` | `usage_activity_by_stage` | `plan` | | CE+EE | | | `deployments` | `usage_activity_by_stage` | `release` | | CE+EE | Total deployments | | `failed_deployments` | `usage_activity_by_stage` | `release` | | CE+EE | Total failed deployments | | `projects_mirrored_with_pipelines_enabled` | `usage_activity_by_stage` | `release` | | EE | Projects with repository mirroring enabled | diff --git a/doc/user/group/epics/img/epics_list_view_v12.5.png b/doc/user/group/epics/img/epics_list_view_v12.5.png Binary files differdeleted file mode 100644 index 6e3c39009be..00000000000 --- a/doc/user/group/epics/img/epics_list_view_v12.5.png +++ /dev/null diff --git a/doc/user/group/epics/img/new_epic_form_v13.2.png b/doc/user/group/epics/img/new_epic_form_v13.2.png Binary files differnew file mode 100644 index 00000000000..3d24763d105 --- /dev/null +++ b/doc/user/group/epics/img/new_epic_form_v13.2.png diff --git a/doc/user/group/epics/img/new_epic_from_groups_v13.2.png b/doc/user/group/epics/img/new_epic_from_groups_v13.2.png Binary files differnew file mode 100644 index 00000000000..85bc4255595 --- /dev/null +++ b/doc/user/group/epics/img/new_epic_from_groups_v13.2.png diff --git a/doc/user/group/epics/index.md b/doc/user/group/epics/index.md index fd7f090c211..a2b04e2d7fe 100644 --- a/doc/user/group/epics/index.md +++ b/doc/user/group/epics/index.md @@ -14,8 +14,15 @@ Epics let you manage your portfolio of projects more efficiently and with less effort by tracking groups of issues that share a theme, across projects and milestones. -<!-- Possibly swap this file with one of a single epic --> -![epics list view](img/epics_list_view_v12.5.png) +An epic's page contains the following tabs: + +- **Epics and Issues**: epics and issues added to this epic. Child epics, and their issues, are + shown in a tree view. + - Click the chevron (**>**) next to a parent epic to reveal the child epics and issues. + - Hover over the total counts to see a breakdown of open and closed items. +- **Roadmap**: a roadmap view of child epics which have start and due dates. + +![epic view](img/epic_view_v13.0.png) ## Use cases @@ -28,6 +35,7 @@ milestones. To learn what you can do with an epic, see [Manage epics](manage_epics.md). Possible actions include: - [Create an epic](manage_epics.md#create-an-epic) +- [Edit an epic](manage_epics.md#edit-an-epic) - [Bulk-edit epics](../bulk_editing/index.md#bulk-edit-epics) - [Delete an epic](manage_epics.md#delete-an-epic) - [Close an epic](manage_epics.md#close-an-epic) diff --git a/doc/user/group/epics/manage_epics.md b/doc/user/group/epics/manage_epics.md index 54838a5b434..4f9bb0e24fb 100644 --- a/doc/user/group/epics/manage_epics.md +++ b/doc/user/group/epics/manage_epics.md @@ -18,12 +18,42 @@ A paginated list of epics is available in each group from where you can create a new epic. The list of epics includes also epics from all subgroups of the selected group. From your group page: -1. Go to **Epics**. +### Create an epic from the epic list + +To create an epic from the epic list, in a group: + +1. Go to **{epic}** **Epics**. 1. Click **New epic**. 1. Enter a descriptive title. 1. Click **Create epic**. -You will be taken to the new epic where can edit the following details: +### Access the New Epic form + +> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/211533) in [GitLab Premium](https://about.gitlab.com/pricing/) 13.2. + +There are two ways to get to the New Epic form and create an epic in the group you're in: + +- From an epic in your group, click **New Epic**. +- From anywhere, in the top menu, click **plus** (**{plus-square}**) **> New epic**. + + ![New epic from an open epic](img/new_epic_from_groups_v13.2.png) + +### Elements of the New Epic form + +When you're creating a new epic, these are the fields you can fill in: + +- Title +- Description +- Confidentiality checkbox +- Labels +- Start date +- Due date + +![New epic form](img/new_epic_form_v13.2.png) + +## Edit an epic + +After you create an epic, you can edit change the following details: - Title - Description @@ -31,15 +61,16 @@ You will be taken to the new epic where can edit the following details: - Due date - Labels -An epic's page contains the following tabs: +To edit an epic's title or description: + +1. Click the **Edit title and description** **{pencil}** button. +1. Make your changes. +1. Click **Save changes**. -- **Epics and Issues**: epics and issues added to this epic. Child epics, and their issues, are - shown in a tree view. - - Click the <kbd>></kbd> beside a parent epic to reveal the child epics and issues. - - Hover over the total counts to see a breakdown of open and closed items. -- **Roadmap**: a roadmap view of child epics which have start and due dates. +To edit an epics' start date, due date, or labels: -![epic view](img/epic_view_v13.0.png) +1. Click **Edit** next to each section in the epic sidebar. +1. Select the dates or labels for your epic. ## Bulk-edit epics diff --git a/doc/user/index.md b/doc/user/index.md index bfb00e45e52..f50b712e2c3 100644 --- a/doc/user/index.md +++ b/doc/user/index.md @@ -46,10 +46,10 @@ GitLab is a Git-based platform that integrates a great number of essential tools - Deploying personal and professional static websites with [GitLab Pages](project/pages/index.md). - Integrating with Docker by using [GitLab Container Registry](packages/container_registry/index.md). - Tracking the development lifecycle by using [GitLab Value Stream Analytics](project/cycle_analytics.md). +- Provide support with [Service Desk](project/service_desk.md). With GitLab Enterprise Edition, you can also: -- Provide support with [Service Desk](project/service_desk.md). - Improve collaboration with: - [Merge Request Approvals](project/merge_requests/merge_request_approvals.md). **(STARTER)** - [Multiple Assignees for Issues](project/issues/multiple_assignees_for_issues.md). **(STARTER)** diff --git a/doc/user/project/integrations/img/jira/open_jira_issues_list_v13.2.png b/doc/user/project/integrations/img/jira/open_jira_issues_list_v13.2.png Binary files differindex aaf4f6e3f90..0cf58433b25 100644 --- a/doc/user/project/integrations/img/jira/open_jira_issues_list_v13.2.png +++ b/doc/user/project/integrations/img/jira/open_jira_issues_list_v13.2.png diff --git a/doc/user/project/integrations/img/jira_service_page_v12_2.png b/doc/user/project/integrations/img/jira_service_page_v12_2.png Binary files differdeleted file mode 100644 index ba7dad9b438..00000000000 --- a/doc/user/project/integrations/img/jira_service_page_v12_2.png +++ /dev/null diff --git a/doc/user/project/integrations/jira.md b/doc/user/project/integrations/jira.md index c44eb2af4dc..541c65041ad 100644 --- a/doc/user/project/integrations/jira.md +++ b/doc/user/project/integrations/jira.md @@ -89,8 +89,6 @@ When you have configured all settings, click **Test settings and save changes**. Your GitLab project can now interact with all Jira projects in your instance and the project now displays a Jira link that opens the Jira project. -![Jira service page](img/jira_service_page_v12_2.png) - #### Obtaining a transition ID In the most recent Jira user interface, you can no longer see transition IDs in the workflow diff --git a/doc/user/project/issues/managing_issues.md b/doc/user/project/issues/managing_issues.md index 08e3164b2eb..babc5030337 100644 --- a/doc/user/project/issues/managing_issues.md +++ b/doc/user/project/issues/managing_issues.md @@ -45,8 +45,7 @@ There are many ways to get to the New Issue form from within a project: ### Elements of the New Issue form -> Ability to add the new issue to an epic [was introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/13847) -> in [GitLab Premium](https://about.gitlab.com/pricing/) 13.1. +> Ability to add the new issue to an epic [was introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/13847) in [GitLab Premium](https://about.gitlab.com/pricing/) 13.1. ![New issue from the issues list](img/new_issue_v13_1.png) @@ -76,7 +75,7 @@ create issues for the same project. ![Create issue from group-level issue tracker](img/create_issue_from_group_level_issue_tracker.png) -### New issue via Service Desk **(STARTER)** +### New issue via Service Desk Enable [Service Desk](../service_desk.md) for your project and offer email support. By doing so, when your customer sends a new email, a new issue can be created in diff --git a/doc/user/project/service_desk.md b/doc/user/project/service_desk.md index 05d616f75dc..cb6f8e2221d 100644 --- a/doc/user/project/service_desk.md +++ b/doc/user/project/service_desk.md @@ -4,10 +4,11 @@ group: Certify info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#designated-technical-writers --- -# Service Desk **(STARTER)** +# Service Desk > - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/149) in [GitLab Premium](https://about.gitlab.com/pricing/) 9.1. > - [Moved](https://gitlab.com/gitlab-org/gitlab/-/issues/214839) to [GitLab Starter](https://about.gitlab.com/pricing/) in 13.0. +> - [Moved](https://gitlab.com/gitlab-org/gitlab/-/issues/215364) to [GitLab Core](https://about.gitlab.com/pricing/) in 13.2. ## Overview @@ -61,10 +62,10 @@ users will only see the thread through email. ## Configuring Service Desk NOTE: **Note:** -Service Desk is enabled on GitLab.com. If you're a [Silver subscriber](https://about.gitlab.com/pricing/#gitlab-com), -you can skip step 1 below; you only need to enable it per project. +Service Desk is enabled on GitLab.com. +You can skip step 1 below; you only need to enable it per project. -If you have the correct access and a Premium license, you have the option to set up Service Desk. +If you have project maintainer access you have the option to set up Service Desk. Follow these steps to do so: 1. [Set up incoming email](../../administration/incoming_email.md#set-it-up) for the GitLab instance. diff --git a/doc/user/project/settings/index.md b/doc/user/project/settings/index.md index 28dfb68234e..7fe6e702d1c 100644 --- a/doc/user/project/settings/index.md +++ b/doc/user/project/settings/index.md @@ -68,7 +68,7 @@ Some features depend on others: - If you disable the **Issues** option, GitLab also removes the following features: - **Issue Boards** - - [**Service Desk**](#service-desk-starter) **(STARTER)** + - [**Service Desk**](#service-desk-starter) NOTE: **Note:** When the **Issues** option is disabled, you can still access **Milestones** diff --git a/lib/api/api.rb b/lib/api/api.rb index 5fccc210779..a89dc0fa6fa 100644 --- a/lib/api/api.rb +++ b/lib/api/api.rb @@ -182,6 +182,16 @@ module API mount ::API::ResourceMilestoneEvents mount ::API::ResourceStateEvents mount ::API::NotificationSettings + mount ::API::ProjectPackages + mount ::API::GroupPackages + mount ::API::PackageFiles + mount ::API::NugetPackages + mount ::API::PypiPackages + mount ::API::ComposerPackages + mount ::API::ConanPackages + mount ::API::MavenPackages + mount ::API::NpmPackages + mount ::API::GoProxy mount ::API::Pages mount ::API::PagesDomains mount ::API::ProjectClusters diff --git a/lib/api/composer_packages.rb b/lib/api/composer_packages.rb new file mode 100644 index 00000000000..726dc89271a --- /dev/null +++ b/lib/api/composer_packages.rb @@ -0,0 +1,156 @@ +# frozen_string_literal: true + +# PHP composer support (https://getcomposer.org/) +module API + class ComposerPackages < Grape::API::Instance + helpers ::API::Helpers::PackagesManagerClientsHelpers + helpers ::API::Helpers::RelatedResourcesHelpers + helpers ::API::Helpers::Packages::BasicAuthHelpers + include ::API::Helpers::Packages::BasicAuthHelpers::Constants + include ::Gitlab::Utils::StrongMemoize + + content_type :json, 'application/json' + default_format :json + + COMPOSER_ENDPOINT_REQUIREMENTS = { + package_name: API::NO_SLASH_URL_PART_REGEX + }.freeze + + default_format :json + + rescue_from ArgumentError do |e| + render_api_error!(e.message, 400) + end + + rescue_from ActiveRecord::RecordInvalid do |e| + render_api_error!(e.message, 400) + end + + helpers do + def packages + strong_memoize(:packages) do + packages = ::Packages::Composer::PackagesFinder.new(current_user, user_group).execute + + if params[:package_name].present? + packages = packages.with_name(params[:package_name]) + end + + packages + end + end + + def presenter + @presenter ||= ::Packages::Composer::PackagesPresenter.new(user_group, packages) + end + end + + before do + require_packages_enabled! + end + + params do + requires :id, type: String, desc: 'The ID of a group' + end + + resource :group, requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS do + before do + user_group + end + + desc 'Composer packages endpoint at group level' + + route_setting :authentication, job_token_allowed: true + + get ':id/-/packages/composer/packages' do + presenter.root + end + + desc 'Composer packages endpoint at group level for packages list' + + params do + requires :sha, type: String, desc: 'Shasum of current json' + end + + route_setting :authentication, job_token_allowed: true + + get ':id/-/packages/composer/p/:sha' do + presenter.provider + end + + desc 'Composer packages endpoint at group level for package versions metadata' + + params do + requires :package_name, type: String, file_path: true, desc: 'The Composer package name' + end + + route_setting :authentication, job_token_allowed: true + + get ':id/-/packages/composer/*package_name', requirements: COMPOSER_ENDPOINT_REQUIREMENTS, file_path: true do + not_found! if packages.empty? + + presenter.package_versions + end + end + + params do + requires :id, type: Integer, desc: 'The ID of a project' + end + + resource :projects, requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS do + before do + unauthorized_user_project! + end + + desc 'Composer packages endpoint for registering packages' + + namespace ':id/packages/composer' do + route_setting :authentication, job_token_allowed: true + + params do + optional :branch, type: String, desc: 'The name of the branch' + optional :tag, type: String, desc: 'The name of the tag' + exactly_one_of :tag, :branch + end + + post do + authorize_create_package!(authorized_user_project) + + if params[:branch].present? + params[:branch] = find_branch!(params[:branch]) + elsif params[:tag].present? + params[:tag] = find_tag!(params[:tag]) + else + bad_request! + end + + track_event('register_package') + + ::Packages::Composer::CreatePackageService + .new(authorized_user_project, current_user, declared_params) + .execute + + created! + end + + params do + requires :sha, type: String, desc: 'Shasum of current json' + requires :package_name, type: String, file_path: true, desc: 'The Composer package name' + end + + get 'archives/*package_name' do + metadata = unauthorized_user_project + .packages + .composer + .with_name(params[:package_name]) + .with_composer_target(params[:sha]) + .first + &.composer_metadatum + + not_found! unless metadata + + send_git_archive unauthorized_user_project.repository, ref: metadata.target_sha, format: 'zip', append_sha: true + end + end + end + end +end diff --git a/lib/api/conan_packages.rb b/lib/api/conan_packages.rb new file mode 100644 index 00000000000..1d941e422a7 --- /dev/null +++ b/lib/api/conan_packages.rb @@ -0,0 +1,309 @@ +# frozen_string_literal: true + +# Conan Package Manager Client API +# +# These API endpoints are not consumed directly by users, so there is no documentation for the +# individual endpoints. They are called by the Conan package manager client when users run commands +# like `conan install` or `conan upload`. The usage of the GitLab Conan repository is documented here: +# https://docs.gitlab.com/ee/user/packages/conan_repository/#installing-a-package +# +# Technical debt: https://gitlab.com/gitlab-org/gitlab/issues/35798 +module API + class ConanPackages < Grape::API::Instance + helpers ::API::Helpers::PackagesManagerClientsHelpers + + PACKAGE_REQUIREMENTS = { + package_name: API::NO_SLASH_URL_PART_REGEX, + package_version: API::NO_SLASH_URL_PART_REGEX, + package_username: API::NO_SLASH_URL_PART_REGEX, + package_channel: API::NO_SLASH_URL_PART_REGEX + }.freeze + + FILE_NAME_REQUIREMENTS = { + file_name: API::NO_SLASH_URL_PART_REGEX + }.freeze + + PACKAGE_COMPONENT_REGEX = Gitlab::Regex.conan_recipe_component_regex + CONAN_REVISION_REGEX = Gitlab::Regex.conan_revision_regex + + before do + require_packages_enabled! + + # Personal access token will be extracted from Bearer or Basic authorization + # in the overridden find_personal_access_token or find_user_from_job_token helpers + authenticate! + end + + namespace 'packages/conan/v1' do + desc 'Ping the Conan API' do + detail 'This feature was introduced in GitLab 12.2' + end + route_setting :authentication, job_token_allowed: true + get 'ping' do + header 'X-Conan-Server-Capabilities', [].join(',') + end + + desc 'Search for packages' do + detail 'This feature was introduced in GitLab 12.4' + end + params do + requires :q, type: String, desc: 'Search query' + end + route_setting :authentication, job_token_allowed: true + get 'conans/search' do + service = ::Packages::Conan::SearchService.new(current_user, query: params[:q]).execute + service.payload + end + + namespace 'users' do + format :txt + + desc 'Authenticate user against conan CLI' do + detail 'This feature was introduced in GitLab 12.2' + end + route_setting :authentication, job_token_allowed: true + get 'authenticate' do + unauthorized! unless token + + token.to_jwt + end + + desc 'Check for valid user credentials per conan CLI' do + detail 'This feature was introduced in GitLab 12.4' + end + route_setting :authentication, job_token_allowed: true + get 'check_credentials' do + authenticate! + :ok + end + end + + params do + requires :package_name, type: String, regexp: PACKAGE_COMPONENT_REGEX, desc: 'Package name' + requires :package_version, type: String, regexp: PACKAGE_COMPONENT_REGEX, desc: 'Package version' + requires :package_username, type: String, regexp: PACKAGE_COMPONENT_REGEX, desc: 'Package username' + requires :package_channel, type: String, regexp: PACKAGE_COMPONENT_REGEX, desc: 'Package channel' + end + namespace 'conans/:package_name/:package_version/:package_username/:package_channel', requirements: PACKAGE_REQUIREMENTS do + # Get the snapshot + # + # the snapshot is a hash of { filename: md5 hash } + # md5 hash is the has of that file. This hash is used to diff the files existing on the client + # to determine which client files need to be uploaded if no recipe exists the snapshot is empty + desc 'Package Snapshot' do + detail 'This feature was introduced in GitLab 12.5' + end + params do + requires :conan_package_reference, type: String, desc: 'Conan package ID' + end + route_setting :authentication, job_token_allowed: true + get 'packages/:conan_package_reference' do + authorize!(:read_package, project) + + presenter = ::Packages::Conan::PackagePresenter.new( + recipe, + current_user, + project, + conan_package_reference: params[:conan_package_reference] + ) + + present presenter, with: ::API::Entities::ConanPackage::ConanPackageSnapshot + end + + desc 'Recipe Snapshot' do + detail 'This feature was introduced in GitLab 12.5' + end + route_setting :authentication, job_token_allowed: true + get do + authorize!(:read_package, project) + + presenter = ::Packages::Conan::PackagePresenter.new(recipe, current_user, project) + + present presenter, with: ::API::Entities::ConanPackage::ConanRecipeSnapshot + end + + # Get the manifest + # returns the download urls for the existing recipe in the registry + # + # the manifest is a hash of { filename: url } + # where the url is the download url for the file + desc 'Package Digest' do + detail 'This feature was introduced in GitLab 12.5' + end + params do + requires :conan_package_reference, type: String, desc: 'Conan package ID' + end + route_setting :authentication, job_token_allowed: true + get 'packages/:conan_package_reference/digest' do + present_package_download_urls + end + + desc 'Recipe Digest' do + detail 'This feature was introduced in GitLab 12.5' + end + route_setting :authentication, job_token_allowed: true + get 'digest' do + present_recipe_download_urls + end + + # Get the download urls + # + # returns the download urls for the existing recipe or package in the registry + # + # the manifest is a hash of { filename: url } + # where the url is the download url for the file + desc 'Package Download Urls' do + detail 'This feature was introduced in GitLab 12.5' + end + params do + requires :conan_package_reference, type: String, desc: 'Conan package ID' + end + route_setting :authentication, job_token_allowed: true + get 'packages/:conan_package_reference/download_urls' do + present_package_download_urls + end + + desc 'Recipe Download Urls' do + detail 'This feature was introduced in GitLab 12.5' + end + route_setting :authentication, job_token_allowed: true + get 'download_urls' do + present_recipe_download_urls + end + + # Get the upload urls + # + # request body contains { filename: filesize } where the filename is the + # name of the file the conan client is requesting to upload + # + # returns { filename: url } + # where the url is the upload url for the file that the conan client will use + desc 'Package Upload Urls' do + detail 'This feature was introduced in GitLab 12.4' + end + params do + requires :conan_package_reference, type: String, desc: 'Conan package ID' + end + route_setting :authentication, job_token_allowed: true + post 'packages/:conan_package_reference/upload_urls' do + authorize!(:read_package, project) + + status 200 + upload_urls = package_upload_urls(::Packages::Conan::FileMetadatum::PACKAGE_FILES) + + present upload_urls, with: ::API::Entities::ConanPackage::ConanUploadUrls + end + + desc 'Recipe Upload Urls' do + detail 'This feature was introduced in GitLab 12.4' + end + route_setting :authentication, job_token_allowed: true + post 'upload_urls' do + authorize!(:read_package, project) + + status 200 + upload_urls = recipe_upload_urls(::Packages::Conan::FileMetadatum::RECIPE_FILES) + + present upload_urls, with: ::API::Entities::ConanPackage::ConanUploadUrls + end + + desc 'Delete Package' do + detail 'This feature was introduced in GitLab 12.5' + end + route_setting :authentication, job_token_allowed: true + delete do + authorize!(:destroy_package, project) + + track_event('delete_package') + + package.destroy + end + end + + params do + requires :package_name, type: String, regexp: PACKAGE_COMPONENT_REGEX, desc: 'Package name' + requires :package_version, type: String, regexp: PACKAGE_COMPONENT_REGEX, desc: 'Package version' + requires :package_username, type: String, regexp: PACKAGE_COMPONENT_REGEX, desc: 'Package username' + requires :package_channel, type: String, regexp: PACKAGE_COMPONENT_REGEX, desc: 'Package channel' + requires :recipe_revision, type: String, regexp: CONAN_REVISION_REGEX, desc: 'Conan Recipe Revision' + end + namespace 'files/:package_name/:package_version/:package_username/:package_channel/:recipe_revision', requirements: PACKAGE_REQUIREMENTS do + before do + authenticate_non_get! + end + + params do + requires :file_name, type: String, desc: 'Package file name', regexp: Gitlab::Regex.conan_file_name_regex + end + namespace 'export/:file_name', requirements: FILE_NAME_REQUIREMENTS do + desc 'Download recipe files' do + detail 'This feature was introduced in GitLab 12.6' + end + route_setting :authentication, job_token_allowed: true + get do + download_package_file(:recipe_file) + end + + desc 'Upload recipe package files' do + detail 'This feature was introduced in GitLab 12.6' + end + params do + use :workhorse_upload_params + end + route_setting :authentication, job_token_allowed: true + put do + upload_package_file(:recipe_file) + end + + desc 'Workhorse authorize the conan recipe file' do + detail 'This feature was introduced in GitLab 12.6' + end + route_setting :authentication, job_token_allowed: true + put 'authorize' do + authorize_workhorse!(subject: project) + end + end + + params do + requires :conan_package_reference, type: String, desc: 'Conan Package ID' + requires :package_revision, type: String, desc: 'Conan Package Revision' + requires :file_name, type: String, desc: 'Package file name', regexp: Gitlab::Regex.conan_file_name_regex + end + namespace 'package/:conan_package_reference/:package_revision/:file_name', requirements: FILE_NAME_REQUIREMENTS do + desc 'Download package files' do + detail 'This feature was introduced in GitLab 12.5' + end + route_setting :authentication, job_token_allowed: true + get do + download_package_file(:package_file) + end + + desc 'Workhorse authorize the conan package file' do + detail 'This feature was introduced in GitLab 12.6' + end + route_setting :authentication, job_token_allowed: true + put 'authorize' do + authorize_workhorse!(subject: project) + end + + desc 'Upload package files' do + detail 'This feature was introduced in GitLab 12.6' + end + params do + use :workhorse_upload_params + end + route_setting :authentication, job_token_allowed: true + put do + upload_package_file(:package_file) + end + end + end + end + + helpers do + include Gitlab::Utils::StrongMemoize + include ::API::Helpers::RelatedResourcesHelpers + include ::API::Helpers::Packages::Conan::ApiHelpers + end + end +end diff --git a/lib/api/entities/go_module_version.rb b/lib/api/entities/go_module_version.rb new file mode 100644 index 00000000000..643e25df9e0 --- /dev/null +++ b/lib/api/entities/go_module_version.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +module API + module Entities + class GoModuleVersion < Grape::Entity + expose :name, as: 'Version' + expose :time, as: 'Time' + end + end +end diff --git a/lib/api/entities/package.rb b/lib/api/entities/package.rb index 670965b225c..73473f16da9 100644 --- a/lib/api/entities/package.rb +++ b/lib/api/entities/package.rb @@ -13,7 +13,9 @@ module API expose :_links do expose :web_path do |package| - ::Gitlab::Routing.url_helpers.project_package_path(package.project, package) + if ::Gitlab.ee? + ::Gitlab::Routing.url_helpers.project_package_path(package.project, package) + end end expose :delete_api_path, if: can_destroy(:package, &:project) do |package| diff --git a/lib/api/go_proxy.rb b/lib/api/go_proxy.rb new file mode 100755 index 00000000000..c0207f9169c --- /dev/null +++ b/lib/api/go_proxy.rb @@ -0,0 +1,135 @@ +# frozen_string_literal: true +module API + class GoProxy < Grape::API::Instance + helpers Gitlab::Golang + helpers ::API::Helpers::PackagesHelpers + + # basic semver, except case encoded (A => !a) + MODULE_VERSION_REGEX = /v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([-.!a-z0-9]+))?(?:\+([-.!a-z0-9]+))?/.freeze + + MODULE_VERSION_REQUIREMENTS = { module_version: MODULE_VERSION_REGEX }.freeze + + before { require_packages_enabled! } + + helpers do + def case_decode(str) + # Converts "github.com/!azure" to "github.com/Azure" + # + # From `go help goproxy`: + # + # > To avoid problems when serving from case-sensitive file systems, + # > the <module> and <version> elements are case-encoded, replacing + # > every uppercase letter with an exclamation mark followed by the + # > corresponding lower-case letter: github.com/Azure encodes as + # > github.com/!azure. + + str.gsub(/![[:alpha:]]/) { |s| s[1..].upcase } + end + + def find_project!(id) + # based on API::Helpers::Packages::BasicAuthHelpers#authorized_project_find! + + project = find_project(id) + + return project if project && can?(current_user, :read_project, project) + + if current_user + not_found!('Project') + else + unauthorized! + end + end + + def find_module + not_found! unless Feature.enabled?(:go_proxy, user_project) + + module_name = case_decode params[:module_name] + bad_request!('Module Name') if module_name.blank? + + mod = ::Packages::Go::ModuleFinder.new(user_project, module_name).execute + + not_found! unless mod + + mod + end + + def find_version + module_version = case_decode params[:module_version] + ver = ::Packages::Go::VersionFinder.new(find_module).find(module_version) + + not_found! unless ver&.valid? + + ver + + rescue ArgumentError + not_found! + end + end + + params do + requires :id, type: String, desc: 'The ID of a project' + requires :module_name, type: String, desc: 'Module name', coerce_with: ->(val) { CGI.unescape(val) } + end + route_setting :authentication, job_token_allowed: true, basic_auth_personal_access_token: true + resource :projects, requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS do + before do + authorize_read_package! + end + + namespace ':id/packages/go/*module_name/@v' do + desc 'Get all tagged versions for a given Go module' do + detail 'See `go help goproxy`, GET $GOPROXY/<module>/@v/list. This feature was introduced in GitLab 13.1.' + end + get 'list' do + mod = find_module + + content_type 'text/plain' + mod.versions.map { |t| t.name }.join("\n") + end + + desc 'Get information about the given module version' do + detail 'See `go help goproxy`, GET $GOPROXY/<module>/@v/<version>.info. This feature was introduced in GitLab 13.1.' + success ::API::Entities::GoModuleVersion + end + params do + requires :module_version, type: String, desc: 'Module version' + end + get ':module_version.info', requirements: MODULE_VERSION_REQUIREMENTS do + ver = find_version + + present ::Packages::Go::ModuleVersionPresenter.new(ver), with: ::API::Entities::GoModuleVersion + end + + desc 'Get the module file of the given module version' do + detail 'See `go help goproxy`, GET $GOPROXY/<module>/@v/<version>.mod. This feature was introduced in GitLab 13.1.' + end + params do + requires :module_version, type: String, desc: 'Module version' + end + get ':module_version.mod', requirements: MODULE_VERSION_REQUIREMENTS do + ver = find_version + + content_type 'text/plain' + ver.gomod + end + + desc 'Get a zip of the source of the given module version' do + detail 'See `go help goproxy`, GET $GOPROXY/<module>/@v/<version>.zip. This feature was introduced in GitLab 13.1.' + end + params do + requires :module_version, type: String, desc: 'Module version' + end + get ':module_version.zip', requirements: MODULE_VERSION_REQUIREMENTS do + ver = find_version + + content_type 'application/zip' + env['api.format'] = :binary + header['Content-Disposition'] = ActionDispatch::Http::ContentDisposition.format(disposition: 'attachment', filename: ver.name + '.zip') + header['Content-Transfer-Encoding'] = 'binary' + status :ok + body ver.archive.string + end + end + end + end +end diff --git a/lib/api/group_packages.rb b/lib/api/group_packages.rb new file mode 100644 index 00000000000..aa047e260f5 --- /dev/null +++ b/lib/api/group_packages.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +module API + class GroupPackages < Grape::API::Instance + include PaginationParams + + before do + authorize_packages_access!(user_group) + end + + helpers ::API::Helpers::PackagesHelpers + + params do + requires :id, type: String, desc: "Group's ID or path" + optional :exclude_subgroups, type: Boolean, default: false, desc: 'Determines if subgroups should be excluded' + end + resource :groups, requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS do + desc 'Get all project packages within a group' do + detail 'This feature was introduced in GitLab 12.5' + success ::API::Entities::Package + end + params do + use :pagination + optional :order_by, type: String, values: %w[created_at name version type project_path], default: 'created_at', + desc: 'Return packages ordered by `created_at`, `name`, `version` or `type` fields.' + optional :sort, type: String, values: %w[asc desc], default: 'asc', + desc: 'Return packages sorted in `asc` or `desc` order.' + optional :package_type, type: String, values: Packages::Package.package_types.keys, + desc: 'Return packages of a certain type' + optional :package_name, type: String, + desc: 'Return packages with this name' + end + get ':id/packages' do + packages = Packages::GroupPackagesFinder.new( + current_user, + user_group, + declared(params).slice(:exclude_subgroups, :order_by, :sort, :package_type, :package_name) + ).execute + + present paginate(packages), with: ::API::Entities::Package, user: current_user, group: true + end + end + end +end diff --git a/lib/api/helpers/packages/basic_auth_helpers.rb b/lib/api/helpers/packages/basic_auth_helpers.rb new file mode 100644 index 00000000000..835b5f4614c --- /dev/null +++ b/lib/api/helpers/packages/basic_auth_helpers.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +module API + module Helpers + module Packages + module BasicAuthHelpers + module Constants + AUTHENTICATE_REALM_HEADER = 'Www-Authenticate: Basic realm' + AUTHENTICATE_REALM_NAME = 'GitLab Packages Registry' + end + + include Constants + + def find_personal_access_token + find_personal_access_token_from_http_basic_auth + end + + def unauthorized_user_project + @unauthorized_user_project ||= find_project(params[:id]) + end + + def unauthorized_user_project! + unauthorized_user_project || not_found! + end + + def authorized_user_project + @authorized_user_project ||= authorized_project_find! + end + + def authorized_project_find! + project = unauthorized_user_project + + unless project && can?(current_user, :read_project, project) + return unauthorized_or! { not_found! } + end + + project + end + + def authorize!(action, subject = :global, reason = nil) + return if can?(current_user, action, subject) + + unauthorized_or! { forbidden!(reason) } + end + + def unauthorized_or! + current_user ? yield : unauthorized_with_header! + end + + def unauthorized_with_header! + header(AUTHENTICATE_REALM_HEADER, AUTHENTICATE_REALM_NAME) + unauthorized! + end + end + end + end +end diff --git a/lib/api/helpers/packages/conan/api_helpers.rb b/lib/api/helpers/packages/conan/api_helpers.rb new file mode 100644 index 00000000000..30e690a5a1d --- /dev/null +++ b/lib/api/helpers/packages/conan/api_helpers.rb @@ -0,0 +1,225 @@ +# frozen_string_literal: true + +module API + module Helpers + module Packages + module Conan + module ApiHelpers + def present_download_urls(entity) + authorize!(:read_package, project) + + presenter = ::Packages::Conan::PackagePresenter.new( + recipe, + current_user, + project, + conan_package_reference: params[:conan_package_reference] + ) + + render_api_error!("No recipe manifest found", 404) if yield(presenter).empty? + + present presenter, with: entity + end + + def present_package_download_urls + present_download_urls(::API::Entities::ConanPackage::ConanPackageManifest, &:package_urls) + end + + def present_recipe_download_urls + present_download_urls(::API::Entities::ConanPackage::ConanRecipeManifest, &:recipe_urls) + end + + def recipe_upload_urls(file_names) + { upload_urls: Hash[ + file_names.collect do |file_name| + [file_name, recipe_file_upload_url(file_name)] + end + ] } + end + + def package_upload_urls(file_names) + { upload_urls: Hash[ + file_names.collect do |file_name| + [file_name, package_file_upload_url(file_name)] + end + ] } + end + + def package_file_upload_url(file_name) + expose_url( + api_v4_packages_conan_v1_files_package_path( + package_name: params[:package_name], + package_version: params[:package_version], + package_username: params[:package_username], + package_channel: params[:package_channel], + recipe_revision: '0', + conan_package_reference: params[:conan_package_reference], + package_revision: '0', + file_name: file_name + ) + ) + end + + def recipe_file_upload_url(file_name) + expose_url( + api_v4_packages_conan_v1_files_export_path( + package_name: params[:package_name], + package_version: params[:package_version], + package_username: params[:package_username], + package_channel: params[:package_channel], + recipe_revision: '0', + file_name: file_name + ) + ) + end + + def recipe + "%{package_name}/%{package_version}@%{package_username}/%{package_channel}" % params.symbolize_keys + end + + def project + strong_memoize(:project) do + full_path = ::Packages::Conan::Metadatum.full_path_from(package_username: params[:package_username]) + Project.find_by_full_path(full_path) + end + end + + def package + strong_memoize(:package) do + project.packages + .with_name(params[:package_name]) + .with_version(params[:package_version]) + .with_conan_channel(params[:package_channel]) + .order_created + .last + end + end + + def token + strong_memoize(:token) do + token = nil + token = ::Gitlab::ConanToken.from_personal_access_token(access_token) if access_token + token = ::Gitlab::ConanToken.from_deploy_token(deploy_token_from_request) if deploy_token_from_request + token = ::Gitlab::ConanToken.from_job(find_job_from_token) if find_job_from_token + token + end + end + + def download_package_file(file_type) + authorize!(:read_package, project) + + package_file = ::Packages::Conan::PackageFileFinder + .new( + package, + params[:file_name].to_s, + conan_file_type: file_type, + conan_package_reference: params[:conan_package_reference] + ).execute! + + track_event('pull_package') if params[:file_name] == ::Packages::Conan::FileMetadatum::PACKAGE_BINARY + + present_carrierwave_file!(package_file.file) + end + + def find_or_create_package + package || ::Packages::Conan::CreatePackageService.new(project, current_user, params).execute + end + + def track_push_package_event + if params[:file_name] == ::Packages::Conan::FileMetadatum::PACKAGE_BINARY && params['file.size'] > 0 + track_event('push_package') + end + end + + def create_package_file_with_type(file_type, current_package) + unless params['file.size'] == 0 + # conan sends two upload requests, the first has no file, so we skip record creation if file.size == 0 + ::Packages::Conan::CreatePackageFileService.new(current_package, uploaded_package_file, params.merge(conan_file_type: file_type)).execute + end + end + + def upload_package_file(file_type) + authorize_upload!(project) + + current_package = find_or_create_package + + track_push_package_event + + create_package_file_with_type(file_type, current_package) + rescue ObjectStorage::RemoteStoreError => e + Gitlab::ErrorTracking.track_exception(e, file_name: params[:file_name], project_id: project.id) + + forbidden! + end + + def find_personal_access_token + personal_access_token = find_personal_access_token_from_conan_jwt || + find_personal_access_token_from_http_basic_auth + + personal_access_token + end + + def find_user_from_job_token + return unless route_authentication_setting[:job_token_allowed] + + job = find_job_from_token || raise(::Gitlab::Auth::UnauthorizedError) + + job.user + end + + def deploy_token_from_request + find_deploy_token_from_conan_jwt || find_deploy_token_from_http_basic_auth + end + + def find_job_from_token + find_job_from_conan_jwt || find_job_from_http_basic_auth + end + + # We need to override this one because it + # looks into Bearer authorization header + def find_oauth_access_token + end + + def find_personal_access_token_from_conan_jwt + token = decode_oauth_token_from_jwt + + return unless token + + PersonalAccessToken.find_by_id_and_user_id(token.access_token_id, token.user_id) + end + + def find_deploy_token_from_conan_jwt + token = decode_oauth_token_from_jwt + + return unless token + + deploy_token = DeployToken.active.find_by_token(token.access_token_id.to_s) + # note: uesr_id is not a user record id, but is the attribute set on ConanToken + return if token.user_id != deploy_token&.username + + deploy_token + end + + def find_job_from_conan_jwt + token = decode_oauth_token_from_jwt + + return unless token + + ::Ci::Build.find_by_token(token.access_token_id.to_s) + end + + def decode_oauth_token_from_jwt + jwt = Doorkeeper::OAuth::Token.from_bearer_authorization(current_request) + + return unless jwt + + token = ::Gitlab::ConanToken.decode(jwt) + + return unless token && token.access_token_id && token.user_id + + token + end + end + end + end + end +end diff --git a/lib/api/helpers/packages/dependency_proxy_helpers.rb b/lib/api/helpers/packages/dependency_proxy_helpers.rb new file mode 100644 index 00000000000..254af7690a2 --- /dev/null +++ b/lib/api/helpers/packages/dependency_proxy_helpers.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +module API + module Helpers + module Packages + module DependencyProxyHelpers + REGISTRY_BASE_URLS = { + npm: 'https://registry.npmjs.org/' + }.freeze + + def redirect_registry_request(forward_to_registry, package_type, options) + if forward_to_registry && redirect_registry_request_available? + redirect(registry_url(package_type, options)) + else + yield + end + end + + def registry_url(package_type, options) + base_url = REGISTRY_BASE_URLS[package_type] + + raise ArgumentError, "Can't build registry_url for package_type #{package_type}" unless base_url + + case package_type + when :npm + "#{base_url}#{options[:package_name]}" + end + end + + def redirect_registry_request_available? + ::Gitlab::CurrentSettings.current_application_settings.npm_package_requests_forwarding + end + end + end + end +end diff --git a/lib/api/helpers/packages_helpers.rb b/lib/api/helpers/packages_helpers.rb new file mode 100644 index 00000000000..c6037d52de9 --- /dev/null +++ b/lib/api/helpers/packages_helpers.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +module API + module Helpers + module PackagesHelpers + MAX_PACKAGE_FILE_SIZE = 50.megabytes.freeze + + def require_packages_enabled! + not_found! unless ::Gitlab.config.packages.enabled + end + + def require_dependency_proxy_enabled! + not_found! unless ::Gitlab.config.dependency_proxy.enabled + end + + def authorize_read_package!(subject = user_project) + authorize!(:read_package, subject) + end + + def authorize_create_package!(subject = user_project) + authorize!(:create_package, subject) + end + + def authorize_destroy_package!(subject = user_project) + authorize!(:destroy_package, subject) + end + + def authorize_packages_access!(subject = user_project) + require_packages_enabled! + authorize_read_package!(subject) + end + + def authorize_workhorse!(subject: user_project, has_length: true, maximum_size: MAX_PACKAGE_FILE_SIZE) + authorize_upload!(subject) + + Gitlab::Workhorse.verify_api_request!(headers) + + status 200 + content_type Gitlab::Workhorse::INTERNAL_API_CONTENT_TYPE + + params = { has_length: has_length } + params[:maximum_size] = maximum_size unless has_length + ::Packages::PackageFileUploader.workhorse_authorize(params) + end + + def authorize_upload!(subject = user_project) + authorize_create_package!(subject) + require_gitlab_workhorse! + end + end + end +end diff --git a/lib/api/helpers/packages_manager_clients_helpers.rb b/lib/api/helpers/packages_manager_clients_helpers.rb new file mode 100644 index 00000000000..7b5d0dd708d --- /dev/null +++ b/lib/api/helpers/packages_manager_clients_helpers.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +module API + module Helpers + module PackagesManagerClientsHelpers + extend Grape::API::Helpers + include ::API::Helpers::PackagesHelpers + + params :workhorse_upload_params do + optional 'file.path', type: String, desc: 'Path to locally stored body (generated by Workhorse)' + optional 'file.name', type: String, desc: 'Real filename as send in Content-Disposition (generated by Workhorse)' + optional 'file.type', type: String, desc: 'Real content type as send in Content-Type (generated by Workhorse)' + optional 'file.size', type: Integer, desc: 'Real size of file (generated by Workhorse)' + optional 'file.md5', type: String, desc: 'MD5 checksum of the file (generated by Workhorse)' + optional 'file.sha1', type: String, desc: 'SHA1 checksum of the file (generated by Workhorse)' + optional 'file.sha256', type: String, desc: 'SHA256 checksum of the file (generated by Workhorse)' + end + + def find_personal_access_token_from_http_basic_auth + return unless headers + + token = decode_token + + return unless token + + PersonalAccessToken.find_by_token(token) + end + + def find_job_from_http_basic_auth + return unless headers + + token = decode_token + + return unless token + + ::Ci::Build.find_by_token(token) + end + + def find_deploy_token_from_http_basic_auth + return unless headers + + token = decode_token + + return unless token + + DeployToken.active.find_by_token(token) + end + + def uploaded_package_file(param_name = :file) + uploaded_file = UploadedFile.from_params(params, param_name, ::Packages::PackageFileUploader.workhorse_local_upload_path) + bad_request!('Missing package file!') unless uploaded_file + uploaded_file + end + + private + + def decode_token + encoded_credentials = headers['Authorization'].to_s.split('Basic ', 2).second + Base64.decode64(encoded_credentials || '').split(':', 2).second + end + end + end +end diff --git a/lib/api/maven_packages.rb b/lib/api/maven_packages.rb new file mode 100644 index 00000000000..32a45c59cfa --- /dev/null +++ b/lib/api/maven_packages.rb @@ -0,0 +1,251 @@ +# frozen_string_literal: true +module API + class MavenPackages < Grape::API::Instance + MAVEN_ENDPOINT_REQUIREMENTS = { + file_name: API::NO_SLASH_URL_PART_REGEX + }.freeze + + content_type :md5, 'text/plain' + content_type :sha1, 'text/plain' + content_type :binary, 'application/octet-stream' + + rescue_from ActiveRecord::RecordInvalid do |e| + render_api_error!(e.message, 400) + end + + before do + require_packages_enabled! + authenticate_non_get! + end + + helpers ::API::Helpers::PackagesHelpers + + helpers do + def extract_format(file_name) + name, _, format = file_name.rpartition('.') + + if %w(md5 sha1).include?(format) + [name, format] + else + [file_name, format] + end + end + + def verify_package_file(package_file, uploaded_file) + stored_sha1 = Digest::SHA256.hexdigest(package_file.file_sha1) + expected_sha1 = uploaded_file.sha256 + + if stored_sha1 == expected_sha1 + no_content! + else + conflict! + end + end + + def find_project_by_path(path) + project_path = path.rpartition('/').first + Project.find_by_full_path(project_path) + end + + def jar_file?(format) + format == 'jar' + end + + def present_carrierwave_file_with_head_support!(file, supports_direct_download: true) + if head_request_on_aws_file?(file, supports_direct_download) && !file.file_storage? + return redirect(signed_head_url(file)) + end + + present_carrierwave_file!(file, supports_direct_download: supports_direct_download) + end + + def signed_head_url(file) + fog_storage = ::Fog::Storage.new(file.fog_credentials) + fog_dir = fog_storage.directories.new(key: file.fog_directory) + fog_file = fog_dir.files.new(key: file.path) + expire_at = ::Fog::Time.now + file.fog_authenticated_url_expiration + + fog_file.collection.head_url(fog_file.key, expire_at) + end + + def head_request_on_aws_file?(file, supports_direct_download) + Gitlab.config.packages.object_store.enabled && + supports_direct_download && + file.class.direct_download_enabled? && + request.head? && + file.fog_credentials[:provider] == 'AWS' + end + end + + desc 'Download the maven package file at instance level' do + detail 'This feature was introduced in GitLab 11.6' + end + params do + requires :path, type: String, desc: 'Package path' + requires :file_name, type: String, desc: 'Package file name' + end + route_setting :authentication, job_token_allowed: true, deploy_token_allowed: true + get 'packages/maven/*path/:file_name', requirements: MAVEN_ENDPOINT_REQUIREMENTS do + file_name, format = extract_format(params[:file_name]) + + # To avoid name collision we require project path and project package be the same. + # For packages that have different name from the project we should use + # the endpoint that includes project id + project = find_project_by_path(params[:path]) + + authorize_read_package!(project) + + package = ::Packages::Maven::PackageFinder + .new(params[:path], current_user, project: project).execute! + + package_file = ::Packages::PackageFileFinder + .new(package, file_name).execute! + + case format + when 'md5' + package_file.file_md5 + when 'sha1' + package_file.file_sha1 + else + track_event('pull_package') if jar_file?(format) + present_carrierwave_file_with_head_support!(package_file.file) + end + end + + desc 'Download the maven package file at a group level' do + detail 'This feature was introduced in GitLab 11.7' + end + params do + requires :id, type: String, desc: 'The ID of a group' + end + resource :groups, requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS do + params do + requires :path, type: String, desc: 'Package path' + requires :file_name, type: String, desc: 'Package file name' + end + route_setting :authentication, job_token_allowed: true, deploy_token_allowed: true + get ':id/-/packages/maven/*path/:file_name', requirements: MAVEN_ENDPOINT_REQUIREMENTS do + file_name, format = extract_format(params[:file_name]) + + group = find_group(params[:id]) + + not_found!('Group') unless can?(current_user, :read_group, group) + + package = ::Packages::Maven::PackageFinder + .new(params[:path], current_user, group: group).execute! + + authorize_read_package!(package.project) + + package_file = ::Packages::PackageFileFinder + .new(package, file_name).execute! + + case format + when 'md5' + package_file.file_md5 + when 'sha1' + package_file.file_sha1 + else + track_event('pull_package') if jar_file?(format) + + present_carrierwave_file_with_head_support!(package_file.file) + end + end + end + + params do + requires :id, type: String, desc: 'The ID of a project' + end + resource :projects, requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS do + desc 'Download the maven package file' do + detail 'This feature was introduced in GitLab 11.3' + end + params do + requires :path, type: String, desc: 'Package path' + requires :file_name, type: String, desc: 'Package file name' + end + route_setting :authentication, job_token_allowed: true, deploy_token_allowed: true + get ':id/packages/maven/*path/:file_name', requirements: MAVEN_ENDPOINT_REQUIREMENTS do + authorize_read_package!(user_project) + + file_name, format = extract_format(params[:file_name]) + + package = ::Packages::Maven::PackageFinder + .new(params[:path], current_user, project: user_project).execute! + + package_file = ::Packages::PackageFileFinder + .new(package, file_name).execute! + + case format + when 'md5' + package_file.file_md5 + when 'sha1' + package_file.file_sha1 + else + track_event('pull_package') if jar_file?(format) + + present_carrierwave_file_with_head_support!(package_file.file) + end + end + + desc 'Workhorse authorize the maven package file upload' do + detail 'This feature was introduced in GitLab 11.3' + end + params do + requires :path, type: String, desc: 'Package path' + requires :file_name, type: String, desc: 'Package file name', regexp: Gitlab::Regex.maven_file_name_regex + end + route_setting :authentication, job_token_allowed: true, deploy_token_allowed: true + put ':id/packages/maven/*path/:file_name/authorize', requirements: MAVEN_ENDPOINT_REQUIREMENTS do + authorize_upload! + + status 200 + content_type Gitlab::Workhorse::INTERNAL_API_CONTENT_TYPE + ::Packages::PackageFileUploader.workhorse_authorize(has_length: true) + end + + desc 'Upload the maven package file' do + detail 'This feature was introduced in GitLab 11.3' + end + params do + requires :path, type: String, desc: 'Package path' + requires :file_name, type: String, desc: 'Package file name', regexp: Gitlab::Regex.maven_file_name_regex + requires :file, type: ::API::Validations::Types::WorkhorseFile, desc: 'The package file to be published (generated by Multipart middleware)' + end + route_setting :authentication, job_token_allowed: true, deploy_token_allowed: true + put ':id/packages/maven/*path/:file_name', requirements: MAVEN_ENDPOINT_REQUIREMENTS do + authorize_upload! + + file_name, format = extract_format(params[:file_name]) + + package = ::Packages::Maven::FindOrCreatePackageService + .new(user_project, current_user, params.merge(build: current_authenticated_job)).execute + + case format + when 'sha1' + # After uploading a file, Maven tries to upload a sha1 and md5 version of it. + # Since we store md5/sha1 in database we simply need to validate our hash + # against one uploaded by Maven. We do this for `sha1` format. + package_file = ::Packages::PackageFileFinder + .new(package, file_name).execute! + + verify_package_file(package_file, params[:file]) + when 'md5' + nil + else + track_event('push_package') if jar_file?(format) + + file_params = { + file: params[:file], + size: params['file.size'], + file_name: file_name, + file_type: params['file.type'], + file_sha1: params['file.sha1'], + file_md5: params['file.md5'] + } + + ::Packages::CreatePackageFileService.new(package, file_params).execute + end + end + end + end +end diff --git a/lib/api/npm_packages.rb b/lib/api/npm_packages.rb new file mode 100644 index 00000000000..21ca57b7985 --- /dev/null +++ b/lib/api/npm_packages.rb @@ -0,0 +1,173 @@ +# frozen_string_literal: true +module API + class NpmPackages < Grape::API::Instance + helpers ::API::Helpers::PackagesHelpers + helpers ::API::Helpers::Packages::DependencyProxyHelpers + + NPM_ENDPOINT_REQUIREMENTS = { + package_name: API::NO_SLASH_URL_PART_REGEX + }.freeze + + rescue_from ActiveRecord::RecordInvalid do |e| + render_api_error!(e.message, 400) + end + + before do + require_packages_enabled! + authenticate_non_get! + end + + helpers do + def project_by_package_name + strong_memoize(:project_by_package_name) do + ::Packages::Package.npm.with_name(params[:package_name]).first&.project + end + end + end + + desc 'Get all tags for a given an NPM package' do + detail 'This feature was introduced in GitLab 12.7' + success ::API::Entities::NpmPackageTag + end + params do + requires :package_name, type: String, desc: 'Package name' + end + get 'packages/npm/-/package/*package_name/dist-tags', format: false, requirements: NPM_ENDPOINT_REQUIREMENTS do + package_name = params[:package_name] + + bad_request!('Package Name') if package_name.blank? + + authorize_read_package!(project_by_package_name) + + packages = ::Packages::Npm::PackageFinder.new(project_by_package_name, package_name) + .execute + + present ::Packages::Npm::PackagePresenter.new(package_name, packages), + with: ::API::Entities::NpmPackageTag + end + + params do + requires :package_name, type: String, desc: 'Package name' + requires :tag, type: String, desc: "Package dist-tag" + end + namespace 'packages/npm/-/package/*package_name/dist-tags/:tag', requirements: NPM_ENDPOINT_REQUIREMENTS do + desc 'Create or Update the given tag for the given NPM package and version' do + detail 'This feature was introduced in GitLab 12.7' + end + put format: false do + package_name = params[:package_name] + version = env['api.request.body'] + tag = params[:tag] + + bad_request!('Package Name') if package_name.blank? + bad_request!('Version') if version.blank? + bad_request!('Tag') if tag.blank? + + authorize_create_package!(project_by_package_name) + + package = ::Packages::Npm::PackageFinder + .new(project_by_package_name, package_name) + .find_by_version(version) + not_found!('Package') unless package + + ::Packages::Npm::CreateTagService.new(package, tag).execute + + no_content! + end + + desc 'Deletes the given tag' do + detail 'This feature was introduced in GitLab 12.7' + end + delete format: false do + package_name = params[:package_name] + tag = params[:tag] + + bad_request!('Package Name') if package_name.blank? + bad_request!('Tag') if tag.blank? + + authorize_destroy_package!(project_by_package_name) + + package_tag = ::Packages::TagsFinder + .new(project_by_package_name, package_name, package_type: :npm) + .find_by_name(tag) + + not_found!('Package tag') unless package_tag + + ::Packages::RemoveTagService.new(package_tag).execute + + no_content! + end + end + + desc 'NPM registry endpoint at instance level' do + detail 'This feature was introduced in GitLab 11.8' + end + params do + requires :package_name, type: String, desc: 'Package name' + end + route_setting :authentication, job_token_allowed: true, deploy_token_allowed: true + get 'packages/npm/*package_name', format: false, requirements: NPM_ENDPOINT_REQUIREMENTS do + package_name = params[:package_name] + + redirect_registry_request(project_by_package_name.blank?, :npm, package_name: package_name) do + authorize_read_package!(project_by_package_name) + + packages = ::Packages::Npm::PackageFinder + .new(project_by_package_name, package_name).execute + + present ::Packages::Npm::PackagePresenter.new(package_name, packages), + with: ::API::Entities::NpmPackage + end + end + + params do + requires :id, type: String, desc: 'The ID of a project' + end + resource :projects, requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS do + desc 'Download the NPM tarball' do + detail 'This feature was introduced in GitLab 11.8' + end + params do + requires :package_name, type: String, desc: 'Package name' + requires :file_name, type: String, desc: 'Package file name' + end + route_setting :authentication, job_token_allowed: true, deploy_token_allowed: true + get ':id/packages/npm/*package_name/-/*file_name', format: false do + authorize_read_package!(user_project) + + package = user_project.packages.npm + .by_name_and_file_name(params[:package_name], params[:file_name]) + + package_file = ::Packages::PackageFileFinder + .new(package, params[:file_name]).execute! + + track_event('pull_package') + + present_carrierwave_file!(package_file.file) + end + + desc 'Create NPM package' do + detail 'This feature was introduced in GitLab 11.8' + end + params do + requires :package_name, type: String, desc: 'Package name' + requires :versions, type: Hash, desc: 'Package version info' + end + route_setting :authentication, job_token_allowed: true, deploy_token_allowed: true + put ':id/packages/npm/:package_name', requirements: NPM_ENDPOINT_REQUIREMENTS do + authorize_create_package!(user_project) + + track_event('push_package') + + created_package = ::Packages::Npm::CreatePackageService + .new(user_project, current_user, params.merge(build: current_authenticated_job)).execute + + if created_package[:status] == :error + render_api_error!(created_package[:message], created_package[:http_status]) + else + created_package + end + end + end + end +end diff --git a/lib/api/nuget_packages.rb b/lib/api/nuget_packages.rb new file mode 100644 index 00000000000..eb7d320a0f5 --- /dev/null +++ b/lib/api/nuget_packages.rb @@ -0,0 +1,221 @@ +# frozen_string_literal: true + +# NuGet Package Manager Client API +# +# These API endpoints are not meant to be consumed directly by users. They are +# called by the NuGet package manager client when users run commands +# like `nuget install` or `nuget push`. +module API + class NugetPackages < Grape::API::Instance + helpers ::API::Helpers::PackagesManagerClientsHelpers + helpers ::API::Helpers::Packages::BasicAuthHelpers + + POSITIVE_INTEGER_REGEX = %r{\A[1-9]\d*\z}.freeze + NON_NEGATIVE_INTEGER_REGEX = %r{\A0|[1-9]\d*\z}.freeze + + PACKAGE_FILENAME = 'package.nupkg' + + default_format :json + + rescue_from ArgumentError do |e| + render_api_error!(e.message, 400) + end + + helpers do + def find_packages + packages = package_finder.execute + + not_found!('Packages') unless packages.exists? + + packages + end + + def find_package + package = package_finder(package_version: params[:package_version]).execute + .first + + not_found!('Package') unless package + + package + end + + def package_finder(finder_params = {}) + ::Packages::Nuget::PackageFinder.new( + authorized_user_project, + finder_params.merge(package_name: params[:package_name]) + ) + end + end + + before do + require_packages_enabled! + end + + params do + requires :id, type: String, desc: 'The ID of a project', regexp: POSITIVE_INTEGER_REGEX + end + route_setting :authentication, deploy_token_allowed: true + resource :projects, requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS do + before do + authorized_user_project + end + + namespace ':id/packages/nuget' do + # https://docs.microsoft.com/en-us/nuget/api/service-index + desc 'The NuGet Service Index' do + detail 'This feature was introduced in GitLab 12.6' + end + route_setting :authentication, deploy_token_allowed: true + get 'index', format: :json do + authorize_read_package!(authorized_user_project) + + track_event('nuget_service_index') + + present ::Packages::Nuget::ServiceIndexPresenter.new(authorized_user_project), + with: ::API::Entities::Nuget::ServiceIndex + end + + # https://docs.microsoft.com/en-us/nuget/api/package-publish-resource + desc 'The NuGet Package Publish endpoint' do + detail 'This feature was introduced in GitLab 12.6' + end + params do + requires :package, type: ::API::Validations::Types::WorkhorseFile, desc: 'The package file to be published (generated by Multipart middleware)' + end + route_setting :authentication, deploy_token_allowed: true + put do + authorize_upload!(authorized_user_project) + + file_params = params.merge( + file: params[:package], + file_name: PACKAGE_FILENAME + ) + + package = ::Packages::Nuget::CreatePackageService.new(authorized_user_project, current_user) + .execute + + package_file = ::Packages::CreatePackageFileService.new(package, file_params) + .execute + + track_event('push_package') + + ::Packages::Nuget::ExtractionWorker.perform_async(package_file.id) # rubocop:disable CodeReuse/Worker + + created! + rescue ObjectStorage::RemoteStoreError => e + Gitlab::ErrorTracking.track_exception(e, extra: { file_name: params[:file_name], project_id: authorized_user_project.id }) + + forbidden! + end + route_setting :authentication, deploy_token_allowed: true + put 'authorize' do + authorize_workhorse!(subject: authorized_user_project, has_length: false) + end + + params do + requires :package_name, type: String, desc: 'The NuGet package name', regexp: API::NO_SLASH_URL_PART_REGEX + end + namespace '/metadata/*package_name' do + before do + authorize_read_package!(authorized_user_project) + end + + # https://docs.microsoft.com/en-us/nuget/api/registration-base-url-resource + desc 'The NuGet Metadata Service - Package name level' do + detail 'This feature was introduced in GitLab 12.8' + end + route_setting :authentication, deploy_token_allowed: true + get 'index', format: :json do + present ::Packages::Nuget::PackagesMetadataPresenter.new(find_packages), + with: ::API::Entities::Nuget::PackagesMetadata + end + + desc 'The NuGet Metadata Service - Package name and version level' do + detail 'This feature was introduced in GitLab 12.8' + end + params do + requires :package_version, type: String, desc: 'The NuGet package version', regexp: API::NO_SLASH_URL_PART_REGEX + end + route_setting :authentication, deploy_token_allowed: true + get '*package_version', format: :json do + present ::Packages::Nuget::PackageMetadataPresenter.new(find_package), + with: ::API::Entities::Nuget::PackageMetadata + end + end + + # https://docs.microsoft.com/en-us/nuget/api/package-base-address-resource + params do + requires :package_name, type: String, desc: 'The NuGet package name', regexp: API::NO_SLASH_URL_PART_REGEX + end + namespace '/download/*package_name' do + before do + authorize_read_package!(authorized_user_project) + end + + desc 'The NuGet Content Service - index request' do + detail 'This feature was introduced in GitLab 12.8' + end + route_setting :authentication, deploy_token_allowed: true + get 'index', format: :json do + present ::Packages::Nuget::PackagesVersionsPresenter.new(find_packages), + with: ::API::Entities::Nuget::PackagesVersions + end + + desc 'The NuGet Content Service - content request' do + detail 'This feature was introduced in GitLab 12.8' + end + params do + requires :package_version, type: String, desc: 'The NuGet package version', regexp: API::NO_SLASH_URL_PART_REGEX + requires :package_filename, type: String, desc: 'The NuGet package filename', regexp: API::NO_SLASH_URL_PART_REGEX + end + route_setting :authentication, deploy_token_allowed: true + get '*package_version/*package_filename', format: :nupkg do + filename = "#{params[:package_filename]}.#{params[:format]}" + package_file = ::Packages::PackageFileFinder.new(find_package, filename, with_file_name_like: true) + .execute + + not_found!('Package') unless package_file + + track_event('pull_package') + + # nuget and dotnet don't support 302 Moved status codes, supports_direct_download has to be set to false + present_carrierwave_file!(package_file.file, supports_direct_download: false) + end + end + + params do + requires :q, type: String, desc: 'The search term' + optional :skip, type: Integer, desc: 'The number of results to skip', default: 0, regexp: NON_NEGATIVE_INTEGER_REGEX + optional :take, type: Integer, desc: 'The number of results to return', default: Kaminari.config.default_per_page, regexp: POSITIVE_INTEGER_REGEX + optional :prerelease, type: Boolean, desc: 'Include prerelease versions', default: true + end + namespace '/query' do + before do + authorize_read_package!(authorized_user_project) + end + + # https://docs.microsoft.com/en-us/nuget/api/search-query-service-resource + desc 'The NuGet Search Service' do + detail 'This feature was introduced in GitLab 12.8' + end + route_setting :authentication, deploy_token_allowed: true + get format: :json do + search_options = { + include_prerelease_versions: params[:prerelease], + per_page: params[:take], + padding: params[:skip] + } + search = Packages::Nuget::SearchService + .new(authorized_user_project, params[:q], search_options) + .execute + + track_event('search_package') + + present ::Packages::Nuget::SearchResultsPresenter.new(search), + with: ::API::Entities::Nuget::SearchResults + end + end + end + end + end +end diff --git a/lib/api/package_files.rb b/lib/api/package_files.rb new file mode 100644 index 00000000000..17b92df629c --- /dev/null +++ b/lib/api/package_files.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module API + class PackageFiles < Grape::API::Instance + include PaginationParams + + before do + authorize_packages_access!(user_project) + end + + helpers ::API::Helpers::PackagesHelpers + + params do + requires :id, type: String, desc: 'The ID of a project' + requires :package_id, type: Integer, desc: 'The ID of a package' + end + resource :projects, requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS do + desc 'Get all package files' do + detail 'This feature was introduced in GitLab 11.8' + success ::API::Entities::PackageFile + end + params do + use :pagination + end + get ':id/packages/:package_id/package_files' do + package = ::Packages::PackageFinder + .new(user_project, params[:package_id]).execute + + present paginate(package.package_files), with: ::API::Entities::PackageFile + end + end + end +end diff --git a/lib/api/project_packages.rb b/lib/api/project_packages.rb new file mode 100644 index 00000000000..359514f1f78 --- /dev/null +++ b/lib/api/project_packages.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +module API + class ProjectPackages < Grape::API::Instance + include PaginationParams + + before do + authorize_packages_access!(user_project) + end + + helpers ::API::Helpers::PackagesHelpers + + params do + requires :id, type: String, desc: 'The ID of a project' + end + resource :projects, requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS do + desc 'Get all project packages' do + detail 'This feature was introduced in GitLab 11.8' + success ::API::Entities::Package + end + params do + use :pagination + optional :order_by, type: String, values: %w[created_at name version type], default: 'created_at', + desc: 'Return packages ordered by `created_at`, `name`, `version` or `type` fields.' + optional :sort, type: String, values: %w[asc desc], default: 'asc', + desc: 'Return packages sorted in `asc` or `desc` order.' + optional :package_type, type: String, values: Packages::Package.package_types.keys, + desc: 'Return packages of a certain type' + optional :package_name, type: String, + desc: 'Return packages with this name' + end + get ':id/packages' do + packages = ::Packages::PackagesFinder.new( + user_project, + declared_params.slice(:order_by, :sort, :package_type, :package_name) + ).execute + + present paginate(packages), with: ::API::Entities::Package, user: current_user + end + + desc 'Get a single project package' do + detail 'This feature was introduced in GitLab 11.9' + success ::API::Entities::Package + end + params do + requires :package_id, type: Integer, desc: 'The ID of a package' + end + get ':id/packages/:package_id' do + package = ::Packages::PackageFinder + .new(user_project, params[:package_id]).execute + + present package, with: ::API::Entities::Package, user: current_user + end + + desc 'Remove a package' do + detail 'This feature was introduced in GitLab 11.9' + end + params do + requires :package_id, type: Integer, desc: 'The ID of a package' + end + delete ':id/packages/:package_id' do + authorize_destroy_package!(user_project) + + package = ::Packages::PackageFinder + .new(user_project, params[:package_id]).execute + + destroy_conditionally!(package) + end + end + end +end diff --git a/lib/api/pypi_packages.rb b/lib/api/pypi_packages.rb new file mode 100644 index 00000000000..a6caacd7df8 --- /dev/null +++ b/lib/api/pypi_packages.rb @@ -0,0 +1,148 @@ +# frozen_string_literal: true + +# PyPI Package Manager Client API +# +# These API endpoints are not meant to be consumed directly by users. They are +# called by the PyPI package manager client when users run commands +# like `pip install` or `twine upload`. +module API + class PypiPackages < Grape::API::Instance + helpers ::API::Helpers::PackagesManagerClientsHelpers + helpers ::API::Helpers::RelatedResourcesHelpers + helpers ::API::Helpers::Packages::BasicAuthHelpers + include ::API::Helpers::Packages::BasicAuthHelpers::Constants + + default_format :json + + rescue_from ArgumentError do |e| + render_api_error!(e.message, 400) + end + + rescue_from ActiveRecord::RecordInvalid do |e| + render_api_error!(e.message, 400) + end + + rescue_from ActiveRecord::RecordInvalid do |e| + render_api_error!(e.message, 400) + end + + helpers do + def packages_finder(project = authorized_user_project) + project + .packages + .pypi + .has_version + .processed + end + + def find_package_versions + packages = packages_finder + .with_name(params[:package_name]) + + not_found!('Package') if packages.empty? + + packages + end + end + + before do + require_packages_enabled! + end + + params do + requires :id, type: Integer, desc: 'The ID of a project' + end + + resource :projects, requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS do + before do + unauthorized_user_project! + end + + namespace ':id/packages/pypi' do + desc 'The PyPi package download endpoint' do + detail 'This feature was introduced in GitLab 12.10' + end + + params do + requires :file_identifier, type: String, desc: 'The PyPi package file identifier', file_path: true + requires :sha256, type: String, desc: 'The PyPi package sha256 check sum' + end + + route_setting :authentication, deploy_token_allowed: true + get 'files/:sha256/*file_identifier' do + project = unauthorized_user_project! + + filename = "#{params[:file_identifier]}.#{params[:format]}" + package = packages_finder(project).by_file_name_and_sha256(filename, params[:sha256]) + package_file = ::Packages::PackageFileFinder.new(package, filename, with_file_name_like: false).execute + + track_event('pull_package') + + present_carrierwave_file!(package_file.file, supports_direct_download: true) + end + + desc 'The PyPi Simple Endpoint' do + detail 'This feature was introduced in GitLab 12.10' + end + + params do + requires :package_name, type: String, file_path: true, desc: 'The PyPi package name' + end + + # An Api entry point but returns an HTML file instead of JSON. + # PyPi simple API returns the package descriptor as a simple HTML file. + route_setting :authentication, deploy_token_allowed: true + get 'simple/*package_name', format: :txt do + authorize_read_package!(authorized_user_project) + + track_event('list_package') + + packages = find_package_versions + presenter = ::Packages::Pypi::PackagePresenter.new(packages, authorized_user_project) + + # Adjusts grape output format + # to be HTML + content_type "text/html; charset=utf-8" + env['api.format'] = :binary + + body presenter.body + end + + desc 'The PyPi Package upload endpoint' do + detail 'This feature was introduced in GitLab 12.10' + end + + params do + requires :content, type: ::API::Validations::Types::WorkhorseFile, desc: 'The package file to be published (generated by Multipart middleware)' + requires :requires_python, type: String + requires :name, type: String + requires :version, type: String + optional :md5_digest, type: String + optional :sha256_digest, type: String + end + + route_setting :authentication, deploy_token_allowed: true + post do + authorize_upload!(authorized_user_project) + + track_event('push_package') + + ::Packages::Pypi::CreatePackageService + .new(authorized_user_project, current_user, declared_params) + .execute + + created! + rescue ObjectStorage::RemoteStoreError => e + Gitlab::ErrorTracking.track_exception(e, extra: { file_name: params[:name], project_id: authorized_user_project.id }) + + forbidden! + end + + route_setting :authentication, deploy_token_allowed: true + post 'authorize' do + authorize_workhorse!(subject: authorized_user_project, has_length: false) + end + end + end + end +end diff --git a/lib/gitlab/conan_token.rb b/lib/gitlab/conan_token.rb new file mode 100644 index 00000000000..7526c10b608 --- /dev/null +++ b/lib/gitlab/conan_token.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +# The Conan client uses a JWT for authenticating with remotes. +# This class encodes and decodes a user's personal access token or +# CI_JOB_TOKEN into a JWT that is used by the Conan client to +# authenticate with GitLab + +module Gitlab + class ConanToken + HMAC_KEY = 'gitlab-conan-packages'.freeze + + attr_reader :access_token_id, :user_id + + class << self + def from_personal_access_token(access_token) + new(access_token_id: access_token.id, user_id: access_token.user_id) + end + + def from_job(job) + new(access_token_id: job.token, user_id: job.user.id) + end + + def from_deploy_token(deploy_token) + new(access_token_id: deploy_token.token, user_id: deploy_token.username) + end + + def decode(jwt) + payload = JSONWebToken::HMACToken.decode(jwt, secret).first + + new(access_token_id: payload['access_token'], user_id: payload['user_id']) + rescue JWT::DecodeError, JWT::ExpiredSignature, JWT::ImmatureSignature + # we return on expired and errored tokens because the Conan client + # will request a new token automatically. + end + + def secret + OpenSSL::HMAC.hexdigest( + OpenSSL::Digest::SHA256.new, + ::Settings.attr_encrypted_db_key_base, + HMAC_KEY + ) + end + end + + def initialize(access_token_id:, user_id:) + @access_token_id = access_token_id + @user_id = user_id + end + + def to_jwt + hmac_token.encoded + end + + private + + def hmac_token + JSONWebToken::HMACToken.new(self.class.secret).tap do |token| + token['access_token'] = access_token_id + token['user_id'] = user_id + token.expire_time = token.issued_at + 1.hour + end + end + end +end diff --git a/lib/gitlab/project_template.rb b/lib/gitlab/project_template.rb index fdb3fbc03bc..e6e599e079d 100644 --- a/lib/gitlab/project_template.rb +++ b/lib/gitlab/project_template.rb @@ -59,6 +59,7 @@ module Gitlab ProjectTemplate.new('nfhexo', 'Netlify/Hexo', _('A Hexo site that uses Netlify for CI/CD instead of GitLab, but still with all the other great GitLab features.'), 'https://gitlab.com/pages/nfhexo', 'illustrations/logos/netlify.svg'), ProjectTemplate.new('salesforcedx', 'SalesforceDX', _('A project boilerplate for Salesforce App development with Salesforce Developer tools.'), 'https://gitlab.com/gitlab-org/project-templates/salesforcedx'), ProjectTemplate.new('serverless_framework', 'Serverless Framework/JS', _('A basic page and serverless function that uses AWS Lambda, AWS API Gateway, and GitLab Pages'), 'https://gitlab.com/gitlab-org/project-templates/serverless-framework', 'illustrations/logos/serverless_framework.svg'), + ProjectTemplate.new('jsonnet', 'Jsonnet for Dynamic Child Pipelines', _('An example showing how to use Jsonnet with GitLab dynamic child pipelines'), 'https://gitlab.com/gitlab-org/project-templates/jsonnet'), ProjectTemplate.new('cluster_management', 'GitLab Cluster Management', _('An example project for managing Kubernetes clusters integrated with GitLab.'), 'https://gitlab.com/gitlab-org/project-templates/cluster-management') ].freeze end diff --git a/locale/gitlab.pot b/locale/gitlab.pot index ee9f2a92766..7b384d4de6a 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -2742,6 +2742,9 @@ msgstr "" msgid "An example project for managing Kubernetes clusters integrated with GitLab." msgstr "" +msgid "An example showing how to use Jsonnet with GitLab dynamic child pipelines" +msgstr "" + msgid "An instance-level serverless domain already exists." msgstr "" diff --git a/spec/factories/ci/builds.rb b/spec/factories/ci/builds.rb index 76a38f15245..b3815b53c2b 100644 --- a/spec/factories/ci/builds.rb +++ b/spec/factories/ci/builds.rb @@ -308,6 +308,12 @@ FactoryBot.define do end end + trait :codequality_report do + after(:build) do |build| + build.job_artifacts << create(:ci_job_artifact, :codequality, job: build) + end + end + trait :test_reports do after(:build) do |build| build.job_artifacts << create(:ci_job_artifact, :junit, job: build) diff --git a/spec/factories/ci/pipelines.rb b/spec/factories/ci/pipelines.rb index 3a1bad8d285..5bd5ab7d67a 100644 --- a/spec/factories/ci/pipelines.rb +++ b/spec/factories/ci/pipelines.rb @@ -73,6 +73,14 @@ FactoryBot.define do end end + trait :with_codequality_report do + status { :success } + + after(:build) do |pipeline, evaluator| + pipeline.builds << build(:ci_build, :codequality_report, pipeline: pipeline, project: pipeline.project) + end + end + trait :with_test_reports do status { :success } diff --git a/spec/features/merge_request/user_approves_spec.rb b/spec/features/merge_request/user_approves_spec.rb new file mode 100644 index 00000000000..d319fdcb87b --- /dev/null +++ b/spec/features/merge_request/user_approves_spec.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'Merge request > User approves', :js do + let(:user) { create(:user) } + let(:project) { create(:project, :public, :repository) } + let(:merge_request) { create(:merge_request, source_project: project) } + + before do + project.add_developer(user) + + sign_in(user) + + visit project_merge_request_path(project, merge_request) + end + + it 'approves merge request' do + click_approval_button('Approve') + expect(page).to have_content('Merge request approved') + + verify_approvals_count_on_index! + + click_approval_button('Revoke approval') + expect(page).to have_content('No approval required; you can still approve') + end + + def verify_approvals_count_on_index! + visit(project_merge_requests_path(project, state: :all)) + expect(page.all('li').any? { |item| item["title"] == "1 approver (you've approved)"}).to be true + visit project_merge_request_path(project, merge_request) + end + + def click_approval_button(action) + page.within('.mr-state-widget') do + click_button(action) + end + + wait_for_requests + end +end diff --git a/spec/frontend/vue_mr_widget/components/approvals/approvals_spec.js b/spec/frontend/vue_mr_widget/components/approvals/approvals_spec.js new file mode 100644 index 00000000000..e39f66d3f30 --- /dev/null +++ b/spec/frontend/vue_mr_widget/components/approvals/approvals_spec.js @@ -0,0 +1,391 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlButton } from '@gitlab/ui'; +import Approvals from '~/vue_merge_request_widget/components/approvals/approvals.vue'; +import ApprovalsSummary from '~/vue_merge_request_widget/components/approvals/approvals_summary.vue'; +import ApprovalsSummaryOptional from '~/vue_merge_request_widget/components/approvals/approvals_summary_optional.vue'; +import createFlash from '~/flash'; +import { + FETCH_LOADING, + FETCH_ERROR, + APPROVE_ERROR, + UNAPPROVE_ERROR, +} from '~/vue_merge_request_widget/components/approvals/messages'; +import eventHub from '~/vue_merge_request_widget/event_hub'; + +jest.mock('~/flash'); + +const TEST_HELP_PATH = 'help/path'; +const testApprovedBy = () => [1, 7, 10].map(id => ({ id })); +const testApprovals = () => ({ + approved: false, + approved_by: testApprovedBy().map(user => ({ user })), + approval_rules_left: [], + approvals_left: 4, + suggested_approvers: [], + user_can_approve: true, + user_has_approved: true, + require_password_to_approve: false, +}); +const testApprovalRulesResponse = () => ({ rules: [{ id: 2 }] }); + +// For some reason, the `Promise.resolve()` needs to be deferred +// or the timing doesn't work. +const tick = () => Promise.resolve(); +const waitForTick = done => + tick() + .then(done) + .catch(done.fail); + +describe('MRWidget approvals', () => { + let wrapper; + let service; + let mr; + + const createComponent = (props = {}) => { + wrapper = shallowMount(Approvals, { + propsData: { + mr, + service, + ...props, + }, + }); + }; + + const findAction = () => wrapper.find(GlButton); + const findActionData = () => { + const action = findAction(); + + return !action.exists() + ? null + : { + variant: action.props('variant'), + category: action.props('category'), + text: action.text(), + }; + }; + const findSummary = () => wrapper.find(ApprovalsSummary); + const findOptionalSummary = () => wrapper.find(ApprovalsSummaryOptional); + + beforeEach(() => { + service = { + ...{ + fetchApprovals: jest.fn().mockReturnValue(Promise.resolve(testApprovals())), + fetchApprovalSettings: jest + .fn() + .mockReturnValue(Promise.resolve(testApprovalRulesResponse())), + approveMergeRequest: jest.fn().mockReturnValue(Promise.resolve(testApprovals())), + unapproveMergeRequest: jest.fn().mockReturnValue(Promise.resolve(testApprovals())), + approveMergeRequestWithAuth: jest.fn().mockReturnValue(Promise.resolve(testApprovals())), + }, + }; + mr = { + ...{ + setApprovals: jest.fn(), + setApprovalRules: jest.fn(), + }, + approvalsHelpPath: TEST_HELP_PATH, + approvals: testApprovals(), + approvalRules: [], + isOpen: true, + state: 'open', + }; + + jest.spyOn(eventHub, '$emit').mockImplementation(() => {}); + }); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe('when created', () => { + beforeEach(() => { + createComponent(); + }); + + it('shows loading message', () => { + wrapper.setData({ fetchingApprovals: true }); + + return tick().then(() => { + expect(wrapper.text()).toContain(FETCH_LOADING); + }); + }); + + it('fetches approvals', () => { + expect(service.fetchApprovals).toHaveBeenCalled(); + }); + }); + + describe('when fetch approvals error', () => { + beforeEach(done => { + jest.spyOn(service, 'fetchApprovals').mockReturnValue(Promise.reject()); + createComponent(); + waitForTick(done); + }); + + it('still shows loading message', () => { + expect(wrapper.text()).toContain(FETCH_LOADING); + }); + + it('flashes error', () => { + expect(createFlash).toHaveBeenCalledWith(FETCH_ERROR); + }); + }); + + describe('action button', () => { + describe('when mr is closed', () => { + beforeEach(done => { + mr.isOpen = false; + mr.approvals.user_has_approved = false; + mr.approvals.user_can_approve = true; + + createComponent(); + waitForTick(done); + }); + + it('action is not rendered', () => { + expect(findActionData()).toBe(null); + }); + }); + + describe('when user cannot approve', () => { + beforeEach(done => { + mr.approvals.user_has_approved = false; + mr.approvals.user_can_approve = false; + + createComponent(); + waitForTick(done); + }); + + it('action is not rendered', () => { + expect(findActionData()).toBe(null); + }); + }); + + describe('when user can approve', () => { + beforeEach(() => { + mr.approvals.user_has_approved = false; + mr.approvals.user_can_approve = true; + }); + + describe('and MR is unapproved', () => { + beforeEach(done => { + createComponent(); + waitForTick(done); + }); + + it('approve action is rendered', () => { + expect(findActionData()).toEqual({ + variant: 'info', + text: 'Approve', + category: 'primary', + }); + }); + }); + + describe('and MR is approved', () => { + beforeEach(() => { + mr.approvals.approved = true; + }); + + describe('with no approvers', () => { + beforeEach(done => { + mr.approvals.approved_by = []; + createComponent(); + waitForTick(done); + }); + + it('approve action (with inverted style) is rendered', () => { + expect(findActionData()).toEqual({ + variant: 'info', + text: 'Approve', + category: 'secondary', + }); + }); + }); + + describe('with approvers', () => { + beforeEach(done => { + mr.approvals.approved_by = [{ user: { id: 7 } }]; + createComponent(); + waitForTick(done); + }); + + it('approve additionally action is rendered', () => { + expect(findActionData()).toEqual({ + variant: 'info', + text: 'Approve additionally', + category: 'secondary', + }); + }); + }); + }); + + describe('when approve action is clicked', () => { + beforeEach(done => { + createComponent(); + waitForTick(done); + }); + + it('shows loading icon', () => { + jest.spyOn(service, 'approveMergeRequest').mockReturnValue(new Promise(() => {})); + const action = findAction(); + + expect(action.props('loading')).toBe(false); + + action.vm.$emit('click'); + + return tick().then(() => { + expect(action.props('loading')).toBe(true); + }); + }); + + describe('and after loading', () => { + beforeEach(done => { + findAction().vm.$emit('click'); + waitForTick(done); + }); + + it('calls service approve', () => { + expect(service.approveMergeRequest).toHaveBeenCalled(); + }); + + it('emits to eventHub', () => { + expect(eventHub.$emit).toHaveBeenCalledWith('MRWidgetUpdateRequested'); + }); + + it('calls store setApprovals', () => { + expect(mr.setApprovals).toHaveBeenCalledWith(testApprovals()); + }); + }); + + describe('and error', () => { + beforeEach(done => { + jest.spyOn(service, 'approveMergeRequest').mockReturnValue(Promise.reject()); + findAction().vm.$emit('click'); + waitForTick(done); + }); + + it('flashes error message', () => { + expect(createFlash).toHaveBeenCalledWith(APPROVE_ERROR); + }); + }); + }); + }); + + describe('when user has approved', () => { + beforeEach(done => { + mr.approvals.user_has_approved = true; + mr.approvals.user_can_approve = false; + + createComponent(); + waitForTick(done); + }); + + it('revoke action is rendered', () => { + expect(findActionData()).toEqual({ + variant: 'warning', + text: 'Revoke approval', + category: 'secondary', + }); + }); + + describe('when revoke action is clicked', () => { + describe('and successful', () => { + beforeEach(done => { + findAction().vm.$emit('click'); + waitForTick(done); + }); + + it('calls service unapprove', () => { + expect(service.unapproveMergeRequest).toHaveBeenCalled(); + }); + + it('emits to eventHub', () => { + expect(eventHub.$emit).toHaveBeenCalledWith('MRWidgetUpdateRequested'); + }); + + it('calls store setApprovals', () => { + expect(mr.setApprovals).toHaveBeenCalledWith(testApprovals()); + }); + }); + + describe('and error', () => { + beforeEach(done => { + jest.spyOn(service, 'unapproveMergeRequest').mockReturnValue(Promise.reject()); + findAction().vm.$emit('click'); + waitForTick(done); + }); + + it('flashes error message', () => { + expect(createFlash).toHaveBeenCalledWith(UNAPPROVE_ERROR); + }); + }); + }); + }); + }); + + describe('approvals optional summary', () => { + describe('when no approvals required and no approvers', () => { + beforeEach(() => { + mr.approvals.approved_by = []; + mr.approvals.approvals_required = 0; + mr.approvals.user_has_approved = false; + }); + + describe('and can approve', () => { + beforeEach(done => { + mr.approvals.user_can_approve = true; + + createComponent(); + waitForTick(done); + }); + + it('is shown', () => { + expect(findSummary().exists()).toBe(false); + expect(findOptionalSummary().props()).toEqual({ + canApprove: true, + helpPath: TEST_HELP_PATH, + }); + }); + }); + + describe('and cannot approve', () => { + beforeEach(done => { + mr.approvals.user_can_approve = false; + + createComponent(); + waitForTick(done); + }); + + it('is shown', () => { + expect(findSummary().exists()).toBe(false); + expect(findOptionalSummary().props()).toEqual({ + canApprove: false, + helpPath: TEST_HELP_PATH, + }); + }); + }); + }); + }); + + describe('approvals summary', () => { + beforeEach(done => { + createComponent(); + waitForTick(done); + }); + + it('is rendered with props', () => { + const expected = testApprovals(); + const summary = findSummary(); + + expect(findOptionalSummary().exists()).toBe(false); + expect(summary.exists()).toBe(true); + expect(summary.props()).toMatchObject({ + approvalsLeft: expected.approvals_left, + rulesLeft: expected.approval_rules_left, + approvers: testApprovedBy(), + }); + }); + }); +}); diff --git a/spec/frontend/vue_mr_widget/components/approvals/approvals_summary_optional_spec.js b/spec/frontend/vue_mr_widget/components/approvals/approvals_summary_optional_spec.js new file mode 100644 index 00000000000..77fad7f51ab --- /dev/null +++ b/spec/frontend/vue_mr_widget/components/approvals/approvals_summary_optional_spec.js @@ -0,0 +1,57 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlLink } from '@gitlab/ui'; +import { + OPTIONAL, + OPTIONAL_CAN_APPROVE, +} from '~/vue_merge_request_widget/components/approvals/messages'; +import ApprovalsSummaryOptional from '~/vue_merge_request_widget/components/approvals/approvals_summary_optional.vue'; + +const TEST_HELP_PATH = 'help/path'; + +describe('MRWidget approvals summary optional', () => { + let wrapper; + + const createComponent = (props = {}) => { + wrapper = shallowMount(ApprovalsSummaryOptional, { + propsData: props, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + const findHelpLink = () => wrapper.find(GlLink); + + describe('when can approve', () => { + beforeEach(() => { + createComponent({ canApprove: true, helpPath: TEST_HELP_PATH }); + }); + + it('shows optional can approve message', () => { + expect(wrapper.text()).toEqual(OPTIONAL_CAN_APPROVE); + }); + + it('shows help link', () => { + const link = findHelpLink(); + + expect(link.exists()).toBe(true); + expect(link.attributes('href')).toBe(TEST_HELP_PATH); + }); + }); + + describe('when cannot approve', () => { + beforeEach(() => { + createComponent({ canApprove: false, helpPath: TEST_HELP_PATH }); + }); + + it('shows optional message', () => { + expect(wrapper.text()).toEqual(OPTIONAL); + }); + + it('does not show help link', () => { + expect(findHelpLink().exists()).toBe(false); + }); + }); +}); diff --git a/spec/frontend/vue_mr_widget/components/approvals/approvals_summary_spec.js b/spec/frontend/vue_mr_widget/components/approvals/approvals_summary_spec.js new file mode 100644 index 00000000000..822d075f28f --- /dev/null +++ b/spec/frontend/vue_mr_widget/components/approvals/approvals_summary_spec.js @@ -0,0 +1,93 @@ +import { shallowMount } from '@vue/test-utils'; +import { APPROVED_MESSAGE } from '~/vue_merge_request_widget/components/approvals/messages'; +import ApprovalsSummary from '~/vue_merge_request_widget/components/approvals/approvals_summary.vue'; +import { toNounSeriesText } from '~/lib/utils/grammar'; +import UserAvatarList from '~/vue_shared/components/user_avatar/user_avatar_list.vue'; + +const testApprovers = () => Array.from({ length: 5 }, (_, i) => i).map(id => ({ id })); +const testRulesLeft = () => ['Lorem', 'Ipsum', 'dolar & sit']; +const TEST_APPROVALS_LEFT = 3; + +describe('MRWidget approvals summary', () => { + let wrapper; + + const createComponent = (props = {}) => { + wrapper = shallowMount(ApprovalsSummary, { + propsData: { + approved: false, + approvers: testApprovers(), + approvalsLeft: TEST_APPROVALS_LEFT, + rulesLeft: testRulesLeft(), + ...props, + }, + }); + }; + + const findAvatars = () => wrapper.find(UserAvatarList); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe('when approved', () => { + beforeEach(() => { + createComponent({ + approved: true, + }); + }); + + it('shows approved message', () => { + expect(wrapper.text()).toContain(APPROVED_MESSAGE); + }); + + it('renders avatar list for approvers', () => { + const avatars = findAvatars(); + + expect(avatars.exists()).toBe(true); + expect(avatars.props()).toEqual( + expect.objectContaining({ + items: testApprovers(), + }), + ); + }); + }); + + describe('when not approved', () => { + beforeEach(() => { + createComponent(); + }); + + it('render message', () => { + const names = toNounSeriesText(testRulesLeft()); + + expect(wrapper.text()).toContain( + `Requires ${TEST_APPROVALS_LEFT} more approvals from ${names}.`, + ); + }); + }); + + describe('when no rulesLeft', () => { + beforeEach(() => { + createComponent({ + rulesLeft: [], + }); + }); + + it('renders message', () => { + expect(wrapper.text()).toContain(`Requires ${TEST_APPROVALS_LEFT} more approvals.`); + }); + }); + + describe('when no approvers', () => { + beforeEach(() => { + createComponent({ + approvers: [], + }); + }); + + it('does not render avatar list', () => { + expect(wrapper.find(UserAvatarList).exists()).toBe(false); + }); + }); +}); diff --git a/spec/graphql/resolvers/packages_resolver_spec.rb b/spec/graphql/resolvers/packages_resolver_spec.rb new file mode 100644 index 00000000000..9aec2c7e036 --- /dev/null +++ b/spec/graphql/resolvers/packages_resolver_spec.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Resolvers::PackagesResolver do + include GraphqlHelpers + + let_it_be(:user) { create(:user) } + let_it_be(:project) { create(:project) } + let_it_be(:package) { create(:package, project: project) } + + describe '#resolve' do + subject(:packages) { resolve(described_class, ctx: { current_user: user }, obj: project) } + + it { is_expected.to contain_exactly(package) } + end +end diff --git a/spec/graphql/types/package_type_enum_spec.rb b/spec/graphql/types/package_type_enum_spec.rb new file mode 100644 index 00000000000..fadec9744ed --- /dev/null +++ b/spec/graphql/types/package_type_enum_spec.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe GitlabSchema.types['PackageTypeEnum'] do + it 'exposes all package types' do + expect(described_class.values.keys).to contain_exactly(*%w[MAVEN NPM CONAN NUGET PYPI COMPOSER]) + end +end diff --git a/spec/graphql/types/package_type_spec.rb b/spec/graphql/types/package_type_spec.rb new file mode 100644 index 00000000000..22048e7a693 --- /dev/null +++ b/spec/graphql/types/package_type_spec.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe GitlabSchema.types['Package'] do + it { expect(described_class.graphql_name).to eq('Package') } + + it 'includes all the package fields' do + expected_fields = %w[ + id name version created_at updated_at package_type + ] + + expect(described_class).to include_graphql_fields(*expected_fields) + end +end diff --git a/spec/lib/api/helpers/packages/dependency_proxy_helpers_spec.rb b/spec/lib/api/helpers/packages/dependency_proxy_helpers_spec.rb new file mode 100644 index 00000000000..ccf96bcbad6 --- /dev/null +++ b/spec/lib/api/helpers/packages/dependency_proxy_helpers_spec.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe API::Helpers::Packages::DependencyProxyHelpers do + let_it_be(:helper) { Class.new.include(described_class).new } + + describe 'redirect_registry_request' do + using RSpec::Parameterized::TableSyntax + + let(:options) { {} } + + subject { helper.redirect_registry_request(forward_to_registry, package_type, options) { helper.fallback } } + + shared_examples 'executing fallback' do + it 'redirects to package registry' do + expect(helper).to receive(:registry_url).never + expect(helper).to receive(:redirect).never + expect(helper).to receive(:fallback).once + + subject + end + end + + shared_examples 'executing redirect' do + it 'redirects to package registry' do + expect(helper).to receive(:registry_url).once + expect(helper).to receive(:redirect).once + expect(helper).to receive(:fallback).never + + subject + end + end + + context 'with npm packages' do + let(:package_type) { :npm } + + where(:application_setting, :forward_to_registry, :example_name) do + true | true | 'executing redirect' + true | false | 'executing fallback' + false | true | 'executing fallback' + false | false | 'executing fallback' + end + + with_them do + before do + stub_application_setting(npm_package_requests_forwarding: application_setting) + end + + it_behaves_like params[:example_name] + end + end + + context 'with non-forwardable packages' do + let(:forward_to_registry) { true } + + before do + stub_application_setting(npm_package_requests_forwarding: true) + end + + Packages::Package.package_types.keys.without('npm').each do |pkg_type| + context "#{pkg_type}" do + let(:package_type) { pkg_type } + + it 'raises an error' do + expect { subject }.to raise_error(ArgumentError, "Can't build registry_url for package_type #{package_type}") + end + end + end + end + end +end diff --git a/spec/lib/api/helpers/packages_helpers_spec.rb b/spec/lib/api/helpers/packages_helpers_spec.rb new file mode 100644 index 00000000000..0c51e25bad9 --- /dev/null +++ b/spec/lib/api/helpers/packages_helpers_spec.rb @@ -0,0 +1,104 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe API::Helpers::PackagesHelpers do + let_it_be(:helper) { Class.new.include(described_class).new } + let_it_be(:project) { create(:project) } + + describe 'authorize_packages_access!' do + subject { helper.authorize_packages_access!(project) } + + it 'authorizes packages access' do + expect(helper).to receive(:require_packages_enabled!) + expect(helper).to receive(:authorize_read_package!).with(project) + + expect(subject).to eq nil + end + end + + %i[read_package create_package destroy_package].each do |action| + describe "authorize_#{action}!" do + subject { helper.send("authorize_#{action}!", project) } + + it 'calls authorize!' do + expect(helper).to receive(:authorize!).with(action, project) + + expect(subject).to eq nil + end + end + end + + describe 'require_packages_enabled!' do + let(:packages_enabled) { true } + + subject { helper.require_packages_enabled! } + + before do + allow(::Gitlab.config.packages).to receive(:enabled).and_return(packages_enabled) + end + + context 'with packages enabled' do + it "doesn't call not_found!" do + expect(helper).to receive(:not_found!).never + + expect(subject).to eq nil + end + end + + context 'with package disabled' do + let(:packages_enabled) { false } + + it 'calls not_found!' do + expect(helper).to receive(:not_found!).once + + subject + end + end + end + + describe '#authorize_workhorse!' do + let_it_be(:headers) { {} } + + subject { helper.authorize_workhorse!(subject: project) } + + before do + allow(helper).to receive(:headers).and_return(headers) + end + + it 'authorizes workhorse' do + expect(helper).to receive(:authorize_upload!).with(project) + expect(helper).to receive(:status).with(200) + expect(helper).to receive(:content_type).with(Gitlab::Workhorse::INTERNAL_API_CONTENT_TYPE) + expect(Gitlab::Workhorse).to receive(:verify_api_request!).with(headers) + expect(::Packages::PackageFileUploader).to receive(:workhorse_authorize).with(has_length: true) + + expect(subject).to eq nil + end + + context 'without length' do + subject { helper.authorize_workhorse!(subject: project, has_length: false) } + + it 'authorizes workhorse' do + expect(helper).to receive(:authorize_upload!).with(project) + expect(helper).to receive(:status).with(200) + expect(helper).to receive(:content_type).with(Gitlab::Workhorse::INTERNAL_API_CONTENT_TYPE) + expect(Gitlab::Workhorse).to receive(:verify_api_request!).with(headers) + expect(::Packages::PackageFileUploader).to receive(:workhorse_authorize).with(has_length: false, maximum_size: ::API::Helpers::PackagesHelpers::MAX_PACKAGE_FILE_SIZE) + + expect(subject).to eq nil + end + end + end + + describe '#authorize_upload!' do + subject { helper.authorize_upload!(project) } + + it 'authorizes the upload' do + expect(helper).to receive(:authorize_create_package!).with(project) + expect(helper).to receive(:require_gitlab_workhorse!) + + expect(subject).to eq nil + end + end +end diff --git a/spec/lib/api/helpers/packages_manager_clients_helpers_spec.rb b/spec/lib/api/helpers/packages_manager_clients_helpers_spec.rb new file mode 100644 index 00000000000..80be5f7d10a --- /dev/null +++ b/spec/lib/api/helpers/packages_manager_clients_helpers_spec.rb @@ -0,0 +1,154 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe API::Helpers::PackagesManagerClientsHelpers do + let_it_be(:personal_access_token) { create(:personal_access_token) } + let_it_be(:username) { personal_access_token.user.username } + let_it_be(:helper) { Class.new.include(described_class).new } + let(:password) { personal_access_token.token } + + describe '#find_personal_access_token_from_http_basic_auth' do + let(:headers) { { Authorization: basic_http_auth(username, password) } } + + subject { helper.find_personal_access_token_from_http_basic_auth } + + before do + allow(helper).to receive(:headers).and_return(headers&.with_indifferent_access) + end + + context 'with a valid Authorization header' do + it { is_expected.to eq personal_access_token } + end + + context 'with an invalid Authorization header' do + where(:headers) do + [ + [{ Authorization: 'Invalid' }], + [{}], + [nil] + ] + end + + with_them do + it { is_expected.to be nil } + end + end + + context 'with an unknown Authorization header' do + let(:password) { 'Unknown' } + + it { is_expected.to be nil } + end + end + + describe '#find_job_from_http_basic_auth' do + let_it_be(:user) { personal_access_token.user } + + let(:job) { create(:ci_build, user: user) } + let(:password) { job.token } + let(:headers) { { Authorization: basic_http_auth(username, password) } } + + subject { helper.find_job_from_http_basic_auth } + + before do + allow(helper).to receive(:headers).and_return(headers&.with_indifferent_access) + end + + context 'with a valid Authorization header' do + it { is_expected.to eq job } + end + + context 'with an invalid Authorization header' do + where(:headers) do + [ + [{ Authorization: 'Invalid' }], + [{}], + [nil] + ] + end + + with_them do + it { is_expected.to be nil } + end + end + + context 'with an unknown Authorization header' do + let(:password) { 'Unknown' } + + it { is_expected.to be nil } + end + end + + describe '#find_deploy_token_from_http_basic_auth' do + let_it_be(:deploy_token) { create(:deploy_token) } + let(:token) { deploy_token.token } + let(:headers) { { Authorization: basic_http_auth(deploy_token.username, token) } } + + subject { helper.find_deploy_token_from_http_basic_auth } + + before do + allow(helper).to receive(:headers).and_return(headers&.with_indifferent_access) + end + + context 'with a valid Authorization header' do + it { is_expected.to eq deploy_token } + end + + context 'with an invalid Authorization header' do + where(:headers) do + [ + [{ Authorization: 'Invalid' }], + [{}], + [nil] + ] + end + + with_them do + it { is_expected.to be nil } + end + end + + context 'with an invalid token' do + let(:token) { 'Unknown' } + + it { is_expected.to be nil } + end + end + + describe '#uploaded_package_file' do + let_it_be(:params) { {} } + + subject { helper.uploaded_package_file } + + before do + allow(helper).to receive(:params).and_return(params) + end + + context 'with valid uploaded package file' do + let_it_be(:uploaded_file) { Object.new } + + before do + allow(UploadedFile).to receive(:from_params).and_return(uploaded_file) + end + + it { is_expected.to be uploaded_file } + end + + context 'with invalid uploaded package file' do + before do + allow(UploadedFile).to receive(:from_params).and_return(nil) + end + + it 'fails with bad_request!' do + expect(helper).to receive(:bad_request!) + + expect(subject).to be nil + end + end + end + + def basic_http_auth(username, password) + ActionController::HttpAuthentication::Basic.encode_credentials(username, password) + end +end diff --git a/spec/lib/gitlab/conan_token_spec.rb b/spec/lib/gitlab/conan_token_spec.rb new file mode 100644 index 00000000000..b17f2eaa8d8 --- /dev/null +++ b/spec/lib/gitlab/conan_token_spec.rb @@ -0,0 +1,97 @@ +# frozen_string_literal: true +require 'spec_helper' + +RSpec.describe Gitlab::ConanToken do + let(:base_secret) { SecureRandom.base64(64) } + + let(:jwt_secret) do + OpenSSL::HMAC.hexdigest( + OpenSSL::Digest::SHA256.new, + base_secret, + described_class::HMAC_KEY + ) + end + + before do + allow(Settings).to receive(:attr_encrypted_db_key_base).and_return(base_secret) + end + + def build_jwt(access_token_id:, user_id:, expire_time: nil) + JSONWebToken::HMACToken.new(jwt_secret).tap do |jwt| + jwt['access_token'] = access_token_id + jwt['user_id'] = user_id || user_id + jwt.expire_time = expire_time || jwt.issued_at + 1.hour + end + end + + describe '.from_personal_access_token' do + it 'sets access token id and user id' do + access_token = double(id: 123, user_id: 456) + + token = described_class.from_personal_access_token(access_token) + + expect(token.access_token_id).to eq(123) + expect(token.user_id).to eq(456) + end + end + + describe '.from_job' do + it 'sets access token id and user id' do + user = double(id: 456) + job = double(token: 123, user: user) + + token = described_class.from_job(job) + + expect(token.access_token_id).to eq(123) + expect(token.user_id).to eq(456) + end + end + + describe '.from_deploy_token' do + it 'sets access token id and user id' do + deploy_token = double(token: '123', username: 'bob') + + token = described_class.from_deploy_token(deploy_token) + + expect(token.access_token_id).to eq('123') + expect(token.user_id).to eq('bob') + end + end + + describe '.decode' do + it 'sets access token id and user id' do + jwt = build_jwt(access_token_id: 123, user_id: 456) + + token = described_class.decode(jwt.encoded) + + expect(token.access_token_id).to eq(123) + expect(token.user_id).to eq(456) + end + + it 'returns nil for invalid JWT' do + expect(described_class.decode('invalid-jwt')).to be_nil + end + + it 'returns nil for expired JWT' do + jwt = build_jwt(access_token_id: 123, + user_id: 456, + expire_time: Time.zone.now - 2.hours) + + expect(described_class.decode(jwt.encoded)).to be_nil + end + end + + describe '#to_jwt' do + it 'returns the encoded JWT' do + allow(SecureRandom).to receive(:uuid).and_return('u-u-i-d') + + Timecop.freeze do + jwt = build_jwt(access_token_id: 123, user_id: 456) + + token = described_class.new(access_token_id: 123, user_id: 456) + + expect(token.to_jwt).to eq(jwt.encoded) + end + end + end +end diff --git a/spec/lib/gitlab/project_template_spec.rb b/spec/lib/gitlab/project_template_spec.rb index 127c22734b9..fa45c605b1b 100644 --- a/spec/lib/gitlab/project_template_spec.rb +++ b/spec/lib/gitlab/project_template_spec.rb @@ -10,7 +10,7 @@ RSpec.describe Gitlab::ProjectTemplate do gomicro gatsby hugo jekyll plainhtml gitbook hexo sse_middleman nfhugo nfjekyll nfplainhtml nfgitbook nfhexo salesforcedx serverless_framework - cluster_management + jsonnet cluster_management ] expect(described_class.all).to be_an(Array) diff --git a/spec/models/ci/job_artifact_spec.rb b/spec/models/ci/job_artifact_spec.rb index 41693b2a084..b5f9128b7c5 100644 --- a/spec/models/ci/job_artifact_spec.rb +++ b/spec/models/ci/job_artifact_spec.rb @@ -110,6 +110,21 @@ RSpec.describe Ci::JobArtifact do end end + describe '.associated_file_types_for' do + using RSpec::Parameterized::TableSyntax + + subject { Ci::JobArtifact.associated_file_types_for(file_type) } + + where(:file_type, :result) do + 'codequality' | %w(codequality) + 'quality' | nil + end + + with_them do + it { is_expected.to eq result } + end + end + describe '.erasable' do subject { described_class.erasable } diff --git a/spec/models/ci/pipeline_spec.rb b/spec/models/ci/pipeline_spec.rb index e909173a799..ed2466d6413 100644 --- a/spec/models/ci/pipeline_spec.rb +++ b/spec/models/ci/pipeline_spec.rb @@ -2995,6 +2995,16 @@ RSpec.describe Ci::Pipeline, :mailer do end end + describe '#batch_lookup_report_artifact_for_file_type' do + context 'with code quality report artifact' do + let(:pipeline) { create(:ci_pipeline, :with_codequality_report, project: project) } + + it "returns the code quality artifact" do + expect(pipeline.batch_lookup_report_artifact_for_file_type(:codequality)).to eq(pipeline.job_artifacts.sample) + end + end + end + describe '#latest_report_builds' do it 'returns build with test artifacts' do test_build = create(:ci_build, :test_reports, pipeline: pipeline, project: project) diff --git a/spec/presenters/packages/composer/packages_presenter_spec.rb b/spec/presenters/packages/composer/packages_presenter_spec.rb new file mode 100644 index 00000000000..0445a346180 --- /dev/null +++ b/spec/presenters/packages/composer/packages_presenter_spec.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe ::Packages::Composer::PackagesPresenter do + using RSpec::Parameterized::TableSyntax + + let_it_be(:package_name) { 'sample-project' } + let_it_be(:json) { { 'name' => package_name } } + let_it_be(:group) { create(:group) } + let_it_be(:project) { create(:project, :custom_repo, files: { 'composer.json' => json.to_json }, group: group) } + let_it_be(:package1) { create(:composer_package, :with_metadatum, project: project, name: package_name, version: '1.0.0', json: json) } + let_it_be(:package2) { create(:composer_package, :with_metadatum, project: project, name: package_name, version: '2.0.0', json: json) } + + let(:branch) { project.repository.find_branch('master') } + + let(:packages) { [package1, package2] } + let(:presenter) { described_class.new(group, packages) } + + describe '#package_versions' do + subject { presenter.package_versions } + + def expected_json(package) + { + 'dist' => { + 'reference' => branch.target, + 'shasum' => '', + 'type' => 'zip', + 'url' => "http://localhost/api/v4/projects/#{project.id}/packages/composer/archives/#{package.name}.zip?sha=#{branch.target}" + }, + 'name' => package.name, + 'uid' => package.id, + 'version' => package.version + } + end + + it 'returns the packages json' do + packages = subject['packages'][package_name] + + expect(packages['1.0.0']).to eq(expected_json(package1)) + expect(packages['2.0.0']).to eq(expected_json(package2)) + end + end + + describe '#provider' do + subject { presenter.provider} + + let(:expected_json) do + { + 'providers' => { + package_name => { + 'sha256' => /^\h+$/ + } + } + } + end + + it 'returns the provider json' do + expect(subject).to match(expected_json) + end + end + + describe '#root' do + subject { presenter.root } + + let(:expected_json) do + { + 'packages' => [], + 'provider-includes' => { 'p/%hash%.json' => { 'sha256' => /^\h+$/ } }, + 'providers-url' => "/api/v4/group/#{group.id}/-/packages/composer/%package%.json" + } + end + + it 'returns the provider json' do + expect(subject).to match(expected_json) + end + end +end diff --git a/spec/presenters/packages/conan/package_presenter_spec.rb b/spec/presenters/packages/conan/package_presenter_spec.rb new file mode 100644 index 00000000000..3bc649c5da4 --- /dev/null +++ b/spec/presenters/packages/conan/package_presenter_spec.rb @@ -0,0 +1,181 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe ::Packages::Conan::PackagePresenter do + let_it_be(:user) { create(:user) } + let_it_be(:project) { create(:project) } + let_it_be(:conan_package_reference) { '123456789'} + + RSpec.shared_examples 'not selecting a package with the wrong type' do + context 'with a nuget package with same name and version' do + let_it_be(:wrong_package) { create(:nuget_package, name: 'wrong', version: '1.0.0', project: project) } + + let(:recipe) { "#{wrong_package.name}/#{wrong_package.version}" } + + it { is_expected.to be_empty } + end + end + + describe '#recipe_urls' do + subject { described_class.new(recipe, user, project).recipe_urls } + + context 'no existing package' do + let(:recipe) { "my-pkg/v1.0.0/#{project.full_path}/stable" } + + it { is_expected.to be_empty } + end + + it_behaves_like 'not selecting a package with the wrong type' + + context 'existing package' do + let(:package) { create(:conan_package, project: project) } + let(:recipe) { package.conan_recipe } + + let(:expected_result) do + { + "conanfile.py" => "#{Settings.build_base_gitlab_url}/api/v4/packages/conan/v1/files/#{package.conan_recipe_path}/0/export/conanfile.py", + "conanmanifest.txt" => "#{Settings.build_base_gitlab_url}/api/v4/packages/conan/v1/files/#{package.conan_recipe_path}/0/export/conanmanifest.txt" + } + end + + it { is_expected.to eq(expected_result) } + end + end + + describe '#recipe_snapshot' do + subject { described_class.new(recipe, user, project).recipe_snapshot } + + context 'no existing package' do + let(:recipe) { "my-pkg/v1.0.0/#{project.full_path}/stable" } + + it { is_expected.to be_empty } + end + + it_behaves_like 'not selecting a package with the wrong type' + + context 'existing package' do + let(:package) { create(:conan_package, project: project) } + let(:recipe) { package.conan_recipe } + + let(:expected_result) do + { + "conanfile.py" => '12345abcde', + "conanmanifest.txt" => '12345abcde' + } + end + + it { is_expected.to eq(expected_result) } + end + end + + describe '#package_urls' do + let(:reference) { conan_package_reference } + + subject do + described_class.new( + recipe, user, project, conan_package_reference: reference + ).package_urls + end + + context 'no existing package' do + let(:recipe) { "my-pkg/v1.0.0/#{project.full_path}/stable" } + + it { is_expected.to be_empty } + end + + it_behaves_like 'not selecting a package with the wrong type' + + context 'existing package' do + let(:package) { create(:conan_package, project: project) } + let(:recipe) { package.conan_recipe } + + let(:expected_result) do + { + "conaninfo.txt" => "#{Settings.build_base_gitlab_url}/api/v4/packages/conan/v1/files/#{package.conan_recipe_path}/0/package/#{conan_package_reference}/0/conaninfo.txt", + "conanmanifest.txt" => "#{Settings.build_base_gitlab_url}/api/v4/packages/conan/v1/files/#{package.conan_recipe_path}/0/package/#{conan_package_reference}/0/conanmanifest.txt", + "conan_package.tgz" => "#{Settings.build_base_gitlab_url}/api/v4/packages/conan/v1/files/#{package.conan_recipe_path}/0/package/#{conan_package_reference}/0/conan_package.tgz" + } + end + + it { is_expected.to eq(expected_result) } + + context 'multiple packages with different references' do + let(:info_file) { create(:conan_package_file, :conan_package_info, package: package) } + let(:manifest_file) { create(:conan_package_file, :conan_package_manifest, package: package) } + let(:package_file) { create(:conan_package_file, :conan_package, package: package) } + let(:alternative_reference) { 'abcdefghi' } + + before do + [info_file, manifest_file, package_file].each do |file| + file.conan_file_metadatum.conan_package_reference = alternative_reference + file.save + end + end + + it { is_expected.to eq(expected_result) } + + context 'requesting the alternative reference' do + let(:reference) { alternative_reference } + + let(:expected_result) do + { + "conaninfo.txt" => "#{Settings.build_base_gitlab_url}/api/v4/packages/conan/v1/files/#{package.conan_recipe_path}/0/package/#{alternative_reference}/0/conaninfo.txt", + "conanmanifest.txt" => "#{Settings.build_base_gitlab_url}/api/v4/packages/conan/v1/files/#{package.conan_recipe_path}/0/package/#{alternative_reference}/0/conanmanifest.txt", + "conan_package.tgz" => "#{Settings.build_base_gitlab_url}/api/v4/packages/conan/v1/files/#{package.conan_recipe_path}/0/package/#{alternative_reference}/0/conan_package.tgz" + } + end + + it { is_expected.to eq(expected_result) } + end + + it 'returns empty if the reference does not exist' do + result = described_class.new( + recipe, user, project, conan_package_reference: 'doesnotexist' + ).package_urls + + expect(result).to eq({}) + end + end + end + end + + describe '#package_snapshot' do + let(:reference) { conan_package_reference } + + subject do + described_class.new( + recipe, user, project, conan_package_reference: reference + ).package_snapshot + end + + context 'no existing package' do + let(:recipe) { "my-pkg/v1.0.0/#{project.full_path}/stable" } + + it { is_expected.to be_empty } + end + + it_behaves_like 'not selecting a package with the wrong type' + + context 'existing package' do + let(:package) { create(:conan_package, project: project) } + let(:recipe) { package.conan_recipe } + + let(:expected_result) do + { + "conaninfo.txt" => '12345abcde', + "conanmanifest.txt" => '12345abcde', + "conan_package.tgz" => '12345abcde' + } + end + + it { is_expected.to eq(expected_result) } + + context 'when requested with invalid reference' do + let(:reference) { 'invalid' } + + it { is_expected.to eq({}) } + end + end + end +end diff --git a/spec/presenters/packages/detail/package_presenter_spec.rb b/spec/presenters/packages/detail/package_presenter_spec.rb new file mode 100644 index 00000000000..34582957364 --- /dev/null +++ b/spec/presenters/packages/detail/package_presenter_spec.rb @@ -0,0 +1,98 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe ::Packages::Detail::PackagePresenter do + let_it_be(:user) { create(:user) } + let_it_be(:project) { create(:project, creator: user) } + let_it_be(:package) { create(:npm_package, :with_build, project: project) } + let(:presenter) { described_class.new(package) } + + let_it_be(:user_info) { { name: user.name, avatar_url: user.avatar_url } } + let!(:expected_package_files) do + npm_file = package.package_files.first + [{ + created_at: npm_file.created_at, + download_path: npm_file.download_path, + file_name: npm_file.file_name, + size: npm_file.size + }] + end + let(:pipeline_info) do + pipeline = package.build_info.pipeline + { + created_at: pipeline.created_at, + id: pipeline.id, + sha: pipeline.sha, + ref: pipeline.ref, + git_commit_message: pipeline.git_commit_message, + user: user_info, + project: { + name: pipeline.project.name, + web_url: pipeline.project.web_url + } + } + end + let!(:dependency_links) { [] } + let!(:expected_package_details) do + { + id: package.id, + created_at: package.created_at, + name: package.name, + package_files: expected_package_files, + package_type: package.package_type, + project_id: package.project_id, + tags: package.tags.as_json, + updated_at: package.updated_at, + version: package.version, + dependency_links: dependency_links + } + end + + context 'detail_view' do + context 'with build_info' do + let_it_be(:package) { create(:npm_package, :with_build, project: project) } + let(:expected_package_details) { super().merge(pipeline: pipeline_info) } + + it 'returns details with pipeline' do + expect(presenter.detail_view).to eq expected_package_details + end + end + + context 'without build info' do + let_it_be(:package) { create(:npm_package, project: project) } + + it 'returns details without pipeline' do + expect(presenter.detail_view).to eq expected_package_details + end + end + + context 'with nuget_metadatum' do + let_it_be(:package) { create(:nuget_package, project: project) } + let_it_be(:nuget_metadatum) { create(:nuget_metadatum, package: package) } + let(:expected_package_details) { super().merge(nuget_metadatum: nuget_metadatum) } + + it 'returns nuget_metadatum' do + expect(presenter.detail_view).to eq expected_package_details + end + end + + context 'with dependency_links' do + let_it_be(:package) { create(:nuget_package, project: project) } + let_it_be(:dependency_link) { create(:packages_dependency_link, package: package) } + let_it_be(:nuget_dependency) { create(:nuget_dependency_link_metadatum, dependency_link: dependency_link) } + let_it_be(:expected_link) do + { + name: dependency_link.dependency.name, + version_pattern: dependency_link.dependency.version_pattern, + target_framework: nuget_dependency.target_framework + } + end + let_it_be(:dependency_links) { [expected_link] } + + it 'returns the correct dependency link' do + expect(presenter.detail_view).to eq expected_package_details + end + end + end +end diff --git a/spec/presenters/packages/npm/package_presenter_spec.rb b/spec/presenters/packages/npm/package_presenter_spec.rb new file mode 100644 index 00000000000..0e8cda5bafd --- /dev/null +++ b/spec/presenters/packages/npm/package_presenter_spec.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe ::Packages::Npm::PackagePresenter do + let_it_be(:project) { create(:project) } + let_it_be(:package_name) { "@#{project.root_namespace.path}/test" } + let!(:package1) { create(:npm_package, version: '1.0.4', project: project, name: package_name) } + let!(:package2) { create(:npm_package, version: '1.0.6', project: project, name: package_name) } + let!(:latest_package) { create(:npm_package, version: '1.0.11', project: project, name: package_name) } + let(:packages) { project.packages.npm.with_name(package_name).last_of_each_version } + let(:presenter) { described_class.new(package_name, packages) } + + describe '#versions' do + subject { presenter.versions } + + context 'for packages without dependencies' do + it { is_expected.to be_a(Hash) } + it { expect(subject[package1.version]).to match_schema('public_api/v4/packages/npm_package_version') } + it { expect(subject[package2.version]).to match_schema('public_api/v4/packages/npm_package_version') } + + described_class::NPM_VALID_DEPENDENCY_TYPES.each do |dependency_type| + it { expect(subject.dig(package1.version, dependency_type)).to be nil } + it { expect(subject.dig(package2.version, dependency_type)).to be nil } + end + end + + context 'for packages with dependencies' do + described_class::NPM_VALID_DEPENDENCY_TYPES.each do |dependency_type| + let!("package_dependency_link_for_#{dependency_type}") { create(:packages_dependency_link, package: package1, dependency_type: dependency_type) } + end + + it { is_expected.to be_a(Hash) } + it { expect(subject[package1.version]).to match_schema('public_api/v4/packages/npm_package_version') } + it { expect(subject[package2.version]).to match_schema('public_api/v4/packages/npm_package_version') } + described_class::NPM_VALID_DEPENDENCY_TYPES.each do |dependency_type| + it { expect(subject.dig(package1.version, dependency_type.to_s)).to be_any } + end + end + end + + describe '#dist_tags' do + subject { presenter.dist_tags } + + context 'for packages without tags' do + it { is_expected.to be_a(Hash) } + it { expect(subject["latest"]).to eq(latest_package.version) } + end + + context 'for packages with tags' do + let!(:package_tag1) { create(:packages_tag, package: package1, name: 'release_a') } + let!(:package_tag2) { create(:packages_tag, package: package1, name: 'test_release') } + let!(:package_tag3) { create(:packages_tag, package: package2, name: 'release_b') } + let!(:package_tag4) { create(:packages_tag, package: latest_package, name: 'release_c') } + let!(:package_tag5) { create(:packages_tag, package: latest_package, name: 'latest') } + + it { is_expected.to be_a(Hash) } + it { expect(subject[package_tag1.name]).to eq(package1.version) } + it { expect(subject[package_tag2.name]).to eq(package1.version) } + it { expect(subject[package_tag3.name]).to eq(package2.version) } + it { expect(subject[package_tag4.name]).to eq(latest_package.version) } + it { expect(subject[package_tag5.name]).to eq(latest_package.version) } + end + end +end diff --git a/spec/presenters/packages/nuget/package_metadata_presenter_spec.rb b/spec/presenters/packages/nuget/package_metadata_presenter_spec.rb new file mode 100644 index 00000000000..d5e7b23d785 --- /dev/null +++ b/spec/presenters/packages/nuget/package_metadata_presenter_spec.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Packages::Nuget::PackageMetadataPresenter do + include_context 'with expected presenters dependency groups' + + let_it_be(:package) { create(:nuget_package, :with_metadatum) } + let_it_be(:tag1) { create(:packages_tag, name: 'tag1', package: package) } + let_it_be(:tag2) { create(:packages_tag, name: 'tag2', package: package) } + let_it_be(:presenter) { described_class.new(package) } + + describe '#json_url' do + let_it_be(:expected_suffix) { "/api/v4/projects/#{package.project_id}/packages/nuget/metadata/#{package.name}/#{package.version}.json" } + + subject { presenter.json_url } + + it { is_expected.to end_with(expected_suffix) } + end + + describe '#archive_url' do + let_it_be(:expected_suffix) { "/api/v4/projects/#{package.project_id}/packages/nuget/download/#{package.name}/#{package.version}/#{package.package_files.last.file_name}" } + + subject { presenter.archive_url } + + it { is_expected.to end_with(expected_suffix) } + end + + describe '#catalog_entry' do + subject { presenter.catalog_entry } + + before do + create_dependencies_for(package) + end + + it 'returns an entry structure' do + entry = subject + + expect(entry).to be_a Hash + %i[json_url archive_url].each { |field| expect(entry[field]).not_to be_blank } + %i[authors summary].each { |field| expect(entry[field]).to be_blank } + expect(entry[:dependency_groups]).to eq expected_dependency_groups(package.project_id, package.name, package.version) + expect(entry[:package_name]).to eq package.name + expect(entry[:package_version]).to eq package.version + expect(entry[:tags].split(::Packages::Tag::NUGET_TAGS_SEPARATOR)).to contain_exactly('tag1', 'tag2') + + %i[project_url license_url icon_url].each do |field| + expect(entry.dig(:metadatum, field)).to eq(package.nuget_metadatum.send(field)) + end + end + end +end diff --git a/spec/presenters/packages/nuget/packages_metadata_presenter_spec.rb b/spec/presenters/packages/nuget/packages_metadata_presenter_spec.rb new file mode 100644 index 00000000000..b2bcdf8f03d --- /dev/null +++ b/spec/presenters/packages/nuget/packages_metadata_presenter_spec.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Packages::Nuget::PackagesMetadataPresenter do + include_context 'with expected presenters dependency groups' + + let_it_be(:project) { create(:project) } + let_it_be(:packages) { create_list(:nuget_package, 5, :with_metadatum, name: 'Dummy.Package', project: project) } + let_it_be(:presenter) { described_class.new(packages) } + + describe '#count' do + subject { presenter.count } + + it {is_expected.to eq 1} + end + + describe '#items' do + let(:tag_names) { %w(tag1 tag2) } + + subject { presenter.items } + + before do + packages.each do |pkg| + tag_names.each { |tag| create(:packages_tag, package: pkg, name: tag) } + + create_dependencies_for(pkg) + end + end + + it 'returns an array' do + items = subject + + expect(items).to be_a Array + expect(items.size).to eq 1 + end + + it 'returns a summary structure' do + item = subject.first + + expect(item).to be_a Hash + %i[json_url lower_version upper_version].each { |field| expect(item[field]).not_to be_blank } + expect(item[:packages_count]).to eq packages.count + expect(item[:packages]).to be_a Array + expect(item[:packages].size).to eq packages.count + end + + it 'returns the catalog entries' do + item = subject.first + + item[:packages].each do |pkg| + expect(pkg).to be_a Hash + %i[json_url archive_url catalog_entry].each { |field| expect(pkg[field]).not_to be_blank } + catalog_entry = pkg[:catalog_entry] + %i[json_url archive_url package_name package_version].each { |field| expect(catalog_entry[field]).not_to be_blank } + %i[authors summary].each { |field| expect(catalog_entry[field]).to be_blank } + expect(catalog_entry[:dependency_groups]).to eq(expected_dependency_groups(project.id, catalog_entry[:package_name], catalog_entry[:package_version])) + expect(catalog_entry[:tags].split(::Packages::Tag::NUGET_TAGS_SEPARATOR)).to contain_exactly('tag1', 'tag2') + + %i[project_url license_url icon_url].each do |field| + expect(catalog_entry.dig(:metadatum, field)).not_to be_blank + end + end + end + end +end diff --git a/spec/presenters/packages/nuget/packages_versions_presenter_spec.rb b/spec/presenters/packages/nuget/packages_versions_presenter_spec.rb new file mode 100644 index 00000000000..36aa28243a4 --- /dev/null +++ b/spec/presenters/packages/nuget/packages_versions_presenter_spec.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Packages::Nuget::PackagesVersionsPresenter do + let_it_be(:packages) { create_list(:nuget_package, 5) } + let_it_be(:presenter) { described_class.new(::Packages::Package.all) } + + describe '#versions' do + subject { presenter.versions } + + it { is_expected.to match_array(packages.map(&:version).sort) } + end +end diff --git a/spec/presenters/packages/nuget/search_results_presenter_spec.rb b/spec/presenters/packages/nuget/search_results_presenter_spec.rb new file mode 100644 index 00000000000..29ec8579dc1 --- /dev/null +++ b/spec/presenters/packages/nuget/search_results_presenter_spec.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Packages::Nuget::SearchResultsPresenter do + let_it_be(:project) { create(:project) } + let_it_be(:package_a) { create(:nuget_package, :with_metadatum, project: project, name: 'DummyPackageA') } + let_it_be(:tag1) { create(:packages_tag, package: package_a, name: 'tag1') } + let_it_be(:tag2) { create(:packages_tag, package: package_a, name: 'tag2') } + let_it_be(:packages_b) { create_list(:nuget_package, 5, project: project, name: 'DummyPackageB') } + let_it_be(:packages_c) { create_list(:nuget_package, 5, project: project, name: 'DummyPackageC') } + let_it_be(:search_results) { OpenStruct.new(total_count: 3, results: [package_a, packages_b, packages_c].flatten) } + let_it_be(:presenter) { described_class.new(search_results) } + let(:total_count) { presenter.total_count } + let(:data) { presenter.data } + + describe '#total_count' do + it 'expects to have 3 total elements' do + expect(total_count).to eq(3) + end + end + + describe '#data' do + it 'returns the proper data structure' do + expect(data.size).to eq 3 + pkg_a, pkg_b, pkg_c = data + expect_package_result(pkg_a, package_a.name, [package_a.version], %w(tag1 tag2), with_metadatum: true) + expect_package_result(pkg_b, packages_b.first.name, packages_b.map(&:version)) + expect_package_result(pkg_c, packages_c.first.name, packages_c.map(&:version)) + end + + # rubocop:disable Metrics/AbcSize + def expect_package_result(package_json, name, versions, tags = [], with_metadatum: false) + expect(package_json[:type]).to eq 'Package' + expect(package_json[:authors]).to be_blank + expect(package_json[:name]).to eq(name) + expect(package_json[:summary]).to be_blank + expect(package_json[:total_downloads]).to eq 0 + expect(package_json[:verified]).to be + expect(package_json[:version]).to eq VersionSorter.sort(versions).last # rubocop: disable Style/UnneededSort + versions.zip(package_json[:versions]).each do |version, version_json| + expect(version_json[:json_url]).to end_with("#{version}.json") + expect(version_json[:downloads]).to eq 0 + expect(version_json[:version]).to eq version + end + + if tags.any? + expect(package_json[:tags].split(::Packages::Tag::NUGET_TAGS_SEPARATOR)).to contain_exactly(*tags) + else + expect(package_json[:tags]).to be_blank + end + + %i[project_url license_url icon_url].each do |field| + expect(package_json.dig(:metadatum, field)).to with_metadatum ? be_present : be_blank + end + end + # rubocop:enable Metrics/AbcSize + end +end diff --git a/spec/presenters/packages/nuget/service_index_presenter_spec.rb b/spec/presenters/packages/nuget/service_index_presenter_spec.rb new file mode 100644 index 00000000000..19ef890e19f --- /dev/null +++ b/spec/presenters/packages/nuget/service_index_presenter_spec.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe ::Packages::Nuget::ServiceIndexPresenter do + let_it_be(:project) { create(:project) } + let_it_be(:presenter) { described_class.new(project) } + + describe '#version' do + subject { presenter.version } + + it { is_expected.to eq '3.0.0' } + end + + describe '#resources' do + subject { presenter.resources } + + it 'has valid resources' do + expect(subject.size).to eq 8 + subject.each do |resource| + %i[@id @type comment].each do |field| + expect(resource).to have_key(field) + expect(resource[field]).to be_a(String) + end + end + end + end +end diff --git a/spec/presenters/packages/pypi/package_presenter_spec.rb b/spec/presenters/packages/pypi/package_presenter_spec.rb new file mode 100644 index 00000000000..e4d234a4688 --- /dev/null +++ b/spec/presenters/packages/pypi/package_presenter_spec.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe ::Packages::Pypi::PackagePresenter do + using RSpec::Parameterized::TableSyntax + + let_it_be(:project) { create(:project) } + let_it_be(:package_name) { 'sample-project' } + let_it_be(:package1) { create(:pypi_package, project: project, name: package_name, version: '1.0.0') } + let_it_be(:package2) { create(:pypi_package, project: project, name: package_name, version: '2.0.0') } + + let(:packages) { [package1, package2] } + let(:presenter) { described_class.new(packages, project) } + + describe '#body' do + subject { presenter.body} + + shared_examples_for "pypi package presenter" do + let(:file) { package.package_files.first } + let(:filename) { file.file_name } + let(:expected_file) { "<a href=\"http://localhost/api/v4/projects/#{project.id}/packages/pypi/files/#{file.file_sha256}/#{filename}#sha256=#{file.file_sha256}\" data-requires-python=\"#{expected_python_version}\">#{filename}</a><br>" } + + before do + package.pypi_metadatum.required_python = python_version + end + + it { is_expected.to include expected_file } + end + + it_behaves_like "pypi package presenter" do + let(:python_version) { '>=2.7' } + let(:expected_python_version) { '>=2.7' } + let(:package) { package1 } + end + + it_behaves_like "pypi package presenter" do + let(:python_version) { '"><script>alert(1)</script>' } + let(:expected_python_version) { '"><script>alert(1)</script>' } + let(:package) { package1 } + end + + it_behaves_like "pypi package presenter" do + let(:python_version) { '>=2.7, !=3.0' } + let(:expected_python_version) { '>=2.7, !=3.0' } + let(:package) { package2 } + end + end +end diff --git a/spec/requests/api/composer_packages_spec.rb b/spec/requests/api/composer_packages_spec.rb new file mode 100644 index 00000000000..d756a7700f6 --- /dev/null +++ b/spec/requests/api/composer_packages_spec.rb @@ -0,0 +1,302 @@ +# frozen_string_literal: true +require 'spec_helper' + +RSpec.describe API::ComposerPackages do + include PackagesManagerApiSpecHelpers + + let_it_be(:user) { create(:user) } + let_it_be(:group, reload: true) { create(:group, :public) } + let_it_be(:personal_access_token) { create(:personal_access_token, user: user) } + let_it_be(:package_name) { 'package-name' } + let_it_be(:project, reload: true) { create(:project, :custom_repo, files: { 'composer.json' => { name: package_name }.to_json }, group: group) } + let(:headers) { {} } + + describe 'GET /api/v4/group/:id/-/packages/composer/packages' do + let(:url) { "/group/#{group.id}/-/packages/composer/packages.json" } + + subject { get api(url), headers: headers } + + context 'without the need for a license' do + context 'with valid project' do + let!(:package) { create(:composer_package, :with_metadatum, project: project) } + + using RSpec::Parameterized::TableSyntax + + where(:project_visibility_level, :user_role, :member, :user_token, :shared_examples_name, :expected_status) do + 'PUBLIC' | :developer | true | true | 'Composer package index' | :success + 'PUBLIC' | :guest | true | true | 'Composer package index' | :success + 'PUBLIC' | :developer | true | false | 'Composer package index' | :success + 'PUBLIC' | :guest | true | false | 'Composer package index' | :success + 'PUBLIC' | :developer | false | true | 'Composer package index' | :success + 'PUBLIC' | :guest | false | true | 'Composer package index' | :success + 'PUBLIC' | :developer | false | false | 'Composer package index' | :success + 'PUBLIC' | :guest | false | false | 'Composer package index' | :success + 'PUBLIC' | :anonymous | false | true | 'Composer package index' | :success + 'PRIVATE' | :developer | true | true | 'Composer package index' | :success + 'PRIVATE' | :guest | true | true | 'Composer package index' | :success + 'PRIVATE' | :developer | true | false | 'process Composer api request' | :not_found + 'PRIVATE' | :guest | true | false | 'process Composer api request' | :not_found + 'PRIVATE' | :developer | false | true | 'process Composer api request' | :not_found + 'PRIVATE' | :guest | false | true | 'process Composer api request' | :not_found + 'PRIVATE' | :developer | false | false | 'process Composer api request' | :not_found + 'PRIVATE' | :guest | false | false | 'process Composer api request' | :not_found + 'PRIVATE' | :anonymous | false | true | 'process Composer api request' | :not_found + end + + with_them do + include_context 'Composer api group access', params[:project_visibility_level], params[:user_role], params[:user_token] do + it_behaves_like params[:shared_examples_name], params[:user_role], params[:expected_status], params[:member] + end + end + end + + it_behaves_like 'rejects Composer access with unknown group id' + end + end + + describe 'GET /api/v4/group/:id/-/packages/composer/p/:sha.json' do + let(:sha) { '123' } + let(:url) { "/group/#{group.id}/-/packages/composer/p/#{sha}.json" } + let!(:package) { create(:composer_package, :with_metadatum, project: project) } + + subject { get api(url), headers: headers } + + context 'without the need for a license' do + context 'with valid project' do + using RSpec::Parameterized::TableSyntax + + where(:project_visibility_level, :user_role, :member, :user_token, :shared_examples_name, :expected_status) do + 'PUBLIC' | :developer | true | true | 'Composer provider index' | :success + 'PUBLIC' | :guest | true | true | 'Composer provider index' | :success + 'PUBLIC' | :developer | true | false | 'Composer provider index' | :success + 'PUBLIC' | :guest | true | false | 'Composer provider index' | :success + 'PUBLIC' | :developer | false | true | 'Composer provider index' | :success + 'PUBLIC' | :guest | false | true | 'Composer provider index' | :success + 'PUBLIC' | :developer | false | false | 'Composer provider index' | :success + 'PUBLIC' | :guest | false | false | 'Composer provider index' | :success + 'PUBLIC' | :anonymous | false | true | 'Composer provider index' | :success + 'PRIVATE' | :developer | true | true | 'Composer provider index' | :success + 'PRIVATE' | :guest | true | true | 'Composer empty provider index' | :success + 'PRIVATE' | :developer | true | false | 'process Composer api request' | :not_found + 'PRIVATE' | :guest | true | false | 'process Composer api request' | :not_found + 'PRIVATE' | :developer | false | true | 'process Composer api request' | :not_found + 'PRIVATE' | :guest | false | true | 'process Composer api request' | :not_found + 'PRIVATE' | :developer | false | false | 'process Composer api request' | :not_found + 'PRIVATE' | :guest | false | false | 'process Composer api request' | :not_found + 'PRIVATE' | :anonymous | false | true | 'process Composer api request' | :not_found + end + + with_them do + include_context 'Composer api group access', params[:project_visibility_level], params[:user_role], params[:user_token] do + it_behaves_like params[:shared_examples_name], params[:user_role], params[:expected_status], params[:member] + end + end + end + + it_behaves_like 'rejects Composer access with unknown group id' + end + end + + describe 'GET /api/v4/group/:id/-/packages/composer/*package_name.json' do + let(:package_name) { 'foobar' } + let(:url) { "/group/#{group.id}/-/packages/composer/#{package_name}.json" } + + subject { get api(url), headers: headers } + + context 'without the need for a license' do + context 'with no packages' do + include_context 'Composer user type', :developer, true do + it_behaves_like 'returning response status', :not_found + end + end + + context 'with valid project' do + using RSpec::Parameterized::TableSyntax + + let!(:package) { create(:composer_package, :with_metadatum, name: package_name, project: project) } + + where(:project_visibility_level, :user_role, :member, :user_token, :shared_examples_name, :expected_status) do + 'PUBLIC' | :developer | true | true | 'Composer package api request' | :success + 'PUBLIC' | :guest | true | true | 'Composer package api request' | :success + 'PUBLIC' | :developer | true | false | 'Composer package api request' | :success + 'PUBLIC' | :guest | true | false | 'Composer package api request' | :success + 'PUBLIC' | :developer | false | true | 'Composer package api request' | :success + 'PUBLIC' | :guest | false | true | 'Composer package api request' | :success + 'PUBLIC' | :developer | false | false | 'Composer package api request' | :success + 'PUBLIC' | :guest | false | false | 'Composer package api request' | :success + 'PUBLIC' | :anonymous | false | true | 'Composer package api request' | :success + 'PRIVATE' | :developer | true | true | 'Composer package api request' | :success + 'PRIVATE' | :guest | true | true | 'process Composer api request' | :not_found + 'PRIVATE' | :developer | true | false | 'process Composer api request' | :not_found + 'PRIVATE' | :guest | true | false | 'process Composer api request' | :not_found + 'PRIVATE' | :developer | false | true | 'process Composer api request' | :not_found + 'PRIVATE' | :guest | false | true | 'process Composer api request' | :not_found + 'PRIVATE' | :developer | false | false | 'process Composer api request' | :not_found + 'PRIVATE' | :guest | false | false | 'process Composer api request' | :not_found + 'PRIVATE' | :anonymous | false | true | 'process Composer api request' | :not_found + end + + with_them do + include_context 'Composer api group access', params[:project_visibility_level], params[:user_role], params[:user_token] do + it_behaves_like params[:shared_examples_name], params[:user_role], params[:expected_status], params[:member] + end + end + end + + it_behaves_like 'rejects Composer access with unknown group id' + end + end + + describe 'POST /api/v4/projects/:id/packages/composer' do + let(:url) { "/projects/#{project.id}/packages/composer" } + let(:params) { {} } + + before(:all) do + project.repository.add_tag(user, 'v1.2.99', 'master') + end + + subject { post api(url), headers: headers, params: params } + + shared_examples 'composer package publish' do + context 'without the need for a license' do + context 'with valid project' do + using RSpec::Parameterized::TableSyntax + + where(:project_visibility_level, :user_role, :member, :user_token, :shared_examples_name, :expected_status) do + 'PUBLIC' | :developer | true | true | 'Composer package creation' | :created + 'PUBLIC' | :guest | true | true | 'process Composer api request' | :forbidden + 'PUBLIC' | :developer | true | false | 'process Composer api request' | :unauthorized + 'PUBLIC' | :guest | true | false | 'process Composer api request' | :unauthorized + 'PUBLIC' | :developer | false | true | 'process Composer api request' | :forbidden + 'PUBLIC' | :guest | false | true | 'process Composer api request' | :forbidden + 'PUBLIC' | :developer | false | false | 'process Composer api request' | :unauthorized + 'PUBLIC' | :guest | false | false | 'process Composer api request' | :unauthorized + 'PUBLIC' | :anonymous | false | true | 'process Composer api request' | :unauthorized + 'PRIVATE' | :developer | true | true | 'Composer package creation' | :created + 'PRIVATE' | :guest | true | true | 'process Composer api request' | :forbidden + 'PRIVATE' | :developer | true | false | 'process Composer api request' | :unauthorized + 'PRIVATE' | :guest | true | false | 'process Composer api request' | :unauthorized + 'PRIVATE' | :developer | false | true | 'process Composer api request' | :not_found + 'PRIVATE' | :guest | false | true | 'process Composer api request' | :not_found + 'PRIVATE' | :developer | false | false | 'process Composer api request' | :unauthorized + 'PRIVATE' | :guest | false | false | 'process Composer api request' | :unauthorized + 'PRIVATE' | :anonymous | false | true | 'process Composer api request' | :unauthorized + end + + with_them do + include_context 'Composer api project access', params[:project_visibility_level], params[:user_role], params[:user_token] do + it_behaves_like params[:shared_examples_name], params[:user_role], params[:expected_status], params[:member] + end + end + end + + it_behaves_like 'rejects Composer access with unknown project id' + end + end + + context 'with no tag or branch params' do + let(:headers) { build_basic_auth_header(user.username, personal_access_token.token) } + + it_behaves_like 'process Composer api request', :developer, :bad_request + end + + context 'with a tag' do + context 'with an existing branch' do + let(:params) { { tag: 'v1.2.99' } } + + it_behaves_like 'composer package publish' + end + + context 'with a non existing tag' do + let(:params) { { tag: 'non-existing-tag' } } + let(:headers) { build_basic_auth_header(user.username, personal_access_token.token) } + + it_behaves_like 'process Composer api request', :developer, :not_found + end + end + + context 'with a branch' do + context 'with an existing branch' do + let(:params) { { branch: 'master' } } + + it_behaves_like 'composer package publish' + end + + context 'with a non existing branch' do + let(:params) { { branch: 'non-existing-branch' } } + let(:headers) { build_basic_auth_header(user.username, personal_access_token.token) } + + it_behaves_like 'process Composer api request', :developer, :not_found + end + end + end + + describe 'GET /api/v4/projects/:id/packages/composer/archives/*package_name?sha=:sha' do + let(:sha) { '123' } + let(:url) { "/projects/#{project.id}/packages/composer/archives/#{package_name}.zip" } + let(:params) { { sha: sha } } + + subject { get api(url), headers: headers, params: params } + + context 'without the need for a license' do + context 'with valid project' do + let!(:package) { create(:composer_package, :with_metadatum, name: package_name, project: project) } + + context 'when the sha does not match the package name' do + let(:sha) { '123' } + + it_behaves_like 'process Composer api request', :anonymous, :not_found + end + + context 'when the package name does not match the sha' do + let(:branch) { project.repository.find_branch('master') } + let(:sha) { branch.target } + let(:url) { "/projects/#{project.id}/packages/composer/archives/unexisting-package-name.zip" } + + it_behaves_like 'process Composer api request', :anonymous, :not_found + end + + context 'with a match package name and sha' do + let(:branch) { project.repository.find_branch('master') } + let(:sha) { branch.target } + + using RSpec::Parameterized::TableSyntax + + where(:project_visibility_level, :user_role, :member, :user_token, :expected_status) do + 'PUBLIC' | :developer | true | true | :success + 'PUBLIC' | :guest | true | true | :success + 'PUBLIC' | :developer | true | false | :success + 'PUBLIC' | :guest | true | false | :success + 'PUBLIC' | :developer | false | true | :success + 'PUBLIC' | :guest | false | true | :success + 'PUBLIC' | :developer | false | false | :success + 'PUBLIC' | :guest | false | false | :success + 'PUBLIC' | :anonymous | false | true | :success + 'PRIVATE' | :developer | true | true | :success + 'PRIVATE' | :guest | true | true | :success + 'PRIVATE' | :developer | true | false | :success + 'PRIVATE' | :guest | true | false | :success + 'PRIVATE' | :developer | false | true | :success + 'PRIVATE' | :guest | false | true | :success + 'PRIVATE' | :developer | false | false | :success + 'PRIVATE' | :guest | false | false | :success + 'PRIVATE' | :anonymous | false | true | :success + end + + with_them do + let(:token) { user_token ? personal_access_token.token : 'wrong' } + let(:headers) { user_role == :anonymous ? {} : build_basic_auth_header(user.username, token) } + + before do + project.update!(visibility_level: Gitlab::VisibilityLevel.const_get(project_visibility_level, false)) + end + + it_behaves_like 'process Composer api request', params[:user_role], params[:expected_status], params[:member] + end + end + end + + it_behaves_like 'rejects Composer access with unknown project id' + end + end +end diff --git a/spec/requests/api/conan_packages_spec.rb b/spec/requests/api/conan_packages_spec.rb new file mode 100644 index 00000000000..1d88eaef79c --- /dev/null +++ b/spec/requests/api/conan_packages_spec.rb @@ -0,0 +1,840 @@ +# frozen_string_literal: true +require 'spec_helper' + +RSpec.describe API::ConanPackages do + include WorkhorseHelpers + include PackagesManagerApiSpecHelpers + + let(:package) { create(:conan_package) } + let_it_be(:personal_access_token) { create(:personal_access_token) } + let_it_be(:user) { personal_access_token.user } + let(:project) { package.project } + + let(:base_secret) { SecureRandom.base64(64) } + let(:auth_token) { personal_access_token.token } + let(:job) { create(:ci_build, user: user) } + let(:job_token) { job.token } + let(:deploy_token) { create(:deploy_token, read_package_registry: true, write_package_registry: true) } + let(:project_deploy_token) { create(:project_deploy_token, deploy_token: deploy_token, project: project) } + + let(:headers) do + { 'HTTP_AUTHORIZATION' => ActionController::HttpAuthentication::Basic.encode_credentials('foo', auth_token) } + end + + let(:jwt_secret) do + OpenSSL::HMAC.hexdigest( + OpenSSL::Digest::SHA256.new, + base_secret, + Gitlab::ConanToken::HMAC_KEY + ) + end + + before do + project.add_developer(user) + allow(Settings).to receive(:attr_encrypted_db_key_base).and_return(base_secret) + end + + describe 'GET /api/v4/packages/conan/v1/ping' do + it 'responds with 401 Unauthorized when no token provided' do + get api('/packages/conan/v1/ping') + + expect(response).to have_gitlab_http_status(:unauthorized) + end + + it 'responds with 200 OK when valid token is provided' do + jwt = build_jwt(personal_access_token) + get api('/packages/conan/v1/ping'), headers: build_token_auth_header(jwt.encoded) + + expect(response).to have_gitlab_http_status(:ok) + expect(response.headers['X-Conan-Server-Capabilities']).to eq("") + end + + it 'responds with 200 OK when valid job token is provided' do + jwt = build_jwt_from_job(job) + get api('/packages/conan/v1/ping'), headers: build_token_auth_header(jwt.encoded) + + expect(response).to have_gitlab_http_status(:ok) + expect(response.headers['X-Conan-Server-Capabilities']).to eq("") + end + + it 'responds with 200 OK when valid deploy token is provided' do + jwt = build_jwt_from_deploy_token(deploy_token) + get api('/packages/conan/v1/ping'), headers: build_token_auth_header(jwt.encoded) + + expect(response).to have_gitlab_http_status(:ok) + expect(response.headers['X-Conan-Server-Capabilities']).to eq("") + end + + it 'responds with 401 Unauthorized when invalid access token ID is provided' do + jwt = build_jwt(double(id: 12345), user_id: personal_access_token.user_id) + get api('/packages/conan/v1/ping'), headers: build_token_auth_header(jwt.encoded) + + expect(response).to have_gitlab_http_status(:unauthorized) + end + + it 'responds with 401 Unauthorized when invalid user is provided' do + jwt = build_jwt(personal_access_token, user_id: 12345) + get api('/packages/conan/v1/ping'), headers: build_token_auth_header(jwt.encoded) + + expect(response).to have_gitlab_http_status(:unauthorized) + end + + it 'responds with 401 Unauthorized when the provided JWT is signed with different secret' do + jwt = build_jwt(personal_access_token, secret: SecureRandom.base64(32)) + get api('/packages/conan/v1/ping'), headers: build_token_auth_header(jwt.encoded) + + expect(response).to have_gitlab_http_status(:unauthorized) + end + + it 'responds with 401 Unauthorized when invalid JWT is provided' do + get api('/packages/conan/v1/ping'), headers: build_token_auth_header('invalid-jwt') + + expect(response).to have_gitlab_http_status(:unauthorized) + end + + context 'packages feature disabled' do + it 'responds with 404 Not Found' do + stub_packages_setting(enabled: false) + get api('/packages/conan/v1/ping') + + expect(response).to have_gitlab_http_status(:not_found) + end + end + end + + describe 'GET /api/v4/packages/conan/v1/conans/search' do + before do + project.update!(visibility_level: Gitlab::VisibilityLevel::PUBLIC) + + get api('/packages/conan/v1/conans/search'), headers: headers, params: params + end + + subject { json_response['results'] } + + context 'returns packages with a matching name' do + let(:params) { { q: package.conan_recipe } } + + it { is_expected.to contain_exactly(package.conan_recipe) } + end + + context 'returns packages using a * wildcard' do + let(:params) { { q: "#{package.name[0, 3]}*" } } + + it { is_expected.to contain_exactly(package.conan_recipe) } + end + + context 'does not return non-matching packages' do + let(:params) { { q: "foo" } } + + it { is_expected.to be_blank } + end + end + + describe 'GET /api/v4/packages/conan/v1/users/authenticate' do + subject { get api('/packages/conan/v1/users/authenticate'), headers: headers } + + context 'when using invalid token' do + let(:auth_token) { 'invalid_token' } + + it 'responds with 401' do + subject + + expect(response).to have_gitlab_http_status(:unauthorized) + end + end + + context 'when valid JWT access token is provided' do + it 'responds with 200' do + subject + + expect(response).to have_gitlab_http_status(:ok) + end + + it 'token has valid validity time' do + Timecop.freeze do + subject + + payload = JSONWebToken::HMACToken.decode( + response.body, jwt_secret).first + expect(payload['access_token']).to eq(personal_access_token.id) + expect(payload['user_id']).to eq(personal_access_token.user_id) + + duration = payload['exp'] - payload['iat'] + expect(duration).to eq(1.hour) + end + end + end + + context 'with valid job token' do + let(:auth_token) { job_token } + + it 'responds with 200' do + subject + + expect(response).to have_gitlab_http_status(:ok) + end + end + + context 'with valid deploy token' do + let(:auth_token) { deploy_token.token } + + it 'responds with 200' do + subject + + expect(response).to have_gitlab_http_status(:ok) + end + end + end + + describe 'GET /api/v4/packages/conan/v1/users/check_credentials' do + it 'responds with a 200 OK with PAT' do + get api('/packages/conan/v1/users/check_credentials'), headers: headers + + expect(response).to have_gitlab_http_status(:ok) + end + + context 'with job token' do + let(:auth_token) { job_token } + + it 'responds with a 200 OK with job token' do + get api('/packages/conan/v1/users/check_credentials'), headers: headers + + expect(response).to have_gitlab_http_status(:ok) + end + end + + context 'with deploy token' do + let(:auth_token) { deploy_token.token } + + it 'responds with a 200 OK with job token' do + get api('/packages/conan/v1/users/check_credentials'), headers: headers + + expect(response).to have_gitlab_http_status(:ok) + end + end + + it 'responds with a 401 Unauthorized when an invalid token is used' do + get api('/packages/conan/v1/users/check_credentials'), headers: build_token_auth_header('invalid-token') + + expect(response).to have_gitlab_http_status(:unauthorized) + end + end + + shared_examples 'rejects invalid recipe' do + context 'with invalid recipe path' do + let(:recipe_path) { '../../foo++../..' } + + it 'returns 400' do + subject + + expect(response).to have_gitlab_http_status(:bad_request) + end + end + end + + shared_examples 'rejects recipe for invalid project' do + context 'with invalid recipe path' do + let(:recipe_path) { 'aa/bb/not-existing-project/ccc' } + + it 'returns forbidden' do + subject + + expect(response).to have_gitlab_http_status(:forbidden) + end + end + end + + shared_examples 'rejects recipe for not found package' do + context 'with invalid recipe path' do + let(:recipe_path) do + 'aa/bb/%{project}/ccc' % { project: ::Packages::Conan::Metadatum.package_username_from(full_path: project.full_path) } + end + + it 'returns not found' do + subject + + expect(response).to have_gitlab_http_status(:not_found) + end + end + end + + shared_examples 'empty recipe for not found package' do + context 'with invalid recipe url' do + let(:recipe_path) do + 'aa/bb/%{project}/ccc' % { project: ::Packages::Conan::Metadatum.package_username_from(full_path: project.full_path) } + end + + it 'returns not found' do + allow(::Packages::Conan::PackagePresenter).to receive(:new) + .with( + 'aa/bb@%{project}/ccc' % { project: ::Packages::Conan::Metadatum.package_username_from(full_path: project.full_path) }, + user, + project, + any_args + ).and_return(presenter) + allow(presenter).to receive(:recipe_snapshot) { {} } + allow(presenter).to receive(:package_snapshot) { {} } + + subject + + expect(response).to have_gitlab_http_status(:ok) + expect(response.body).to eq("{}") + end + end + end + + shared_examples 'recipe download_urls' do + let(:recipe_path) { package.conan_recipe_path } + + it 'returns the download_urls for the recipe files' do + expected_response = { + 'conanfile.py' => "#{Settings.gitlab.base_url}/api/v4/packages/conan/v1/files/#{package.conan_recipe_path}/0/export/conanfile.py", + 'conanmanifest.txt' => "#{Settings.gitlab.base_url}/api/v4/packages/conan/v1/files/#{package.conan_recipe_path}/0/export/conanmanifest.txt" + } + + allow(presenter).to receive(:recipe_urls) { expected_response } + + subject + + expect(json_response).to eq(expected_response) + end + end + + shared_examples 'package download_urls' do + let(:recipe_path) { package.conan_recipe_path } + + it 'returns the download_urls for the package files' do + expected_response = { + 'conaninfo.txt' => "#{Settings.gitlab.base_url}/api/v4/packages/conan/v1/files/#{package.conan_recipe_path}/0/package/123456789/0/conaninfo.txt", + 'conanmanifest.txt' => "#{Settings.gitlab.base_url}/api/v4/packages/conan/v1/files/#{package.conan_recipe_path}/0/package/123456789/0/conanmanifest.txt", + 'conan_package.tgz' => "#{Settings.gitlab.base_url}/api/v4/packages/conan/v1/files/#{package.conan_recipe_path}/0/package/123456789/0/conan_package.tgz" + } + + allow(presenter).to receive(:package_urls) { expected_response } + + subject + + expect(json_response).to eq(expected_response) + end + end + + context 'recipe endpoints' do + let(:jwt) { build_jwt(personal_access_token) } + let(:headers) { build_token_auth_header(jwt.encoded) } + let(:conan_package_reference) { '123456789' } + let(:presenter) { double('::Packages::Conan::PackagePresenter') } + + before do + allow(::Packages::Conan::PackagePresenter).to receive(:new) + .with(package.conan_recipe, user, package.project, any_args) + .and_return(presenter) + end + + describe 'GET /api/v4/packages/conan/v1/conans/:package_name/package_version/:package_username/:package_channel' do + let(:recipe_path) { package.conan_recipe_path } + + subject { get api("/packages/conan/v1/conans/#{recipe_path}"), headers: headers } + + it_behaves_like 'rejects invalid recipe' + it_behaves_like 'rejects recipe for invalid project' + it_behaves_like 'empty recipe for not found package' + + context 'with existing package' do + it 'returns a hash of files with their md5 hashes' do + expected_response = { + 'conanfile.py' => 'md5hash1', + 'conanmanifest.txt' => 'md5hash2' + } + + allow(presenter).to receive(:recipe_snapshot) { expected_response } + + subject + + expect(json_response).to eq(expected_response) + end + end + end + + describe 'GET /api/v4/packages/conan/v1/conans/:package_name/package_version/:package_username/:package_channel/packages/:conan_package_reference' do + let(:recipe_path) { package.conan_recipe_path } + + subject { get api("/packages/conan/v1/conans/#{recipe_path}/packages/#{conan_package_reference}"), headers: headers } + + it_behaves_like 'rejects invalid recipe' + it_behaves_like 'rejects recipe for invalid project' + it_behaves_like 'empty recipe for not found package' + + context 'with existing package' do + it 'returns a hash of md5 values for the files' do + expected_response = { + 'conaninfo.txt' => "md5hash1", + 'conanmanifest.txt' => "md5hash2", + 'conan_package.tgz' => "md5hash3" + } + + allow(presenter).to receive(:package_snapshot) { expected_response } + + subject + + expect(json_response).to eq(expected_response) + end + end + end + + describe 'GET /api/v4/packages/conan/v1/conans/:package_name/package_version/:package_username/:package_channel/digest' do + subject { get api("/packages/conan/v1/conans/#{recipe_path}/digest"), headers: headers } + + it_behaves_like 'rejects invalid recipe' + it_behaves_like 'rejects recipe for invalid project' + it_behaves_like 'recipe download_urls' + end + + describe 'GET /api/v4/packages/conan/v1/conans/:package_name/package_version/:package_username/:package_channel/packages/:conan_package_reference/download_urls' do + subject { get api("/packages/conan/v1/conans/#{recipe_path}/packages/#{conan_package_reference}/download_urls"), headers: headers } + + it_behaves_like 'rejects invalid recipe' + it_behaves_like 'rejects recipe for invalid project' + it_behaves_like 'package download_urls' + end + + describe 'GET /api/v4/packages/conan/v1/conans/:package_name/package_version/:package_username/:package_channel/download_urls' do + subject { get api("/packages/conan/v1/conans/#{recipe_path}/download_urls"), headers: headers } + + it_behaves_like 'rejects invalid recipe' + it_behaves_like 'rejects recipe for invalid project' + it_behaves_like 'recipe download_urls' + end + + describe 'GET /api/v4/packages/conan/v1/conans/:package_name/package_version/:package_username/:package_channel/packages/:conan_package_reference/digest' do + subject { get api("/packages/conan/v1/conans/#{recipe_path}/packages/#{conan_package_reference}/digest"), headers: headers } + + it_behaves_like 'rejects invalid recipe' + it_behaves_like 'rejects recipe for invalid project' + it_behaves_like 'package download_urls' + end + + describe 'POST /api/v4/packages/conan/v1/conans/:package_name/package_version/:package_username/:package_channel/upload_urls' do + let(:recipe_path) { package.conan_recipe_path } + + let(:params) do + { "conanfile.py": 24, + "conanmanifext.txt": 123 } + end + + subject { post api("/packages/conan/v1/conans/#{recipe_path}/upload_urls"), params: params, headers: headers } + + it_behaves_like 'rejects invalid recipe' + + it 'returns a set of upload urls for the files requested' do + subject + + expected_response = { + 'conanfile.py': "#{Settings.gitlab.base_url}/api/v4/packages/conan/v1/files/#{package.conan_recipe_path}/0/export/conanfile.py", + 'conanmanifest.txt': "#{Settings.gitlab.base_url}/api/v4/packages/conan/v1/files/#{package.conan_recipe_path}/0/export/conanmanifest.txt" + } + + expect(response.body).to eq(expected_response.to_json) + end + end + + describe 'POST /api/v4/packages/conan/v1/conans/:package_name/package_version/:package_username/:package_channel/packages/:conan_package_reference/upload_urls' do + let(:recipe_path) { package.conan_recipe_path } + + let(:params) do + { "conaninfo.txt": 24, + "conanmanifext.txt": 123, + "conan_package.tgz": 523 } + end + + subject { post api("/packages/conan/v1/conans/#{recipe_path}/packages/123456789/upload_urls"), params: params, headers: headers } + + it_behaves_like 'rejects invalid recipe' + + it 'returns a set of upload urls for the files requested' do + expected_response = { + 'conaninfo.txt': "#{Settings.gitlab.base_url}/api/v4/packages/conan/v1/files/#{package.conan_recipe_path}/0/package/123456789/0/conaninfo.txt", + 'conanmanifest.txt': "#{Settings.gitlab.base_url}/api/v4/packages/conan/v1/files/#{package.conan_recipe_path}/0/package/123456789/0/conanmanifest.txt", + 'conan_package.tgz': "#{Settings.gitlab.base_url}/api/v4/packages/conan/v1/files/#{package.conan_recipe_path}/0/package/123456789/0/conan_package.tgz" + } + + subject + + expect(response.body).to eq(expected_response.to_json) + end + end + + describe 'DELETE /api/v4/packages/conan/v1/conans/:package_name/package_version/:package_username/:package_channel' do + let(:recipe_path) { package.conan_recipe_path } + + subject { delete api("/packages/conan/v1/conans/#{recipe_path}"), headers: headers} + + it_behaves_like 'rejects invalid recipe' + + it 'returns unauthorized for users without valid permission' do + subject + + expect(response).to have_gitlab_http_status(:forbidden) + end + + context 'with delete permissions' do + before do + project.add_maintainer(user) + end + + it_behaves_like 'a gitlab tracking event', described_class.name, 'delete_package' + + it 'deletes a package' do + expect { subject }.to change { Packages::Package.count }.from(2).to(1) + end + end + end + end + + context 'file endpoints' do + let(:jwt) { build_jwt(personal_access_token) } + let(:headers) { build_token_auth_header(jwt.encoded) } + let(:recipe_path) { package.conan_recipe_path } + + shared_examples 'denies download with no token' do + context 'with no private token' do + let(:headers) { {} } + + it 'returns 400' do + subject + + expect(response).to have_gitlab_http_status(:unauthorized) + end + end + end + + shared_examples 'a public project with packages' do + it 'returns the file' do + subject + + expect(response).to have_gitlab_http_status(:ok) + expect(response.media_type).to eq('application/octet-stream') + end + end + + shared_examples 'an internal project with packages' do + before do + project.team.truncate + project.update!(visibility_level: Gitlab::VisibilityLevel::INTERNAL) + end + + it_behaves_like 'denies download with no token' + + it 'returns the file' do + subject + + expect(response).to have_gitlab_http_status(:ok) + expect(response.media_type).to eq('application/octet-stream') + end + end + + shared_examples 'a private project with packages' do + before do + project.update!(visibility_level: Gitlab::VisibilityLevel::PRIVATE) + end + + it_behaves_like 'denies download with no token' + + it 'returns the file' do + subject + + expect(response).to have_gitlab_http_status(:ok) + expect(response.media_type).to eq('application/octet-stream') + end + + it 'denies download when not enough permissions' do + project.add_guest(user) + + subject + + expect(response).to have_gitlab_http_status(:forbidden) + end + end + + shared_examples 'a project is not found' do + let(:recipe_path) { 'not/package/for/project' } + + it 'returns forbidden' do + subject + + expect(response).to have_gitlab_http_status(:forbidden) + end + end + + describe 'GET /api/v4/packages/conan/v1/files/:package_name/package_version/:package_username/:package_channel/ +:recipe_revision/export/:file_name' do + let(:recipe_file) { package.package_files.find_by(file_name: 'conanfile.py') } + let(:metadata) { recipe_file.conan_file_metadatum } + + subject do + get api("/packages/conan/v1/files/#{recipe_path}/#{metadata.recipe_revision}/export/#{recipe_file.file_name}"), + headers: headers + end + + it_behaves_like 'a public project with packages' + it_behaves_like 'an internal project with packages' + it_behaves_like 'a private project with packages' + it_behaves_like 'a project is not found' + end + + describe 'GET /api/v4/packages/conan/v1/files/:package_name/package_version/:package_username/:package_channel/ +:recipe_revision/package/:conan_package_reference/:package_revision/:file_name' do + let(:package_file) { package.package_files.find_by(file_name: 'conaninfo.txt') } + let(:metadata) { package_file.conan_file_metadatum } + + subject do + get api("/packages/conan/v1/files/#{recipe_path}/#{metadata.recipe_revision}/package/#{metadata.conan_package_reference}/#{metadata.package_revision}/#{package_file.file_name}"), + headers: headers + end + + it_behaves_like 'a public project with packages' + it_behaves_like 'an internal project with packages' + it_behaves_like 'a private project with packages' + it_behaves_like 'a project is not found' + + context 'tracking the conan_package.tgz download' do + let(:package_file) { package.package_files.find_by(file_name: ::Packages::Conan::FileMetadatum::PACKAGE_BINARY) } + + it_behaves_like 'a gitlab tracking event', described_class.name, 'pull_package' + end + end + end + + context 'file uploads' do + let(:jwt) { build_jwt(personal_access_token) } + let(:workhorse_token) { JWT.encode({ 'iss' => 'gitlab-workhorse' }, Gitlab::Workhorse.secret, 'HS256') } + let(:workhorse_header) { { 'GitLab-Workhorse' => '1.0', Gitlab::Workhorse::INTERNAL_API_REQUEST_HEADER => workhorse_token } } + let(:headers_with_token) { build_token_auth_header(jwt.encoded).merge(workhorse_header) } + let(:recipe_path) { "foo/bar/#{project.full_path.tr('/', '+')}/baz"} + + shared_examples 'uploads a package file' do + context 'with object storage disabled' do + context 'without a file from workhorse' do + let(:params) { { file: nil } } + + it_behaves_like 'package workhorse uploads' + + it 'rejects the request' do + subject + + expect(response).to have_gitlab_http_status(:bad_request) + end + end + + context 'without a token' do + it 'rejects request without a token' do + headers_with_token.delete('HTTP_AUTHORIZATION') + + subject + + expect(response).to have_gitlab_http_status(:unauthorized) + end + end + + context 'when params from workhorse are correct' do + it 'creates package and stores package file' do + expect { subject } + .to change { project.packages.count }.by(1) + .and change { Packages::PackageFile.count }.by(1) + + expect(response).to have_gitlab_http_status(:ok) + + package_file = project.packages.last.package_files.reload.last + expect(package_file.file_name).to eq(params[:file].original_filename) + end + + it "doesn't attempt to migrate file to object storage" do + expect(ObjectStorage::BackgroundMoveWorker).not_to receive(:perform_async) + + subject + end + end + end + + context 'with object storage enabled' do + context 'and direct upload enabled' do + let!(:fog_connection) do + stub_package_file_object_storage(direct_upload: true) + end + + let(:tmp_object) do + fog_connection.directories.new(key: 'packages').files.create( + key: "tmp/uploads/#{file_name}", + body: 'content' + ) + end + + let(:fog_file) { fog_to_uploaded_file(tmp_object) } + + ['123123', '../../123123'].each do |remote_id| + context "with invalid remote_id: #{remote_id}" do + let(:params) do + { + file: fog_file, + 'file.remote_id' => remote_id + } + end + + it 'responds with status 403' do + subject + + expect(response).to have_gitlab_http_status(:forbidden) + end + end + end + + context 'with valid remote_id' do + let(:params) do + { + file: fog_file, + 'file.remote_id' => file_name + } + end + + it 'creates package and stores package file' do + expect { subject } + .to change { project.packages.count }.by(1) + .and change { Packages::PackageFile.count }.by(1) + + expect(response).to have_gitlab_http_status(:ok) + + package_file = project.packages.last.package_files.reload.last + expect(package_file.file_name).to eq(params[:file].original_filename) + expect(package_file.file.read).to eq('content') + end + end + end + + it_behaves_like 'background upload schedules a file migration' + end + end + + shared_examples 'workhorse authorization' do + it 'authorizes posting package with a valid token' do + subject + + expect(response).to have_gitlab_http_status(:ok) + expect(response.media_type).to eq(Gitlab::Workhorse::INTERNAL_API_CONTENT_TYPE) + end + + it 'rejects request without a valid token' do + headers_with_token['HTTP_AUTHORIZATION'] = 'foo' + + subject + + expect(response).to have_gitlab_http_status(:unauthorized) + end + + it 'rejects request without a valid permission' do + project.add_guest(user) + + subject + + expect(response).to have_gitlab_http_status(:forbidden) + end + + it 'rejects requests that bypassed gitlab-workhorse' do + headers_with_token.delete(Gitlab::Workhorse::INTERNAL_API_REQUEST_HEADER) + + subject + + expect(response).to have_gitlab_http_status(:forbidden) + end + + context 'when using remote storage' do + context 'when direct upload is enabled' do + before do + stub_package_file_object_storage(enabled: true, direct_upload: true) + end + + it 'responds with status 200, location of package remote store and object details' do + subject + + expect(response).to have_gitlab_http_status(:ok) + expect(response.media_type).to eq(Gitlab::Workhorse::INTERNAL_API_CONTENT_TYPE) + expect(json_response).not_to have_key('TempPath') + expect(json_response['RemoteObject']).to have_key('ID') + expect(json_response['RemoteObject']).to have_key('GetURL') + expect(json_response['RemoteObject']).to have_key('StoreURL') + expect(json_response['RemoteObject']).to have_key('DeleteURL') + expect(json_response['RemoteObject']).not_to have_key('MultipartUpload') + end + end + + context 'when direct upload is disabled' do + before do + stub_package_file_object_storage(enabled: true, direct_upload: false) + end + + it 'handles as a local file' do + subject + + expect(response).to have_gitlab_http_status(:ok) + expect(response.media_type).to eq(Gitlab::Workhorse::INTERNAL_API_CONTENT_TYPE) + expect(json_response['TempPath']).to eq(::Packages::PackageFileUploader.workhorse_local_upload_path) + expect(json_response['RemoteObject']).to be_nil + end + end + end + end + + describe 'PUT /api/v4/packages/conan/v1/files/:package_name/package_version/:package_username/:package_channel/:recipe_revision/export/:file_name/authorize' do + subject { put api("/packages/conan/v1/files/#{recipe_path}/0/export/conanfile.py/authorize"), headers: headers_with_token } + + it_behaves_like 'rejects invalid recipe' + it_behaves_like 'workhorse authorization' + end + + describe 'PUT /api/v4/packages/conan/v1/files/:package_name/package_version/:package_username/:package_channel/:recipe_revision/export/:conan_package_reference/:package_revision/:file_name/authorize' do + subject { put api("/packages/conan/v1/files/#{recipe_path}/0/package/123456789/0/conaninfo.txt/authorize"), headers: headers_with_token } + + it_behaves_like 'rejects invalid recipe' + it_behaves_like 'workhorse authorization' + end + + describe 'PUT /api/v4/packages/conan/v1/files/:package_name/package_version/:package_username/:package_channel/:recipe_revision/export/:file_name' do + let(:file_name) { 'conanfile.py' } + let(:params) { { file: temp_file(file_name) } } + + subject do + workhorse_finalize( + "/api/v4/packages/conan/v1/files/#{recipe_path}/0/export/#{file_name}", + method: :put, + file_key: :file, + params: params, + headers: headers_with_token + ) + end + + it_behaves_like 'rejects invalid recipe' + it_behaves_like 'uploads a package file' + end + + describe 'PUT /api/v4/packages/conan/v1/files/:package_name/package_version/:package_username/:package_channel/:recipe_revision/export/:conan_package_reference/:package_revision/:file_name' do + let(:file_name) { 'conaninfo.txt' } + let(:params) { { file: temp_file(file_name) } } + + subject do + workhorse_finalize( + "/api/v4/packages/conan/v1/files/#{recipe_path}/0/package/123456789/0/#{file_name}", + method: :put, + file_key: :file, + params: params, + headers: headers_with_token + ) + end + + it_behaves_like 'rejects invalid recipe' + it_behaves_like 'uploads a package file' + context 'tracking the conan_package.tgz upload' do + let(:file_name) { ::Packages::Conan::FileMetadatum::PACKAGE_BINARY } + + it_behaves_like 'a gitlab tracking event', described_class.name, 'push_package' + end + end + end +end diff --git a/spec/requests/api/go_proxy_spec.rb b/spec/requests/api/go_proxy_spec.rb new file mode 100644 index 00000000000..91e455dac19 --- /dev/null +++ b/spec/requests/api/go_proxy_spec.rb @@ -0,0 +1,465 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe API::GoProxy do + include PackagesManagerApiSpecHelpers + + let_it_be(:user) { create :user } + let_it_be(:project) { create :project_empty_repo, creator: user, path: 'my-go-lib' } + let_it_be(:base) { "#{Settings.build_gitlab_go_url}/#{project.full_path}" } + + let_it_be(:oauth) { create :oauth_access_token, scopes: 'api', resource_owner: user } + let_it_be(:job) { create :ci_build, user: user } + let_it_be(:pa_token) { create :personal_access_token, user: user } + + let_it_be(:modules) do + commits = [ + create(:go_module_commit, :files, project: project, tag: 'v1.0.0', files: { 'README.md' => 'Hi' } ), + create(:go_module_commit, :module, project: project, tag: 'v1.0.1' ), + create(:go_module_commit, :package, project: project, tag: 'v1.0.2', path: 'pkg' ), + create(:go_module_commit, :module, project: project, tag: 'v1.0.3', name: 'mod' ), + create(:go_module_commit, :files, project: project, files: { 'y.go' => "package a\n" } ), + create(:go_module_commit, :module, project: project, name: 'v2' ), + create(:go_module_commit, :files, project: project, tag: 'v2.0.0', files: { 'v2/x.go' => "package a\n" }) + ] + + { sha: [commits[4].sha, commits[5].sha] } + end + + before do + project.add_developer(user) + + stub_feature_flags(go_proxy_disable_gomod_validation: false) + + modules + end + + shared_examples 'an unavailable resource' do + it 'returns not found' do + get_resource(user) + + expect(response).to have_gitlab_http_status(:not_found) + end + end + + shared_examples 'a module version list resource' do |*versions, path: ''| + let(:module_name) { "#{base}#{path}" } + let(:resource) { "list" } + + it "returns #{versions.empty? ? 'nothing' : versions.join(', ')}" do + get_resource(user) + + expect(response).to have_gitlab_http_status(:ok) + expect(response.body.split("\n").to_set).to eq(versions.to_set) + end + end + + shared_examples 'a missing module version list resource' do |path: ''| + let(:module_name) { "#{base}#{path}" } + let(:resource) { "list" } + + it_behaves_like 'an unavailable resource' + end + + shared_examples 'a module version information resource' do |version, path: ''| + let(:module_name) { "#{base}#{path}" } + let(:resource) { "#{version}.info" } + + it "returns information for #{version}" do + get_resource(user) + + time = project.repository.find_tag(version).dereferenced_target.committed_date + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response).to be_kind_of(Hash) + expect(json_response['Version']).to eq(version) + expect(json_response['Time']).to eq(time.strftime('%Y-%m-%dT%H:%M:%S.%L%:z')) + end + end + + shared_examples 'a missing module version information resource' do |version, path: ''| + let(:module_name) { "#{base}#{path}" } + let(:resource) { "#{version}.info" } + + it_behaves_like 'an unavailable resource' + end + + shared_examples 'a module pseudo-version information resource' do |prefix, path: ''| + let(:module_name) { "#{base}#{path}" } + let(:commit) { project.repository.commit_by(oid: sha) } + let(:version) { fmt_pseudo_version prefix, commit } + let(:resource) { "#{version}.info" } + + it "returns information for #{prefix}yyyymmddhhmmss-abcdefabcdef" do + get_resource(user) + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response).to be_kind_of(Hash) + expect(json_response['Version']).to eq(version) + expect(json_response['Time']).to eq(commit.committed_date.strftime('%Y-%m-%dT%H:%M:%S.%L%:z')) + end + end + + shared_examples 'a missing module pseudo-version information resource' do |path: ''| + let(:module_name) { "#{base}#{path}" } + let(:commit) do + raise "tried to reference :commit without defining :sha" unless defined?(sha) + + project.repository.commit_by(oid: sha) + end + let(:resource) { "#{version}.info" } + + it_behaves_like 'an unavailable resource' + end + + shared_examples 'a module file resource' do |version, path: ''| + let(:module_name) { "#{base}#{path}" } + let(:resource) { "#{version}.mod" } + + it "returns #{path}/go.mod from the repo" do + get_resource(user) + + expect(response).to have_gitlab_http_status(:ok) + expect(response.body.split("\n", 2).first).to eq("module #{module_name}") + end + end + + shared_examples 'a missing module file resource' do |version, path: ''| + let(:module_name) { "#{base}#{path}" } + let(:resource) { "#{version}.mod" } + + it_behaves_like 'an unavailable resource' + end + + shared_examples 'a module archive resource' do |version, entries, path: ''| + let(:module_name) { "#{base}#{path}" } + let(:resource) { "#{version}.zip" } + + it "returns an archive of #{path.empty? ? '/' : path} @ #{version} from the repo" do + get_resource(user) + + expect(response).to have_gitlab_http_status(:ok) + + entries = entries.map { |e| "#{module_name}@#{version}/#{e}" }.to_set + actual = Set[] + Zip::InputStream.open(StringIO.new(response.body)) do |zip| + while (entry = zip.get_next_entry) + actual.add(entry.name) + end + end + + expect(actual).to eq(entries) + end + end + + describe 'GET /projects/:id/packages/go/*module_name/@v/list' do + context 'for the root module' do + it_behaves_like 'a module version list resource', 'v1.0.1', 'v1.0.2', 'v1.0.3' + end + + context 'for the package' do + it_behaves_like 'a module version list resource', path: '/pkg' + end + + context 'for the submodule' do + it_behaves_like 'a module version list resource', 'v1.0.3', path: '/mod' + end + + context 'for the root module v2' do + it_behaves_like 'a module version list resource', 'v2.0.0', path: '/v2' + end + + context 'with a URL encoded relative path component' do + it_behaves_like 'a missing module version list resource', path: '/%2E%2E%2Fxyz' + end + + context 'with the feature disabled' do + before do + stub_feature_flags(go_proxy: false) + end + + it_behaves_like 'a missing module version list resource' + end + end + + describe 'GET /projects/:id/packages/go/*module_name/@v/:module_version.info' do + context 'with the root module v1.0.1' do + it_behaves_like 'a module version information resource', 'v1.0.1' + end + + context 'with the submodule v1.0.3' do + it_behaves_like 'a module version information resource', 'v1.0.3', path: '/mod' + end + + context 'with the root module v2.0.0' do + it_behaves_like 'a module version information resource', 'v2.0.0', path: '/v2' + end + + context 'with an invalid path' do + it_behaves_like 'a missing module version information resource', 'v1.0.3', path: '/pkg' + end + + context 'with an invalid version' do + it_behaves_like 'a missing module version information resource', 'v1.0.1', path: '/mod' + end + + context 'with a pseudo-version for v1' do + it_behaves_like 'a module pseudo-version information resource', 'v1.0.4-0.' do + let(:sha) { modules[:sha][0] } + end + end + + context 'with a pseudo-version for v2' do + it_behaves_like 'a module pseudo-version information resource', 'v2.0.0-', path: '/v2' do + let(:sha) { modules[:sha][1] } + end + end + + context 'with a pseudo-version with an invalid timestamp' do + it_behaves_like 'a missing module pseudo-version information resource' do + let(:version) { "v1.0.4-0.00000000000000-#{modules[:sha][0][0..11]}" } + end + end + + context 'with a pseudo-version with an invalid commit sha' do + it_behaves_like 'a missing module pseudo-version information resource' do + let(:sha) { modules[:sha][0] } + let(:version) { "v1.0.4-0.#{commit.committed_date.strftime('%Y%m%d%H%M%S')}-000000000000" } + end + end + + context 'with a pseudo-version with a short commit sha' do + it_behaves_like 'a missing module pseudo-version information resource' do + let(:sha) { modules[:sha][0] } + let(:version) { "v1.0.4-0.#{commit.committed_date.strftime('%Y%m%d%H%M%S')}-#{modules[:sha][0][0..10]}" } + end + end + end + + describe 'GET /projects/:id/packages/go/*module_name/@v/:module_version.mod' do + context 'with the root module v1.0.1' do + it_behaves_like 'a module file resource', 'v1.0.1' + end + + context 'with the submodule v1.0.3' do + it_behaves_like 'a module file resource', 'v1.0.3', path: '/mod' + end + + context 'with the root module v2.0.0' do + it_behaves_like 'a module file resource', 'v2.0.0', path: '/v2' + end + + context 'with an invalid path' do + it_behaves_like 'a missing module file resource', 'v1.0.3', path: '/pkg' + end + + context 'with an invalid version' do + it_behaves_like 'a missing module file resource', 'v1.0.1', path: '/mod' + end + end + + describe 'GET /projects/:id/packages/go/*module_name/@v/:module_version.zip' do + context 'with the root module v1.0.1' do + it_behaves_like 'a module archive resource', 'v1.0.1', ['README.md', 'go.mod', 'a.go'] + end + + context 'with the root module v1.0.2' do + it_behaves_like 'a module archive resource', 'v1.0.2', ['README.md', 'go.mod', 'a.go', 'pkg/b.go'] + end + + context 'with the root module v1.0.3' do + it_behaves_like 'a module archive resource', 'v1.0.3', ['README.md', 'go.mod', 'a.go', 'pkg/b.go'] + end + + context 'with the submodule v1.0.3' do + it_behaves_like 'a module archive resource', 'v1.0.3', ['go.mod', 'a.go'], path: '/mod' + end + + context 'with the root module v2.0.0' do + it_behaves_like 'a module archive resource', 'v2.0.0', ['go.mod', 'a.go', 'x.go'], path: '/v2' + end + end + + context 'with an invalid module directive' do + let_it_be(:project) { create :project_empty_repo, :public, creator: user } + let_it_be(:base) { "#{Settings.build_gitlab_go_url}/#{project.full_path}" } + + let_it_be(:modules) do + create(:go_module_commit, :files, project: project, files: { 'a.go' => "package\a" } ) + create(:go_module_commit, :files, project: project, tag: 'v1.0.0', files: { 'go.mod' => "module not/a/real/module\n" }) + create(:go_module_commit, :files, project: project, files: { 'v2/a.go' => "package a\n" } ) + create(:go_module_commit, :files, project: project, tag: 'v2.0.0', files: { 'v2/go.mod' => "module #{base}\n" } ) + end + + describe 'GET /projects/:id/packages/go/*module_name/@v/list' do + context 'with a completely wrong directive for v1' do + it_behaves_like 'a module version list resource' + end + + context 'with a directive omitting the suffix for v2' do + it_behaves_like 'a module version list resource', path: '/v2' + end + end + + describe 'GET /projects/:id/packages/go/*module_name/@v/:module_version.info' do + context 'with a completely wrong directive for v1' do + it_behaves_like 'a missing module version information resource', 'v1.0.0' + end + + context 'with a directive omitting the suffix for v2' do + it_behaves_like 'a missing module version information resource', 'v2.0.0', path: '/v2' + end + end + end + + context 'with a case sensitive project and versions' do + let_it_be(:project) { create :project_empty_repo, :public, creator: user, path: 'MyGoLib' } + let_it_be(:base) { "#{Settings.build_gitlab_go_url}/#{project.full_path}" } + let_it_be(:base_encoded) { base.gsub(/[A-Z]/) { |s| "!#{s.downcase}"} } + + let_it_be(:modules) do + create(:go_module_commit, :files, project: project, files: { 'README.md' => "Hi" }) + create(:go_module_commit, :module, project: project, tag: 'v1.0.1-prerelease') + create(:go_module_commit, :package, project: project, tag: 'v1.0.1-Prerelease', path: 'pkg') + end + + describe 'GET /projects/:id/packages/go/*module_name/@v/list' do + let(:resource) { "list" } + + context 'with a case encoded path' do + it_behaves_like 'a module version list resource', 'v1.0.1-prerelease', 'v1.0.1-Prerelease' do + let(:module_name) { base_encoded } + end + end + + context 'without a case encoded path' do + it_behaves_like 'a missing module version list resource' do + let(:module_name) { base.downcase } + end + end + end + + describe 'GET /projects/:id/packages/go/*module_name/@v/:module_version.info' do + context 'with a case encoded path' do + it_behaves_like 'a module version information resource', 'v1.0.1-Prerelease' do + let(:module_name) { base_encoded } + let(:resource) { "v1.0.1-!prerelease.info" } + end + end + + context 'without a case encoded path' do + it_behaves_like 'a module version information resource', 'v1.0.1-prerelease' do + let(:module_name) { base_encoded } + let(:resource) { "v1.0.1-prerelease.info" } + end + end + end + end + + context 'with a private project' do + let(:module_name) { base } + + before do + project.update(visibility_level: Gitlab::VisibilityLevel::PRIVATE) + end + + describe 'GET /projects/:id/packages/go/*module_name/@v/list' do + let(:resource) { "list" } + + it 'returns ok with an oauth token' do + get_resource(oauth_access_token: oauth) + + expect(response).to have_gitlab_http_status(:ok) + end + + it 'returns ok with a job token' do + get_resource(oauth_access_token: job) + + expect(response).to have_gitlab_http_status(:ok) + end + + it 'returns ok with a personal access token' do + get_resource(personal_access_token: pa_token) + + expect(response).to have_gitlab_http_status(:ok) + end + + it 'returns ok with a personal access token and basic authentication' do + get_resource(headers: build_basic_auth_header(user.username, pa_token.token)) + + expect(response).to have_gitlab_http_status(:ok) + end + + it 'returns unauthorized with no authentication' do + get_resource + + expect(response).to have_gitlab_http_status(:unauthorized) + end + end + end + + context 'with a public project' do + let(:module_name) { base } + + before do + project.update(visibility_level: Gitlab::VisibilityLevel::PUBLIC) + end + + describe 'GET /projects/:id/packages/go/*module_name/@v/list' do + let(:resource) { "list" } + + it 'returns ok with no authentication' do + get_resource + + expect(response).to have_gitlab_http_status(:ok) + end + end + end + + context 'with a non-existent project' do + def get_resource(user = nil, **params) + get api("/projects/not%2fa%2fproject/packages/go/#{base}/@v/list", user, params) + end + + describe 'GET /projects/:id/packages/go/*module_name/@v/list' do + it 'returns not found with a user' do + get_resource(user) + + expect(response).to have_gitlab_http_status(:not_found) + end + + it 'returns not found with an oauth token' do + get_resource(oauth_access_token: oauth) + + expect(response).to have_gitlab_http_status(:not_found) + end + + it 'returns not found with a job token' do + get_resource(oauth_access_token: job) + + expect(response).to have_gitlab_http_status(:not_found) + end + + it 'returns not found with a personal access token' do + get_resource(personal_access_token: pa_token) + + expect(response).to have_gitlab_http_status(:not_found) + end + + it 'returns unauthorized with no authentication' do + get_resource + + expect(response).to have_gitlab_http_status(:unauthorized) + end + end + end + + def get_resource(user = nil, headers: {}, **params) + get api("/projects/#{project.id}/packages/go/#{module_name}/@v/#{resource}", user, params), headers: headers + end + + def fmt_pseudo_version(prefix, commit) + "#{prefix}#{commit.committed_date.strftime('%Y%m%d%H%M%S')}-#{commit.sha[0..11]}" + end +end diff --git a/spec/requests/api/graphql/project/packages_spec.rb b/spec/requests/api/graphql/project/packages_spec.rb new file mode 100644 index 00000000000..88f97f9256b --- /dev/null +++ b/spec/requests/api/graphql/project/packages_spec.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'getting a package list for a project' do + include GraphqlHelpers + + let_it_be(:project) { create(:project) } + let_it_be(:current_user) { create(:user) } + let_it_be(:package) { create(:package, project: project) } + let(:packages_data) { graphql_data['project']['packages']['edges'] } + + let(:fields) do + <<~QUERY + edges { + node { + #{all_graphql_fields_for('packages'.classify)} + } + } + QUERY + end + + let(:query) do + graphql_query_for( + 'project', + { 'fullPath' => project.full_path }, + query_graphql_field('packages', {}, fields) + ) + end + + context 'without the need for a license' do + context 'when user has access to the project' do + before do + project.add_reporter(current_user) + post_graphql(query, current_user: current_user) + end + + it_behaves_like 'a working graphql query' + + it 'returns packages successfully' do + expect(packages_data[0]['node']['name']).to eq package.name + end + end + + context 'when the user does not have access to the project/packages' do + before do + post_graphql(query, current_user: current_user) + end + + it_behaves_like 'a working graphql query' + + it 'returns nil' do + expect(graphql_data['project']).to be_nil + end + end + + context 'when the user is not autenthicated' do + before do + post_graphql(query) + end + + it_behaves_like 'a working graphql query' + + it 'returns nil' do + expect(graphql_data['project']).to be_nil + end + end + end +end diff --git a/spec/requests/api/group_packages_spec.rb b/spec/requests/api/group_packages_spec.rb new file mode 100644 index 00000000000..7c7e8da3fb1 --- /dev/null +++ b/spec/requests/api/group_packages_spec.rb @@ -0,0 +1,147 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe API::GroupPackages do + let_it_be(:group) { create(:group, :public) } + let_it_be(:project) { create(:project, :public, namespace: group, name: 'project A') } + let_it_be(:user) { create(:user) } + + subject { get api(url) } + + describe 'GET /groups/:id/packages' do + let(:url) { "/groups/#{group.id}/packages" } + let(:package_schema) { 'public_api/v4/packages/group_packages' } + + context 'without the need for a license' do + context 'with sorting' do + let_it_be(:package1) { create(:npm_package, project: project, version: '3.1.0', name: "@#{project.root_namespace.path}/foo1") } + let_it_be(:package2) { create(:nuget_package, project: project, version: '2.0.4') } + let(:package3) { create(:maven_package, project: project, version: '1.1.1', name: 'zzz') } + + before do + travel_to(1.day.ago) do + package3 + end + end + + context 'without sorting params' do + let(:packages) { [package3, package1, package2] } + + it 'sorts by created_at asc' do + subject + + expect(json_response.map { |package| package['id'] }).to eq(packages.map(&:id)) + end + end + + it_behaves_like 'package sorting', 'name' do + let(:packages) { [package1, package2, package3] } + end + + it_behaves_like 'package sorting', 'created_at' do + let(:packages) { [package3, package1, package2] } + end + + it_behaves_like 'package sorting', 'version' do + let(:packages) { [package3, package2, package1] } + end + + it_behaves_like 'package sorting', 'type' do + let(:packages) { [package3, package1, package2] } + end + + it_behaves_like 'package sorting', 'project_path' do + let(:another_project) { create(:project, :public, namespace: group, name: 'project B') } + let!(:package4) { create(:npm_package, project: another_project, version: '3.1.0', name: "@#{project.root_namespace.path}/bar") } + + let(:packages) { [package1, package2, package3, package4] } + end + end + + context 'with private group' do + let!(:package1) { create(:package, project: project) } + let!(:package2) { create(:package, project: project) } + + let(:group) { create(:group, :private) } + let(:subgroup) { create(:group, :private, parent: group) } + let(:project) { create(:project, :private, namespace: group) } + let(:subproject) { create(:project, :private, namespace: subgroup) } + + context 'with unauthenticated user' do + it_behaves_like 'rejects packages access', :group, :no_type, :not_found + end + + context 'with authenticated user' do + subject { get api(url, user) } + + it_behaves_like 'returns packages', :group, :owner + it_behaves_like 'returns packages', :group, :maintainer + it_behaves_like 'returns packages', :group, :developer + it_behaves_like 'rejects packages access', :group, :reporter, :forbidden + it_behaves_like 'rejects packages access', :group, :guest, :forbidden + + context 'with subgroup' do + let(:subgroup) { create(:group, :private, parent: group) } + let(:subproject) { create(:project, :private, namespace: subgroup) } + let!(:package3) { create(:npm_package, project: subproject) } + + it_behaves_like 'returns packages with subgroups', :group, :owner + it_behaves_like 'returns packages with subgroups', :group, :maintainer + it_behaves_like 'returns packages with subgroups', :group, :developer + it_behaves_like 'rejects packages access', :group, :reporter, :forbidden + it_behaves_like 'rejects packages access', :group, :guest, :forbidden + + context 'excluding subgroup' do + let(:url) { "/groups/#{group.id}/packages?exclude_subgroups=true" } + + it_behaves_like 'returns packages', :group, :owner + it_behaves_like 'returns packages', :group, :maintainer + it_behaves_like 'returns packages', :group, :developer + it_behaves_like 'rejects packages access', :group, :reporter, :forbidden + it_behaves_like 'rejects packages access', :group, :guest, :forbidden + end + end + end + end + + context 'with public group' do + let_it_be(:package1) { create(:package, project: project) } + let_it_be(:package2) { create(:package, project: project) } + + context 'with unauthenticated user' do + it_behaves_like 'returns packages', :group, :no_type + end + + context 'with authenticated user' do + subject { get api(url, user) } + + it_behaves_like 'returns packages', :group, :owner + it_behaves_like 'returns packages', :group, :maintainer + it_behaves_like 'returns packages', :group, :developer + it_behaves_like 'returns packages', :group, :reporter + it_behaves_like 'returns packages', :group, :guest + end + end + + context 'with pagination params' do + let_it_be(:package1) { create(:package, project: project) } + let_it_be(:package2) { create(:package, project: project) } + let_it_be(:package3) { create(:npm_package, project: project) } + let_it_be(:package4) { create(:npm_package, project: project) } + + it_behaves_like 'returns paginated packages' + end + + it_behaves_like 'filters on each package_type', is_project: false + + context 'does not accept non supported package_type value' do + include_context 'package filter context' + + let(:url) { group_filter_url(:type, 'foo') } + + it_behaves_like 'returning response status', :bad_request + end + end + end +end diff --git a/spec/requests/api/maven_packages_spec.rb b/spec/requests/api/maven_packages_spec.rb new file mode 100644 index 00000000000..189d6a4c1a4 --- /dev/null +++ b/spec/requests/api/maven_packages_spec.rb @@ -0,0 +1,569 @@ +# frozen_string_literal: true +require 'spec_helper' + +RSpec.describe API::MavenPackages do + include WorkhorseHelpers + + let_it_be(:group) { create(:group) } + let_it_be(:user) { create(:user) } + let_it_be(:project, reload: true) { create(:project, :public, namespace: group) } + let_it_be(:package, reload: true) { create(:maven_package, project: project, name: project.full_path) } + let_it_be(:maven_metadatum, reload: true) { package.maven_metadatum } + let_it_be(:package_file) { package.package_files.with_file_name_like('%.xml').first } + let_it_be(:jar_file) { package.package_files.with_file_name_like('%.jar').first } + let_it_be(:personal_access_token) { create(:personal_access_token, user: user) } + let_it_be(:job) { create(:ci_build, user: user) } + let_it_be(:deploy_token) { create(:deploy_token, read_package_registry: true, write_package_registry: true) } + let_it_be(:project_deploy_token) { create(:project_deploy_token, deploy_token: deploy_token, project: project) } + + let(:workhorse_token) { JWT.encode({ 'iss' => 'gitlab-workhorse' }, Gitlab::Workhorse.secret, 'HS256') } + let(:headers) { { 'GitLab-Workhorse' => '1.0', Gitlab::Workhorse::INTERNAL_API_REQUEST_HEADER => workhorse_token } } + let(:headers_with_token) { headers.merge('Private-Token' => personal_access_token.token) } + + let(:headers_with_deploy_token) do + headers.merge( + Gitlab::Auth::AuthFinders::DEPLOY_TOKEN_HEADER => deploy_token.token + ) + end + + let(:version) { '1.0-SNAPSHOT' } + + before do + project.add_developer(user) + end + + shared_examples 'tracking the file download event' do + context 'with jar file' do + let_it_be(:package_file) { jar_file } + + it_behaves_like 'a gitlab tracking event', described_class.name, 'pull_package' + end + end + + shared_examples 'processing HEAD requests' do + subject { head api(url) } + + before do + allow_any_instance_of(::Packages::PackageFileUploader).to receive(:fog_credentials).and_return(object_storage_credentials) + stub_package_file_object_storage(enabled: object_storage_enabled) + end + + context 'with object storage enabled' do + let(:object_storage_enabled) { true } + + before do + allow_any_instance_of(::Packages::PackageFileUploader).to receive(:file_storage?).and_return(false) + end + + context 'non AWS provider' do + let(:object_storage_credentials) { { provider: 'Google' } } + + it 'does not generated a signed url for head' do + expect_any_instance_of(Fog::AWS::Storage::Files).not_to receive(:head_url) + + subject + end + end + + context 'with AWS provider' do + let(:object_storage_credentials) { { provider: 'AWS', aws_access_key_id: 'test', aws_secret_access_key: 'test' } } + + it 'generates a signed url for head' do + expect_any_instance_of(Fog::AWS::Storage::Files).to receive(:head_url).and_call_original + + subject + end + end + end + + context 'with object storage disabled' do + let(:object_storage_enabled) { false } + let(:object_storage_credentials) { {} } + + it 'does not generate a signed url for head' do + expect_any_instance_of(Fog::AWS::Storage::Files).not_to receive(:head_url) + + subject + end + end + end + + shared_examples 'downloads with a deploy token' do + it 'allows download with deploy token' do + download_file( + package_file.file_name, + {}, + Gitlab::Auth::AuthFinders::DEPLOY_TOKEN_HEADER => deploy_token.token + ) + + expect(response).to have_gitlab_http_status(:ok) + expect(response.media_type).to eq('application/octet-stream') + end + end + + shared_examples 'downloads with a job token' do + it 'allows download with job token' do + download_file(package_file.file_name, job_token: job.token) + + expect(response).to have_gitlab_http_status(:ok) + expect(response.media_type).to eq('application/octet-stream') + end + end + + describe 'GET /api/v4/packages/maven/*path/:file_name' do + context 'a public project' do + subject { download_file(package_file.file_name) } + + it_behaves_like 'tracking the file download event' + + it 'returns the file' do + subject + + expect(response).to have_gitlab_http_status(:ok) + expect(response.media_type).to eq('application/octet-stream') + end + + it 'returns sha1 of the file' do + download_file(package_file.file_name + '.sha1') + + expect(response).to have_gitlab_http_status(:ok) + expect(response.media_type).to eq('text/plain') + expect(response.body).to eq(package_file.file_sha1) + end + end + + context 'internal project' do + before do + project.team.truncate + project.update!(visibility_level: Gitlab::VisibilityLevel::INTERNAL) + end + + subject { download_file_with_token(package_file.file_name) } + + it_behaves_like 'tracking the file download event' + + it 'returns the file' do + subject + + expect(response).to have_gitlab_http_status(:ok) + expect(response.media_type).to eq('application/octet-stream') + end + + it 'denies download when no private token' do + download_file(package_file.file_name) + + expect(response).to have_gitlab_http_status(:forbidden) + end + + it_behaves_like 'downloads with a job token' + + it_behaves_like 'downloads with a deploy token' + end + + context 'private project' do + subject { download_file_with_token(package_file.file_name) } + + before do + project.update!(visibility_level: Gitlab::VisibilityLevel::PRIVATE) + end + + it_behaves_like 'tracking the file download event' + + it 'returns the file' do + subject + + expect(response).to have_gitlab_http_status(:ok) + expect(response.media_type).to eq('application/octet-stream') + end + + it 'denies download when not enough permissions' do + project.add_guest(user) + + subject + + expect(response).to have_gitlab_http_status(:forbidden) + end + + it 'denies download when no private token' do + download_file(package_file.file_name) + + expect(response).to have_gitlab_http_status(:forbidden) + end + + it_behaves_like 'downloads with a job token' + + it_behaves_like 'downloads with a deploy token' + end + + context 'project name is different from a package name' do + before do + maven_metadatum.update!(path: "wrong_name/#{package.version}") + end + + it 'rejects request' do + download_file(package_file.file_name) + + expect(response).to have_gitlab_http_status(:forbidden) + end + end + + def download_file(file_name, params = {}, request_headers = headers) + get api("/packages/maven/#{maven_metadatum.path}/#{file_name}"), params: params, headers: request_headers + end + + def download_file_with_token(file_name, params = {}, request_headers = headers_with_token) + download_file(file_name, params, request_headers) + end + end + + describe 'HEAD /api/v4/packages/maven/*path/:file_name' do + let(:url) { "/packages/maven/#{package.maven_metadatum.path}/#{package_file.file_name}" } + + it_behaves_like 'processing HEAD requests' + end + + describe 'GET /api/v4/groups/:id/-/packages/maven/*path/:file_name' do + before do + project.team.truncate + group.add_developer(user) + end + + context 'a public project' do + subject { download_file(package_file.file_name) } + + it_behaves_like 'tracking the file download event' + + it 'returns the file' do + subject + + expect(response).to have_gitlab_http_status(:ok) + expect(response.media_type).to eq('application/octet-stream') + end + + it 'returns sha1 of the file' do + download_file(package_file.file_name + '.sha1') + + expect(response).to have_gitlab_http_status(:ok) + expect(response.media_type).to eq('text/plain') + expect(response.body).to eq(package_file.file_sha1) + end + end + + context 'internal project' do + before do + group.group_member(user).destroy + project.update!(visibility_level: Gitlab::VisibilityLevel::INTERNAL) + end + + subject { download_file_with_token(package_file.file_name) } + + it_behaves_like 'tracking the file download event' + + it 'returns the file' do + subject + + expect(response).to have_gitlab_http_status(:ok) + expect(response.media_type).to eq('application/octet-stream') + end + + it 'denies download when no private token' do + download_file(package_file.file_name) + + expect(response).to have_gitlab_http_status(:not_found) + end + + it_behaves_like 'downloads with a job token' + + it_behaves_like 'downloads with a deploy token' + end + + context 'private project' do + before do + project.update!(visibility_level: Gitlab::VisibilityLevel::PRIVATE) + end + + subject { download_file_with_token(package_file.file_name) } + + it_behaves_like 'tracking the file download event' + + it 'returns the file' do + subject + + expect(response).to have_gitlab_http_status(:ok) + expect(response.media_type).to eq('application/octet-stream') + end + + it 'denies download when not enough permissions' do + group.add_guest(user) + + subject + + expect(response).to have_gitlab_http_status(:forbidden) + end + + it 'denies download when no private token' do + download_file(package_file.file_name) + + expect(response).to have_gitlab_http_status(:not_found) + end + + it_behaves_like 'downloads with a job token' + + it_behaves_like 'downloads with a deploy token' + end + + def download_file(file_name, params = {}, request_headers = headers) + get api("/groups/#{group.id}/-/packages/maven/#{maven_metadatum.path}/#{file_name}"), params: params, headers: request_headers + end + + def download_file_with_token(file_name, params = {}, request_headers = headers_with_token) + download_file(file_name, params, request_headers) + end + end + + describe 'HEAD /api/v4/groups/:id/-/packages/maven/*path/:file_name' do + let(:url) { "/groups/#{group.id}/-/packages/maven/#{package.maven_metadatum.path}/#{package_file.file_name}" } + + it_behaves_like 'processing HEAD requests' + end + + describe 'GET /api/v4/projects/:id/packages/maven/*path/:file_name' do + context 'a public project' do + subject { download_file(package_file.file_name) } + + it_behaves_like 'tracking the file download event' + + it 'returns the file' do + subject + + expect(response).to have_gitlab_http_status(:ok) + expect(response.media_type).to eq('application/octet-stream') + end + + it 'returns sha1 of the file' do + download_file(package_file.file_name + '.sha1') + + expect(response).to have_gitlab_http_status(:ok) + expect(response.media_type).to eq('text/plain') + expect(response.body).to eq(package_file.file_sha1) + end + end + + context 'private project' do + before do + project.update!(visibility_level: Gitlab::VisibilityLevel::PRIVATE) + end + + subject { download_file_with_token(package_file.file_name) } + + it_behaves_like 'tracking the file download event' + + it 'returns the file' do + subject + + expect(response).to have_gitlab_http_status(:ok) + expect(response.media_type).to eq('application/octet-stream') + end + + it 'denies download when not enough permissions' do + project.add_guest(user) + + subject + + expect(response).to have_gitlab_http_status(:forbidden) + end + + it 'denies download when no private token' do + download_file(package_file.file_name) + + expect(response).to have_gitlab_http_status(:not_found) + end + + it_behaves_like 'downloads with a job token' + + it_behaves_like 'downloads with a deploy token' + end + + def download_file(file_name, params = {}, request_headers = headers) + get api("/projects/#{project.id}/packages/maven/" \ + "#{maven_metadatum.path}/#{file_name}"), params: params, headers: request_headers + end + + def download_file_with_token(file_name, params = {}, request_headers = headers_with_token) + download_file(file_name, params, request_headers) + end + end + + describe 'HEAD /api/v4/projects/:id/packages/maven/*path/:file_name' do + let(:url) { "/projects/#{project.id}/packages/maven/#{package.maven_metadatum.path}/#{package_file.file_name}" } + + it_behaves_like 'processing HEAD requests' + end + + describe 'PUT /api/v4/projects/:id/packages/maven/*path/:file_name/authorize' do + it 'rejects a malicious request' do + put api("/projects/#{project.id}/packages/maven/com/example/my-app/#{version}/%2e%2e%2F.ssh%2Fauthorized_keys/authorize"), params: {}, headers: headers_with_token + + expect(response).to have_gitlab_http_status(:bad_request) + end + + it 'authorizes posting package with a valid token' do + authorize_upload_with_token + + expect(response).to have_gitlab_http_status(:ok) + expect(response.media_type).to eq(Gitlab::Workhorse::INTERNAL_API_CONTENT_TYPE) + expect(json_response['TempPath']).not_to be_nil + end + + it 'rejects request without a valid token' do + headers_with_token['Private-Token'] = 'foo' + + authorize_upload_with_token + + expect(response).to have_gitlab_http_status(:unauthorized) + end + + it 'rejects request without a valid permission' do + project.add_guest(user) + + authorize_upload_with_token + + expect(response).to have_gitlab_http_status(:forbidden) + end + + it 'rejects requests that did not go through gitlab-workhorse' do + headers.delete(Gitlab::Workhorse::INTERNAL_API_REQUEST_HEADER) + + authorize_upload_with_token + + expect(response).to have_gitlab_http_status(:forbidden) + end + + it 'authorizes upload with job token' do + authorize_upload(job_token: job.token) + + expect(response).to have_gitlab_http_status(:ok) + end + + it 'authorizes upload with deploy token' do + authorize_upload({}, headers_with_deploy_token) + + expect(response).to have_gitlab_http_status(:ok) + end + + def authorize_upload(params = {}, request_headers = headers) + put api("/projects/#{project.id}/packages/maven/com/example/my-app/#{version}/maven-metadata.xml/authorize"), params: params, headers: request_headers + end + + def authorize_upload_with_token(params = {}, request_headers = headers_with_token) + authorize_upload(params, request_headers) + end + end + + describe 'PUT /api/v4/projects/:id/packages/maven/*path/:file_name' do + let(:workhorse_token) { JWT.encode({ 'iss' => 'gitlab-workhorse' }, Gitlab::Workhorse.secret, 'HS256') } + let(:workhorse_header) { { 'GitLab-Workhorse' => '1.0', Gitlab::Workhorse::INTERNAL_API_REQUEST_HEADER => workhorse_token } } + let(:send_rewritten_field) { true } + let(:file_upload) { fixture_file_upload('spec/fixtures/packages/maven/my-app-1.0-20180724.124855-1.jar') } + + before do + # by configuring this path we allow to pass temp file from any path + allow(Packages::PackageFileUploader).to receive(:workhorse_upload_path).and_return('/') + end + + it 'rejects requests without a file from workhorse' do + upload_file_with_token + + expect(response).to have_gitlab_http_status(:bad_request) + end + + it 'rejects request without a token' do + upload_file + + expect(response).to have_gitlab_http_status(:unauthorized) + end + + context 'without workhorse rewritten field' do + let(:send_rewritten_field) { false } + + it 'rejects the request' do + upload_file_with_token + + expect(response).to have_gitlab_http_status(:bad_request) + end + end + + context 'when params from workhorse are correct' do + let(:params) { { file: file_upload } } + + it 'rejects a malicious request' do + put api("/projects/#{project.id}/packages/maven/com/example/my-app/#{version}/%2e%2e%2f.ssh%2fauthorized_keys"), params: params, headers: headers_with_token + + expect(response).to have_gitlab_http_status(:bad_request) + end + + context 'without workhorse header' do + let(:workhorse_header) { {} } + + subject { upload_file_with_token(params) } + + it_behaves_like 'package workhorse uploads' + end + + context 'event tracking' do + subject { upload_file_with_token(params) } + + it_behaves_like 'a gitlab tracking event', described_class.name, 'push_package' + end + + it 'creates package and stores package file' do + expect { upload_file_with_token(params) }.to change { project.packages.count }.by(1) + .and change { Packages::Maven::Metadatum.count }.by(1) + .and change { Packages::PackageFile.count }.by(1) + + expect(response).to have_gitlab_http_status(:ok) + expect(jar_file.file_name).to eq(file_upload.original_filename) + end + + it 'allows upload with job token' do + upload_file(params.merge(job_token: job.token)) + + expect(response).to have_gitlab_http_status(:ok) + expect(project.reload.packages.last.build_info.pipeline).to eq job.pipeline + end + + it 'allows upload with deploy token' do + upload_file(params, headers_with_deploy_token) + + expect(response).to have_gitlab_http_status(:ok) + end + + context 'version is not correct' do + let(:version) { '$%123' } + + it 'rejects request' do + expect { upload_file_with_token(params) }.not_to change { project.packages.count } + + expect(response).to have_gitlab_http_status(:bad_request) + expect(json_response['message']).to include('Validation failed') + end + end + end + + def upload_file(params = {}, request_headers = headers) + url = "/projects/#{project.id}/packages/maven/com/example/my-app/#{version}/my-app-1.0-20180724.124855-1.jar" + workhorse_finalize( + api(url), + method: :put, + file_key: :file, + params: params, + headers: request_headers, + send_rewritten_field: send_rewritten_field + ) + end + + def upload_file_with_token(params = {}, request_headers = headers_with_token) + upload_file(params, request_headers) + end + end +end diff --git a/spec/requests/api/npm_packages_spec.rb b/spec/requests/api/npm_packages_spec.rb new file mode 100644 index 00000000000..98a1ca978a8 --- /dev/null +++ b/spec/requests/api/npm_packages_spec.rb @@ -0,0 +1,550 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe API::NpmPackages do + include PackagesManagerApiSpecHelpers + + let_it_be(:user) { create(:user) } + let_it_be(:group) { create(:group) } + let_it_be(:project, reload: true) { create(:project, :public, namespace: group) } + let_it_be(:package, reload: true) { create(:npm_package, project: project) } + let_it_be(:token) { create(:oauth_access_token, scopes: 'api', resource_owner: user) } + let_it_be(:personal_access_token) { create(:personal_access_token, user: user) } + let_it_be(:job) { create(:ci_build, user: user) } + let_it_be(:deploy_token) { create(:deploy_token, read_package_registry: true, write_package_registry: true) } + let_it_be(:project_deploy_token) { create(:project_deploy_token, deploy_token: deploy_token, project: project) } + + before do + project.add_developer(user) + end + + shared_examples 'a package that requires auth' do + it 'returns the package info with oauth token' do + get_package_with_token(package) + + expect_a_valid_package_response + end + + it 'returns the package info with job token' do + get_package_with_job_token(package) + + expect_a_valid_package_response + end + + it 'denies request without oauth token' do + get_package(package) + + expect(response).to have_gitlab_http_status(:forbidden) + end + + it 'returns the package info with deploy token' do + get_package_with_deploy_token(package) + + expect_a_valid_package_response + end + end + + describe 'GET /api/v4/packages/npm/*package_name' do + let_it_be(:package_dependency_link1) { create(:packages_dependency_link, package: package, dependency_type: :dependencies) } + let_it_be(:package_dependency_link2) { create(:packages_dependency_link, package: package, dependency_type: :devDependencies) } + let_it_be(:package_dependency_link3) { create(:packages_dependency_link, package: package, dependency_type: :bundleDependencies) } + let_it_be(:package_dependency_link4) { create(:packages_dependency_link, package: package, dependency_type: :peerDependencies) } + + shared_examples 'returning the npm package info' do + it 'returns the package info' do + get_package(package) + + expect_a_valid_package_response + end + end + + shared_examples 'returning forbidden for unknown package' do + context 'with an unknown package' do + it 'returns forbidden' do + get api("/packages/npm/unknown") + + expect(response).to have_gitlab_http_status(:forbidden) + end + end + end + + context 'a public project' do + it_behaves_like 'returning the npm package info' + + context 'with application setting enabled' do + before do + stub_application_setting(npm_package_requests_forwarding: true) + end + + it_behaves_like 'returning the npm package info' + + context 'with unknown package' do + it 'returns a redirect' do + get api("/packages/npm/unknown") + + expect(response).to have_gitlab_http_status(:found) + expect(response.headers['Location']).to eq('https://registry.npmjs.org/unknown') + end + end + end + + context 'with application setting disabled' do + before do + stub_application_setting(npm_package_requests_forwarding: false) + end + + it_behaves_like 'returning the npm package info' + + it_behaves_like 'returning forbidden for unknown package' + end + + context 'project path with a dot' do + before do + project.update!(path: 'foo.bar') + end + + it_behaves_like 'returning the npm package info' + end + end + + context 'internal project' do + before do + project.team.truncate + project.update!(visibility_level: Gitlab::VisibilityLevel::INTERNAL) + end + + it_behaves_like 'a package that requires auth' + end + + context 'private project' do + before do + project.update!(visibility_level: Gitlab::VisibilityLevel::PRIVATE) + end + + it_behaves_like 'a package that requires auth' + + it 'denies request when not enough permissions' do + project.add_guest(user) + + get_package_with_token(package) + + expect(response).to have_gitlab_http_status(:forbidden) + end + end + + def get_package(package, params = {}, headers = {}) + get api("/packages/npm/#{package.name}"), params: params, headers: headers + end + + def get_package_with_token(package, params = {}) + get_package(package, params.merge(access_token: token.token)) + end + + def get_package_with_job_token(package, params = {}) + get_package(package, params.merge(job_token: job.token)) + end + + def get_package_with_deploy_token(package, params = {}) + get_package(package, {}, build_token_auth_header(deploy_token.token)) + end + end + + describe 'GET /api/v4/projects/:id/packages/npm/*package_name/-/*file_name' do + let_it_be(:package_file) { package.package_files.first } + + shared_examples 'a package file that requires auth' do + it 'returns the file with an access token' do + get_file_with_token(package_file) + + expect(response).to have_gitlab_http_status(:ok) + expect(response.media_type).to eq('application/octet-stream') + end + + it 'returns the file with a job token' do + get_file_with_job_token(package_file) + + expect(response).to have_gitlab_http_status(:ok) + expect(response.media_type).to eq('application/octet-stream') + end + + it 'denies download with no token' do + get_file(package_file) + + expect(response).to have_gitlab_http_status(:not_found) + end + end + + context 'a public project' do + subject { get_file(package_file) } + + it 'returns the file with no token needed' do + subject + + expect(response).to have_gitlab_http_status(:ok) + expect(response.media_type).to eq('application/octet-stream') + end + + it_behaves_like 'a gitlab tracking event', described_class.name, 'pull_package' + end + + context 'private project' do + before do + project.update!(visibility_level: Gitlab::VisibilityLevel::PRIVATE) + end + + it_behaves_like 'a package file that requires auth' + + it 'denies download when not enough permissions' do + project.add_guest(user) + + get_file_with_token(package_file) + + expect(response).to have_gitlab_http_status(:forbidden) + end + end + + context 'internal project' do + before do + project.update!(visibility_level: Gitlab::VisibilityLevel::INTERNAL) + end + + it_behaves_like 'a package file that requires auth' + end + + def get_file(package_file, params = {}) + get api("/projects/#{project.id}/packages/npm/" \ + "#{package_file.package.name}/-/#{package_file.file_name}"), params: params + end + + def get_file_with_token(package_file, params = {}) + get_file(package_file, params.merge(access_token: token.token)) + end + + def get_file_with_job_token(package_file, params = {}) + get_file(package_file, params.merge(job_token: job.token)) + end + end + + describe 'PUT /api/v4/projects/:id/packages/npm/:package_name' do + RSpec.shared_examples 'handling invalid record with 400 error' do + it 'handles an ActiveRecord::RecordInvalid exception with 400 error' do + expect { upload_package_with_token(package_name, params) } + .not_to change { project.packages.count } + + expect(response).to have_gitlab_http_status(:bad_request) + end + end + + context 'when params are correct' do + context 'invalid package record' do + context 'unscoped package' do + let(:package_name) { 'my_unscoped_package' } + let(:params) { upload_params(package_name: package_name) } + + it_behaves_like 'handling invalid record with 400 error' + + context 'with empty versions' do + let(:params) { upload_params(package_name: package_name).merge!(versions: {}) } + + it 'throws a 400 error' do + expect { upload_package_with_token(package_name, params) } + .not_to change { project.packages.count } + + expect(response).to have_gitlab_http_status(:bad_request) + end + end + end + + context 'invalid package name' do + let(:package_name) { "@#{group.path}/my_inv@@lid_package_name" } + let(:params) { upload_params(package_name: package_name) } + + it_behaves_like 'handling invalid record with 400 error' + end + + context 'invalid package version' do + using RSpec::Parameterized::TableSyntax + + let(:package_name) { "@#{group.path}/my_package_name" } + + where(:version) do + [ + '1', + '1.2', + '1./2.3', + '../../../../../1.2.3', + '%2e%2e%2f1.2.3' + ] + end + + with_them do + let(:params) { upload_params(package_name: package_name, package_version: version) } + + it_behaves_like 'handling invalid record with 400 error' + end + end + end + + context 'scoped package' do + let(:package_name) { "@#{group.path}/my_package_name" } + let(:params) { upload_params(package_name: package_name) } + + context 'with access token' do + subject { upload_package_with_token(package_name, params) } + + it_behaves_like 'a gitlab tracking event', described_class.name, 'push_package' + + it 'creates npm package with file' do + expect { subject } + .to change { project.packages.count }.by(1) + .and change { Packages::PackageFile.count }.by(1) + .and change { Packages::Tag.count }.by(1) + + expect(response).to have_gitlab_http_status(:ok) + end + end + + it 'creates npm package with file with job token' do + expect { upload_package_with_job_token(package_name, params) } + .to change { project.packages.count }.by(1) + .and change { Packages::PackageFile.count }.by(1) + + expect(response).to have_gitlab_http_status(:ok) + end + + context 'with an authenticated job token' do + let!(:job) { create(:ci_build, user: user) } + + before do + Grape::Endpoint.before_each do |endpoint| + expect(endpoint).to receive(:current_authenticated_job) { job } + end + end + + after do + Grape::Endpoint.before_each nil + end + + it 'creates the package metadata' do + upload_package_with_token(package_name, params) + + expect(response).to have_gitlab_http_status(:ok) + expect(project.reload.packages.find(json_response['id']).build_info.pipeline).to eq job.pipeline + end + end + end + + context 'package creation fails' do + let(:package_name) { "@#{group.path}/my_package_name" } + let(:params) { upload_params(package_name: package_name) } + + it 'returns an error if the package already exists' do + create(:npm_package, project: project, version: '1.0.1', name: "@#{group.path}/my_package_name") + expect { upload_package_with_token(package_name, params) } + .not_to change { project.packages.count } + + expect(response).to have_gitlab_http_status(:forbidden) + end + end + + context 'with dependencies' do + let(:package_name) { "@#{group.path}/my_package_name" } + let(:params) { upload_params(package_name: package_name, file: 'npm/payload_with_duplicated_packages.json') } + + it 'creates npm package with file and dependencies' do + expect { upload_package_with_token(package_name, params) } + .to change { project.packages.count }.by(1) + .and change { Packages::PackageFile.count }.by(1) + .and change { Packages::Dependency.count}.by(4) + .and change { Packages::DependencyLink.count}.by(6) + + expect(response).to have_gitlab_http_status(:ok) + end + + context 'with existing dependencies' do + before do + name = "@#{group.path}/existing_package" + upload_package_with_token(name, upload_params(package_name: name, file: 'npm/payload_with_duplicated_packages.json')) + end + + it 'reuses them' do + expect { upload_package_with_token(package_name, params) } + .to change { project.packages.count }.by(1) + .and change { Packages::PackageFile.count }.by(1) + .and not_change { Packages::Dependency.count} + .and change { Packages::DependencyLink.count}.by(6) + end + end + end + end + + def upload_package(package_name, params = {}) + put api("/projects/#{project.id}/packages/npm/#{package_name.sub('/', '%2f')}"), params: params + end + + def upload_package_with_token(package_name, params = {}) + upload_package(package_name, params.merge(access_token: token.token)) + end + + def upload_package_with_job_token(package_name, params = {}) + upload_package(package_name, params.merge(job_token: job.token)) + end + + def upload_params(package_name:, package_version: '1.0.1', file: 'npm/payload.json') + Gitlab::Json.parse(fixture_file("packages/#{file}") + .gsub('@root/npm-test', package_name) + .gsub('1.0.1', package_version)) + end + end + + describe 'GET /api/v4/packages/npm/-/package/*package_name/dist-tags' do + let_it_be(:package_tag1) { create(:packages_tag, package: package) } + let_it_be(:package_tag2) { create(:packages_tag, package: package) } + + let(:package_name) { package.name } + let(:url) { "/packages/npm/-/package/#{package_name}/dist-tags" } + + subject { get api(url) } + + context 'without the need for a license' do + context 'with public project' do + context 'with authenticated user' do + subject { get api(url, personal_access_token: personal_access_token) } + + it_behaves_like 'returns package tags', :maintainer + it_behaves_like 'returns package tags', :developer + it_behaves_like 'returns package tags', :reporter + it_behaves_like 'returns package tags', :guest + end + + context 'with unauthenticated user' do + it_behaves_like 'returns package tags', :no_type + end + end + + context 'with private project' do + before do + project.update!(visibility_level: Gitlab::VisibilityLevel::PRIVATE) + end + + context 'with authenticated user' do + subject { get api(url, personal_access_token: personal_access_token) } + + it_behaves_like 'returns package tags', :maintainer + it_behaves_like 'returns package tags', :developer + it_behaves_like 'returns package tags', :reporter + it_behaves_like 'rejects package tags access', :guest, :forbidden + end + + context 'with unauthenticated user' do + it_behaves_like 'rejects package tags access', :no_type, :forbidden + end + end + end + end + + describe 'PUT /api/v4/packages/npm/-/package/*package_name/dist-tags/:tag' do + let_it_be(:tag_name) { 'test' } + + let(:package_name) { package.name } + let(:version) { package.version } + let(:url) { "/packages/npm/-/package/#{package_name}/dist-tags/#{tag_name}" } + + subject { put api(url), env: { 'api.request.body': version } } + + context 'without the need for a license' do + context 'with public project' do + context 'with authenticated user' do + subject { put api(url, personal_access_token: personal_access_token), env: { 'api.request.body': version } } + + it_behaves_like 'create package tag', :maintainer + it_behaves_like 'create package tag', :developer + it_behaves_like 'rejects package tags access', :reporter, :forbidden + it_behaves_like 'rejects package tags access', :guest, :forbidden + end + + context 'with unauthenticated user' do + it_behaves_like 'rejects package tags access', :no_type, :unauthorized + end + end + + context 'with private project' do + before do + project.update!(visibility_level: Gitlab::VisibilityLevel::PRIVATE) + end + + context 'with authenticated user' do + subject { put api(url, personal_access_token: personal_access_token), env: { 'api.request.body': version } } + + it_behaves_like 'create package tag', :maintainer + it_behaves_like 'create package tag', :developer + it_behaves_like 'rejects package tags access', :reporter, :forbidden + it_behaves_like 'rejects package tags access', :guest, :forbidden + end + + context 'with unauthenticated user' do + it_behaves_like 'rejects package tags access', :no_type, :unauthorized + end + end + end + end + + describe 'DELETE /api/v4/packages/npm/-/package/*package_name/dist-tags/:tag' do + let_it_be(:package_tag) { create(:packages_tag, package: package) } + + let(:package_name) { package.name } + let(:tag_name) { package_tag.name } + let(:url) { "/packages/npm/-/package/#{package_name}/dist-tags/#{tag_name}" } + + subject { delete api(url) } + + context 'without the need for a license' do + context 'with public project' do + context 'with authenticated user' do + subject { delete api(url, personal_access_token: personal_access_token) } + + it_behaves_like 'delete package tag', :maintainer + it_behaves_like 'rejects package tags access', :developer, :forbidden + it_behaves_like 'rejects package tags access', :reporter, :forbidden + it_behaves_like 'rejects package tags access', :guest, :forbidden + end + + context 'with unauthenticated user' do + it_behaves_like 'rejects package tags access', :no_type, :unauthorized + end + end + + context 'with private project' do + before do + project.update!(visibility_level: Gitlab::VisibilityLevel::PRIVATE) + end + + context 'with authenticated user' do + subject { delete api(url, personal_access_token: personal_access_token) } + + it_behaves_like 'delete package tag', :maintainer + it_behaves_like 'rejects package tags access', :developer, :forbidden + it_behaves_like 'rejects package tags access', :reporter, :forbidden + it_behaves_like 'rejects package tags access', :guest, :forbidden + end + + context 'with unauthenticated user' do + it_behaves_like 'rejects package tags access', :no_type, :unauthorized + end + end + end + end + + def expect_a_valid_package_response + expect(response).to have_gitlab_http_status(:ok) + expect(response.media_type).to eq('application/json') + expect(response).to match_response_schema('public_api/v4/packages/npm_package') + expect(json_response['name']).to eq(package.name) + expect(json_response['versions'][package.version]).to match_schema('public_api/v4/packages/npm_package_version') + ::Packages::Npm::PackagePresenter::NPM_VALID_DEPENDENCY_TYPES.each do |dependency_type| + expect(json_response.dig('versions', package.version, dependency_type.to_s)).to be_any + end + expect(json_response['dist-tags']).to match_schema('public_api/v4/packages/npm_package_tags') + end +end diff --git a/spec/requests/api/nuget_packages_spec.rb b/spec/requests/api/nuget_packages_spec.rb new file mode 100644 index 00000000000..43aa65d1f76 --- /dev/null +++ b/spec/requests/api/nuget_packages_spec.rb @@ -0,0 +1,482 @@ +# frozen_string_literal: true +require 'spec_helper' + +RSpec.describe API::NugetPackages do + include WorkhorseHelpers + include PackagesManagerApiSpecHelpers + + let_it_be(:user) { create(:user) } + let_it_be(:project, reload: true) { create(:project, :public) } + let_it_be(:personal_access_token) { create(:personal_access_token, user: user) } + let_it_be(:deploy_token) { create(:deploy_token, read_package_registry: true, write_package_registry: true) } + let_it_be(:project_deploy_token) { create(:project_deploy_token, deploy_token: deploy_token, project: project) } + + describe 'GET /api/v4/projects/:id/packages/nuget' do + let(:url) { "/projects/#{project.id}/packages/nuget/index.json" } + + subject { get api(url) } + + context 'without the need for a license' do + context 'with valid project' do + using RSpec::Parameterized::TableSyntax + + where(:project_visibility_level, :user_role, :member, :user_token, :shared_examples_name, :expected_status) do + 'PUBLIC' | :developer | true | true | 'process nuget service index request' | :success + 'PUBLIC' | :guest | true | true | 'process nuget service index request' | :success + 'PUBLIC' | :developer | true | false | 'process nuget service index request' | :success + 'PUBLIC' | :guest | true | false | 'process nuget service index request' | :success + 'PUBLIC' | :developer | false | true | 'process nuget service index request' | :success + 'PUBLIC' | :guest | false | true | 'process nuget service index request' | :success + 'PUBLIC' | :developer | false | false | 'process nuget service index request' | :success + 'PUBLIC' | :guest | false | false | 'process nuget service index request' | :success + 'PUBLIC' | :anonymous | false | true | 'process nuget service index request' | :success + 'PRIVATE' | :developer | true | true | 'process nuget service index request' | :success + 'PRIVATE' | :guest | true | true | 'rejects nuget packages access' | :forbidden + 'PRIVATE' | :developer | true | false | 'rejects nuget packages access' | :unauthorized + 'PRIVATE' | :guest | true | false | 'rejects nuget packages access' | :unauthorized + 'PRIVATE' | :developer | false | true | 'rejects nuget packages access' | :not_found + 'PRIVATE' | :guest | false | true | 'rejects nuget packages access' | :not_found + 'PRIVATE' | :developer | false | false | 'rejects nuget packages access' | :unauthorized + 'PRIVATE' | :guest | false | false | 'rejects nuget packages access' | :unauthorized + 'PRIVATE' | :anonymous | false | true | 'rejects nuget packages access' | :unauthorized + end + + with_them do + let(:token) { user_token ? personal_access_token.token : 'wrong' } + let(:headers) { user_role == :anonymous ? {} : build_basic_auth_header(user.username, token) } + + subject { get api(url), headers: headers } + + before do + project.update!(visibility_level: Gitlab::VisibilityLevel.const_get(project_visibility_level, false)) + end + + it_behaves_like params[:shared_examples_name], params[:user_role], params[:expected_status], params[:member] + end + end + + it_behaves_like 'deploy token for package GET requests' + + it_behaves_like 'rejects nuget access with unknown project id' + + it_behaves_like 'rejects nuget access with invalid project id' + end + end + + describe 'PUT /api/v4/projects/:id/packages/nuget/authorize' do + let_it_be(:workhorse_token) { JWT.encode({ 'iss' => 'gitlab-workhorse' }, Gitlab::Workhorse.secret, 'HS256') } + let_it_be(:workhorse_header) { { 'GitLab-Workhorse' => '1.0', Gitlab::Workhorse::INTERNAL_API_REQUEST_HEADER => workhorse_token } } + let(:url) { "/projects/#{project.id}/packages/nuget/authorize" } + let(:headers) { {} } + + subject { put api(url), headers: headers } + + context 'without the need for a license' do + context 'with valid project' do + using RSpec::Parameterized::TableSyntax + + where(:project_visibility_level, :user_role, :member, :user_token, :shared_examples_name, :expected_status) do + 'PUBLIC' | :developer | true | true | 'process nuget workhorse authorization' | :success + 'PUBLIC' | :guest | true | true | 'rejects nuget packages access' | :forbidden + 'PUBLIC' | :developer | true | false | 'rejects nuget packages access' | :unauthorized + 'PUBLIC' | :guest | true | false | 'rejects nuget packages access' | :unauthorized + 'PUBLIC' | :developer | false | true | 'rejects nuget packages access' | :forbidden + 'PUBLIC' | :guest | false | true | 'rejects nuget packages access' | :forbidden + 'PUBLIC' | :developer | false | false | 'rejects nuget packages access' | :unauthorized + 'PUBLIC' | :guest | false | false | 'rejects nuget packages access' | :unauthorized + 'PUBLIC' | :anonymous | false | true | 'rejects nuget packages access' | :unauthorized + 'PRIVATE' | :developer | true | true | 'process nuget workhorse authorization' | :success + 'PRIVATE' | :guest | true | true | 'rejects nuget packages access' | :forbidden + 'PRIVATE' | :developer | true | false | 'rejects nuget packages access' | :unauthorized + 'PRIVATE' | :guest | true | false | 'rejects nuget packages access' | :unauthorized + 'PRIVATE' | :developer | false | true | 'rejects nuget packages access' | :not_found + 'PRIVATE' | :guest | false | true | 'rejects nuget packages access' | :not_found + 'PRIVATE' | :developer | false | false | 'rejects nuget packages access' | :unauthorized + 'PRIVATE' | :guest | false | false | 'rejects nuget packages access' | :unauthorized + 'PRIVATE' | :anonymous | false | true | 'rejects nuget packages access' | :unauthorized + end + + with_them do + let(:token) { user_token ? personal_access_token.token : 'wrong' } + let(:user_headers) { user_role == :anonymous ? {} : build_basic_auth_header(user.username, token) } + let(:headers) { user_headers.merge(workhorse_header) } + + before do + project.update!(visibility_level: Gitlab::VisibilityLevel.const_get(project_visibility_level, false)) + end + + it_behaves_like params[:shared_examples_name], params[:user_role], params[:expected_status], params[:member] + end + end + + it_behaves_like 'deploy token for package uploads' + + it_behaves_like 'rejects nuget access with unknown project id' + + it_behaves_like 'rejects nuget access with invalid project id' + end + end + + describe 'PUT /api/v4/projects/:id/packages/nuget' do + let(:workhorse_token) { JWT.encode({ 'iss' => 'gitlab-workhorse' }, Gitlab::Workhorse.secret, 'HS256') } + let(:workhorse_header) { { 'GitLab-Workhorse' => '1.0', Gitlab::Workhorse::INTERNAL_API_REQUEST_HEADER => workhorse_token } } + let_it_be(:file_name) { 'package.nupkg' } + let(:url) { "/projects/#{project.id}/packages/nuget" } + let(:headers) { {} } + let(:params) { { package: temp_file(file_name) } } + let(:file_key) { :package } + let(:send_rewritten_field) { true } + + subject do + workhorse_finalize( + api(url), + method: :put, + file_key: file_key, + params: params, + headers: headers, + send_rewritten_field: send_rewritten_field + ) + end + + context 'without the need for a license' do + context 'with valid project' do + using RSpec::Parameterized::TableSyntax + + where(:project_visibility_level, :user_role, :member, :user_token, :shared_examples_name, :expected_status) do + 'PUBLIC' | :developer | true | true | 'process nuget upload' | :created + 'PUBLIC' | :guest | true | true | 'rejects nuget packages access' | :forbidden + 'PUBLIC' | :developer | true | false | 'rejects nuget packages access' | :unauthorized + 'PUBLIC' | :guest | true | false | 'rejects nuget packages access' | :unauthorized + 'PUBLIC' | :developer | false | true | 'rejects nuget packages access' | :forbidden + 'PUBLIC' | :guest | false | true | 'rejects nuget packages access' | :forbidden + 'PUBLIC' | :developer | false | false | 'rejects nuget packages access' | :unauthorized + 'PUBLIC' | :guest | false | false | 'rejects nuget packages access' | :unauthorized + 'PUBLIC' | :anonymous | false | true | 'rejects nuget packages access' | :unauthorized + 'PRIVATE' | :developer | true | true | 'process nuget upload' | :created + 'PRIVATE' | :guest | true | true | 'rejects nuget packages access' | :forbidden + 'PRIVATE' | :developer | true | false | 'rejects nuget packages access' | :unauthorized + 'PRIVATE' | :guest | true | false | 'rejects nuget packages access' | :unauthorized + 'PRIVATE' | :developer | false | true | 'rejects nuget packages access' | :not_found + 'PRIVATE' | :guest | false | true | 'rejects nuget packages access' | :not_found + 'PRIVATE' | :developer | false | false | 'rejects nuget packages access' | :unauthorized + 'PRIVATE' | :guest | false | false | 'rejects nuget packages access' | :unauthorized + 'PRIVATE' | :anonymous | false | true | 'rejects nuget packages access' | :unauthorized + end + + with_them do + let(:token) { user_token ? personal_access_token.token : 'wrong' } + let(:user_headers) { user_role == :anonymous ? {} : build_basic_auth_header(user.username, token) } + let(:headers) { user_headers.merge(workhorse_header) } + + before do + project.update!(visibility_level: Gitlab::VisibilityLevel.const_get(project_visibility_level, false)) + end + + it_behaves_like params[:shared_examples_name], params[:user_role], params[:expected_status], params[:member] + end + end + + it_behaves_like 'deploy token for package uploads' + + it_behaves_like 'rejects nuget access with unknown project id' + + it_behaves_like 'rejects nuget access with invalid project id' + end + end + + describe 'GET /api/v4/projects/:id/packages/nuget/metadata/*package_name/index' do + include_context 'with expected presenters dependency groups' + + let_it_be(:package_name) { 'Dummy.Package' } + let_it_be(:packages) { create_list(:nuget_package, 5, :with_metadatum, name: package_name, project: project) } + let_it_be(:tags) { packages.each { |pkg| create(:packages_tag, package: pkg, name: 'test') } } + let(:url) { "/projects/#{project.id}/packages/nuget/metadata/#{package_name}/index.json" } + + subject { get api(url) } + + before do + packages.each { |pkg| create_dependencies_for(pkg) } + end + + context 'without the need for license' do + context 'with valid project' do + using RSpec::Parameterized::TableSyntax + + where(:project_visibility_level, :user_role, :member, :user_token, :shared_examples_name, :expected_status) do + 'PUBLIC' | :developer | true | true | 'process nuget metadata request at package name level' | :success + 'PUBLIC' | :guest | true | true | 'process nuget metadata request at package name level' | :success + 'PUBLIC' | :developer | true | false | 'process nuget metadata request at package name level' | :success + 'PUBLIC' | :guest | true | false | 'process nuget metadata request at package name level' | :success + 'PUBLIC' | :developer | false | true | 'process nuget metadata request at package name level' | :success + 'PUBLIC' | :guest | false | true | 'process nuget metadata request at package name level' | :success + 'PUBLIC' | :developer | false | false | 'process nuget metadata request at package name level' | :success + 'PUBLIC' | :guest | false | false | 'process nuget metadata request at package name level' | :success + 'PUBLIC' | :anonymous | false | true | 'process nuget metadata request at package name level' | :success + 'PRIVATE' | :developer | true | true | 'process nuget metadata request at package name level' | :success + 'PRIVATE' | :guest | true | true | 'rejects nuget packages access' | :forbidden + 'PRIVATE' | :developer | true | false | 'rejects nuget packages access' | :unauthorized + 'PRIVATE' | :guest | true | false | 'rejects nuget packages access' | :unauthorized + 'PRIVATE' | :developer | false | true | 'rejects nuget packages access' | :not_found + 'PRIVATE' | :guest | false | true | 'rejects nuget packages access' | :not_found + 'PRIVATE' | :developer | false | false | 'rejects nuget packages access' | :unauthorized + 'PRIVATE' | :guest | false | false | 'rejects nuget packages access' | :unauthorized + 'PRIVATE' | :anonymous | false | true | 'rejects nuget packages access' | :unauthorized + end + + with_them do + let(:token) { user_token ? personal_access_token.token : 'wrong' } + let(:headers) { user_role == :anonymous ? {} : build_basic_auth_header(user.username, token) } + + subject { get api(url), headers: headers } + + before do + project.update!(visibility_level: Gitlab::VisibilityLevel.const_get(project_visibility_level, false)) + end + + it_behaves_like params[:shared_examples_name], params[:user_role], params[:expected_status], params[:member] + end + + it_behaves_like 'deploy token for package GET requests' + + it_behaves_like 'rejects nuget access with unknown project id' + + it_behaves_like 'rejects nuget access with invalid project id' + end + end + end + + describe 'GET /api/v4/projects/:id/packages/nuget/metadata/*package_name/*package_version' do + include_context 'with expected presenters dependency groups' + + let_it_be(:package_name) { 'Dummy.Package' } + let_it_be(:package) { create(:nuget_package, :with_metadatum, name: 'Dummy.Package', project: project) } + let_it_be(:tag) { create(:packages_tag, package: package, name: 'test') } + let(:url) { "/projects/#{project.id}/packages/nuget/metadata/#{package_name}/#{package.version}.json" } + + subject { get api(url) } + + before do + create_dependencies_for(package) + end + + context 'without the need for a license' do + context 'with valid project' do + using RSpec::Parameterized::TableSyntax + + where(:project_visibility_level, :user_role, :member, :user_token, :shared_examples_name, :expected_status) do + 'PUBLIC' | :developer | true | true | 'process nuget metadata request at package name and package version level' | :success + 'PUBLIC' | :guest | true | true | 'process nuget metadata request at package name and package version level' | :success + 'PUBLIC' | :developer | true | false | 'process nuget metadata request at package name and package version level' | :success + 'PUBLIC' | :guest | true | false | 'process nuget metadata request at package name and package version level' | :success + 'PUBLIC' | :developer | false | true | 'process nuget metadata request at package name and package version level' | :success + 'PUBLIC' | :guest | false | true | 'process nuget metadata request at package name and package version level' | :success + 'PUBLIC' | :developer | false | false | 'process nuget metadata request at package name and package version level' | :success + 'PUBLIC' | :guest | false | false | 'process nuget metadata request at package name and package version level' | :success + 'PUBLIC' | :anonymous | false | true | 'process nuget metadata request at package name and package version level' | :success + 'PRIVATE' | :developer | true | true | 'process nuget metadata request at package name and package version level' | :success + 'PRIVATE' | :guest | true | true | 'rejects nuget packages access' | :forbidden + 'PRIVATE' | :developer | true | false | 'rejects nuget packages access' | :unauthorized + 'PRIVATE' | :guest | true | false | 'rejects nuget packages access' | :unauthorized + 'PRIVATE' | :developer | false | true | 'rejects nuget packages access' | :not_found + 'PRIVATE' | :guest | false | true | 'rejects nuget packages access' | :not_found + 'PRIVATE' | :developer | false | false | 'rejects nuget packages access' | :unauthorized + 'PRIVATE' | :guest | false | false | 'rejects nuget packages access' | :unauthorized + 'PRIVATE' | :anonymous | false | true | 'rejects nuget packages access' | :unauthorized + end + + with_them do + let(:token) { user_token ? personal_access_token.token : 'wrong' } + let(:headers) { user_role == :anonymous ? {} : build_basic_auth_header(user.username, token) } + + subject { get api(url), headers: headers } + + before do + project.update!(visibility_level: Gitlab::VisibilityLevel.const_get(project_visibility_level, false)) + end + + it_behaves_like params[:shared_examples_name], params[:user_role], params[:expected_status], params[:member] + end + end + + it_behaves_like 'deploy token for package GET requests' + + context 'with invalid package name' do + let_it_be(:package_name) { 'Unkown' } + + it_behaves_like 'rejects nuget packages access', :developer, :not_found + end + end + end + + describe 'GET /api/v4/projects/:id/packages/nuget/download/*package_name/index' do + let_it_be(:package_name) { 'Dummy.Package' } + let_it_be(:packages) { create_list(:nuget_package, 5, name: package_name, project: project) } + let(:url) { "/projects/#{project.id}/packages/nuget/download/#{package_name}/index.json" } + + subject { get api(url) } + + context 'without the need for a license' do + context 'with valid project' do + using RSpec::Parameterized::TableSyntax + + where(:project_visibility_level, :user_role, :member, :user_token, :shared_examples_name, :expected_status) do + 'PUBLIC' | :developer | true | true | 'process nuget download versions request' | :success + 'PUBLIC' | :guest | true | true | 'process nuget download versions request' | :success + 'PUBLIC' | :developer | true | false | 'process nuget download versions request' | :success + 'PUBLIC' | :guest | true | false | 'process nuget download versions request' | :success + 'PUBLIC' | :developer | false | true | 'process nuget download versions request' | :success + 'PUBLIC' | :guest | false | true | 'process nuget download versions request' | :success + 'PUBLIC' | :developer | false | false | 'process nuget download versions request' | :success + 'PUBLIC' | :guest | false | false | 'process nuget download versions request' | :success + 'PUBLIC' | :anonymous | false | true | 'process nuget download versions request' | :success + 'PRIVATE' | :developer | true | true | 'process nuget download versions request' | :success + 'PRIVATE' | :guest | true | true | 'rejects nuget packages access' | :forbidden + 'PRIVATE' | :developer | true | false | 'rejects nuget packages access' | :unauthorized + 'PRIVATE' | :guest | true | false | 'rejects nuget packages access' | :unauthorized + 'PRIVATE' | :developer | false | true | 'rejects nuget packages access' | :not_found + 'PRIVATE' | :guest | false | true | 'rejects nuget packages access' | :not_found + 'PRIVATE' | :developer | false | false | 'rejects nuget packages access' | :unauthorized + 'PRIVATE' | :guest | false | false | 'rejects nuget packages access' | :unauthorized + 'PRIVATE' | :anonymous | false | true | 'rejects nuget packages access' | :unauthorized + end + + with_them do + let(:token) { user_token ? personal_access_token.token : 'wrong' } + let(:headers) { user_role == :anonymous ? {} : build_basic_auth_header(user.username, token) } + + subject { get api(url), headers: headers } + + before do + project.update!(visibility_level: Gitlab::VisibilityLevel.const_get(project_visibility_level, false)) + end + + it_behaves_like params[:shared_examples_name], params[:user_role], params[:expected_status], params[:member] + end + end + + it_behaves_like 'deploy token for package GET requests' + + it_behaves_like 'rejects nuget access with unknown project id' + + it_behaves_like 'rejects nuget access with invalid project id' + end + end + + describe 'GET /api/v4/projects/:id/packages/nuget/download/*package_name/*package_version/*package_filename' do + let_it_be(:package_name) { 'Dummy.Package' } + let_it_be(:package) { create(:nuget_package, project: project, name: package_name) } + + let(:url) { "/projects/#{project.id}/packages/nuget/download/#{package.name}/#{package.version}/#{package.name}.#{package.version}.nupkg" } + + subject { get api(url) } + + context 'without the need for a license' do + context 'with valid project' do + using RSpec::Parameterized::TableSyntax + + where(:project_visibility_level, :user_role, :member, :user_token, :shared_examples_name, :expected_status) do + 'PUBLIC' | :developer | true | true | 'process nuget download content request' | :success + 'PUBLIC' | :guest | true | true | 'process nuget download content request' | :success + 'PUBLIC' | :developer | true | false | 'process nuget download content request' | :success + 'PUBLIC' | :guest | true | false | 'process nuget download content request' | :success + 'PUBLIC' | :developer | false | true | 'process nuget download content request' | :success + 'PUBLIC' | :guest | false | true | 'process nuget download content request' | :success + 'PUBLIC' | :developer | false | false | 'process nuget download content request' | :success + 'PUBLIC' | :guest | false | false | 'process nuget download content request' | :success + 'PUBLIC' | :anonymous | false | true | 'process nuget download content request' | :success + 'PRIVATE' | :developer | true | true | 'process nuget download content request' | :success + 'PRIVATE' | :guest | true | true | 'rejects nuget packages access' | :forbidden + 'PRIVATE' | :developer | true | false | 'rejects nuget packages access' | :unauthorized + 'PRIVATE' | :guest | true | false | 'rejects nuget packages access' | :unauthorized + 'PRIVATE' | :developer | false | true | 'rejects nuget packages access' | :not_found + 'PRIVATE' | :guest | false | true | 'rejects nuget packages access' | :not_found + 'PRIVATE' | :developer | false | false | 'rejects nuget packages access' | :unauthorized + 'PRIVATE' | :guest | false | false | 'rejects nuget packages access' | :unauthorized + 'PRIVATE' | :anonymous | false | true | 'rejects nuget packages access' | :unauthorized + end + + with_them do + let(:token) { user_token ? personal_access_token.token : 'wrong' } + let(:headers) { user_role == :anonymous ? {} : build_basic_auth_header(user.username, token) } + + subject { get api(url), headers: headers } + + before do + project.update!(visibility_level: Gitlab::VisibilityLevel.const_get(project_visibility_level, false)) + end + + it_behaves_like params[:shared_examples_name], params[:user_role], params[:expected_status], params[:member] + end + end + + it_behaves_like 'deploy token for package GET requests' + + it_behaves_like 'rejects nuget access with unknown project id' + + it_behaves_like 'rejects nuget access with invalid project id' + end + end + + describe 'GET /api/v4/projects/:id/packages/nuget/query' do + let_it_be(:package_a) { create(:nuget_package, :with_metadatum, name: 'Dummy.PackageA', project: project) } + let_it_be(:tag) { create(:packages_tag, package: package_a, name: 'test') } + let_it_be(:packages_b) { create_list(:nuget_package, 5, name: 'Dummy.PackageB', project: project) } + let_it_be(:packages_c) { create_list(:nuget_package, 5, name: 'Dummy.PackageC', project: project) } + let_it_be(:package_d) { create(:nuget_package, name: 'Dummy.PackageD', version: '5.0.5-alpha', project: project) } + let_it_be(:package_e) { create(:nuget_package, name: 'Foo.BarE', project: project) } + let(:search_term) { 'uMmy' } + let(:take) { 26 } + let(:skip) { 0 } + let(:include_prereleases) { true } + let(:query_parameters) { { q: search_term, take: take, skip: skip, prerelease: include_prereleases } } + let(:url) { "/projects/#{project.id}/packages/nuget/query?#{query_parameters.to_query}" } + + subject { get api(url) } + + context 'without the need for a license' do + context 'with valid project' do + using RSpec::Parameterized::TableSyntax + + where(:project_visibility_level, :user_role, :member, :user_token, :shared_examples_name, :expected_status) do + 'PUBLIC' | :developer | true | true | 'process nuget search request' | :success + 'PUBLIC' | :guest | true | true | 'process nuget search request' | :success + 'PUBLIC' | :developer | true | false | 'process nuget search request' | :success + 'PUBLIC' | :guest | true | false | 'process nuget search request' | :success + 'PUBLIC' | :developer | false | true | 'process nuget search request' | :success + 'PUBLIC' | :guest | false | true | 'process nuget search request' | :success + 'PUBLIC' | :developer | false | false | 'process nuget search request' | :success + 'PUBLIC' | :guest | false | false | 'process nuget search request' | :success + 'PUBLIC' | :anonymous | false | true | 'process nuget search request' | :success + 'PRIVATE' | :developer | true | true | 'process nuget search request' | :success + 'PRIVATE' | :guest | true | true | 'rejects nuget packages access' | :forbidden + 'PRIVATE' | :developer | true | false | 'rejects nuget packages access' | :unauthorized + 'PRIVATE' | :guest | true | false | 'rejects nuget packages access' | :unauthorized + 'PRIVATE' | :developer | false | true | 'rejects nuget packages access' | :not_found + 'PRIVATE' | :guest | false | true | 'rejects nuget packages access' | :not_found + 'PRIVATE' | :developer | false | false | 'rejects nuget packages access' | :unauthorized + 'PRIVATE' | :guest | false | false | 'rejects nuget packages access' | :unauthorized + 'PRIVATE' | :anonymous | false | true | 'rejects nuget packages access' | :unauthorized + end + + with_them do + let(:token) { user_token ? personal_access_token.token : 'wrong' } + let(:headers) { user_role == :anonymous ? {} : build_basic_auth_header(user.username, token) } + + subject { get api(url), headers: headers } + + before do + project.update!(visibility_level: Gitlab::VisibilityLevel.const_get(project_visibility_level, false)) + end + + it_behaves_like params[:shared_examples_name], params[:user_role], params[:expected_status], params[:member] + end + end + + it_behaves_like 'deploy token for package GET requests' + + it_behaves_like 'rejects nuget access with unknown project id' + + it_behaves_like 'rejects nuget access with invalid project id' + end + end +end diff --git a/spec/requests/api/package_files_spec.rb b/spec/requests/api/package_files_spec.rb new file mode 100644 index 00000000000..11170066d6e --- /dev/null +++ b/spec/requests/api/package_files_spec.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe API::PackageFiles do + let(:user) { create(:user) } + let(:project) { create(:project, :public) } + let(:package) { create(:maven_package, project: project) } + + before do + project.add_developer(user) + end + + describe 'GET /projects/:id/packages/:package_id/package_files' do + let(:url) { "/projects/#{project.id}/packages/#{package.id}/package_files" } + + context 'without the need for a license' do + context 'project is public' do + it 'returns 200' do + get api(url) + + expect(response).to have_gitlab_http_status(:ok) + end + + it 'returns 404 if package does not exist' do + get api("/projects/#{project.id}/packages/0/package_files") + + expect(response).to have_gitlab_http_status(:not_found) + end + end + + context 'project is private' do + let(:project) { create(:project, :private) } + + it 'returns 404 for non authenticated user' do + get api(url) + + expect(response).to have_gitlab_http_status(:not_found) + end + + it 'returns 404 for a user without access to the project' do + project.team.truncate + + get api(url, user) + + expect(response).to have_gitlab_http_status(:not_found) + end + + it 'returns 200 and valid response schema' do + get api(url, user) + + expect(response).to have_gitlab_http_status(:ok) + expect(response).to match_response_schema('public_api/v4/packages/package_files') + end + end + + context 'with pagination params' do + let(:per_page) { 2 } + let!(:package_file_1) { package.package_files[0] } + let!(:package_file_2) { package.package_files[1] } + let!(:package_file_3) { package.package_files[2] } + + context 'when viewing the first page' do + it 'returns first 2 packages' do + get api(url, user), params: { page: 1, per_page: per_page } + + expect_paginated_array_response([package_file_1.id, package_file_2.id]) + end + end + + context 'viewing the second page' do + it 'returns the last package' do + get api(url, user), params: { page: 2, per_page: per_page } + + expect_paginated_array_response([package_file_3.id]) + end + end + end + end + end +end diff --git a/spec/requests/api/project_packages_spec.rb b/spec/requests/api/project_packages_spec.rb new file mode 100644 index 00000000000..0ece3bff8f9 --- /dev/null +++ b/spec/requests/api/project_packages_spec.rb @@ -0,0 +1,272 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe API::ProjectPackages do + let(:user) { create(:user) } + let_it_be(:project) { create(:project, :public) } + let!(:package1) { create(:npm_package, project: project, version: '3.1.0', name: "@#{project.root_namespace.path}/foo1") } + let(:package_url) { "/projects/#{project.id}/packages/#{package1.id}" } + let!(:package2) { create(:nuget_package, project: project, version: '2.0.4') } + let!(:another_package) { create(:npm_package) } + let(:no_package_url) { "/projects/#{project.id}/packages/0" } + let(:wrong_package_url) { "/projects/#{project.id}/packages/#{another_package.id}" } + + describe 'GET /projects/:id/packages' do + let(:url) { "/projects/#{project.id}/packages" } + let(:package_schema) { 'public_api/v4/packages/packages' } + + subject { get api(url) } + + context 'without the need for a license' do + context 'project is public' do + it_behaves_like 'returns packages', :project, :no_type + end + + context 'project is private' do + let(:project) { create(:project, :private) } + + context 'for unauthenticated user' do + it_behaves_like 'rejects packages access', :project, :no_type, :not_found + end + + context 'for authenticated user' do + subject { get api(url, user) } + + it_behaves_like 'returns packages', :project, :maintainer + it_behaves_like 'returns packages', :project, :developer + it_behaves_like 'returns packages', :project, :reporter + it_behaves_like 'rejects packages access', :project, :no_type, :not_found + it_behaves_like 'rejects packages access', :project, :guest, :forbidden + + context 'user is a maintainer' do + before do + project.add_maintainer(user) + end + + it 'returns the destroy url' do + subject + + expect(json_response.first['_links']).to include('delete_api_path') + end + end + end + end + + context 'with pagination params' do + let!(:package3) { create(:maven_package, project: project) } + let!(:package4) { create(:maven_package, project: project) } + + context 'with pagination params' do + let!(:package3) { create(:npm_package, project: project) } + let!(:package4) { create(:npm_package, project: project) } + + it_behaves_like 'returns paginated packages' + end + end + + context 'with sorting' do + let(:package3) { create(:maven_package, project: project, version: '1.1.1', name: 'zzz') } + + before do + travel_to(1.day.ago) do + package3 + end + end + + it_behaves_like 'package sorting', 'name' do + let(:packages) { [package1, package2, package3] } + end + + it_behaves_like 'package sorting', 'created_at' do + let(:packages) { [package3, package1, package2] } + end + + it_behaves_like 'package sorting', 'version' do + let(:packages) { [package3, package2, package1] } + end + + it_behaves_like 'package sorting', 'type' do + let(:packages) { [package3, package1, package2] } + end + end + + it_behaves_like 'filters on each package_type', is_project: true + + context 'filtering on package_name' do + include_context 'package filter context' + + it 'returns the named package' do + url = package_filter_url(:name, 'nuget') + get api(url, user) + + expect(json_response.length).to eq(1) + expect(json_response.first['name']).to include(package2.name) + end + end + end + end + + describe 'GET /projects/:id/packages/:package_id' do + subject { get api(package_url, user) } + + shared_examples 'no destroy url' do + it 'returns no destroy url' do + subject + + expect(json_response['_links']).not_to include('delete_api_path') + end + end + + shared_examples 'destroy url' do + it 'returns destroy url' do + subject + + expect(json_response['_links']['delete_api_path']).to be_present + end + end + + context 'without the need for a license' do + context 'project is public' do + it 'returns 200 and the package information' do + subject + + expect(response).to have_gitlab_http_status(:ok) + expect(response).to match_response_schema('public_api/v4/packages/package') + end + + it 'returns 404 when the package does not exist' do + get api(no_package_url, user) + + expect(response).to have_gitlab_http_status(:not_found) + end + + it 'returns 404 for the package from a different project' do + get api(wrong_package_url, user) + + expect(response).to have_gitlab_http_status(:not_found) + end + + it_behaves_like 'no destroy url' + end + + context 'project is private' do + let(:project) { create(:project, :private) } + + it 'returns 404 for non authenticated user' do + get api(package_url) + + expect(response).to have_gitlab_http_status(:not_found) + end + + it 'returns 404 for a user without access to the project' do + subject + + expect(response).to have_gitlab_http_status(:not_found) + end + + context 'user is a developer' do + before do + project.add_developer(user) + end + + it 'returns 200 and the package information' do + subject + + expect(response).to have_gitlab_http_status(:ok) + expect(response).to match_response_schema('public_api/v4/packages/package') + end + + it_behaves_like 'no destroy url' + end + + context 'user is a maintainer' do + before do + project.add_maintainer(user) + end + + it_behaves_like 'destroy url' + end + + context 'with pipeline' do + let!(:package1) { create(:npm_package, :with_build, project: project) } + + it 'returns the pipeline info' do + project.add_developer(user) + + get api(package_url, user) + + expect(response).to have_gitlab_http_status(:ok) + expect(response).to match_response_schema('public_api/v4/packages/package_with_build') + end + end + end + end + end + + describe 'DELETE /projects/:id/packages/:package_id' do + context 'without the need for a license' do + context 'project is public' do + it 'returns 403 for non authenticated user' do + delete api(package_url) + + expect(response).to have_gitlab_http_status(:forbidden) + end + + it 'returns 403 for a user without access to the project' do + delete api(package_url, user) + + expect(response).to have_gitlab_http_status(:forbidden) + end + end + + context 'project is private' do + let(:project) { create(:project, :private) } + + it 'returns 404 for non authenticated user' do + delete api(package_url) + + expect(response).to have_gitlab_http_status(:not_found) + end + + it 'returns 404 for a user without access to the project' do + delete api(package_url, user) + + expect(response).to have_gitlab_http_status(:not_found) + end + + it 'returns 404 when the package does not exist' do + project.add_maintainer(user) + + delete api(no_package_url, user) + + expect(response).to have_gitlab_http_status(:not_found) + end + + it 'returns 404 for the package from a different project' do + project.add_maintainer(user) + + delete api(wrong_package_url, user) + + expect(response).to have_gitlab_http_status(:not_found) + end + + it 'returns 403 for a user without enough permissions' do + project.add_developer(user) + + delete api(package_url, user) + + expect(response).to have_gitlab_http_status(:forbidden) + end + + it 'returns 204' do + project.add_maintainer(user) + + delete api(package_url, user) + + expect(response).to have_gitlab_http_status(:no_content) + end + end + end + end +end diff --git a/spec/requests/api/pypi_packages_spec.rb b/spec/requests/api/pypi_packages_spec.rb new file mode 100644 index 00000000000..b4e83c8caab --- /dev/null +++ b/spec/requests/api/pypi_packages_spec.rb @@ -0,0 +1,259 @@ +# frozen_string_literal: true +require 'spec_helper' + +RSpec.describe API::PypiPackages do + include WorkhorseHelpers + include PackagesManagerApiSpecHelpers + + let_it_be(:user) { create(:user) } + let_it_be(:project, reload: true) { create(:project, :public) } + let_it_be(:personal_access_token) { create(:personal_access_token, user: user) } + let_it_be(:deploy_token) { create(:deploy_token, read_package_registry: true, write_package_registry: true) } + let_it_be(:project_deploy_token) { create(:project_deploy_token, deploy_token: deploy_token, project: project) } + + describe 'GET /api/v4/projects/:id/packages/pypi/simple/:package_name' do + let_it_be(:package) { create(:pypi_package, project: project) } + let(:url) { "/projects/#{project.id}/packages/pypi/simple/#{package.name}" } + + subject { get api(url) } + + context 'without the need for a license' do + context 'with valid project' do + using RSpec::Parameterized::TableSyntax + + where(:project_visibility_level, :user_role, :member, :user_token, :shared_examples_name, :expected_status) do + 'PUBLIC' | :developer | true | true | 'PyPi package versions' | :success + 'PUBLIC' | :guest | true | true | 'PyPi package versions' | :success + 'PUBLIC' | :developer | true | false | 'PyPi package versions' | :success + 'PUBLIC' | :guest | true | false | 'PyPi package versions' | :success + 'PUBLIC' | :developer | false | true | 'PyPi package versions' | :success + 'PUBLIC' | :guest | false | true | 'PyPi package versions' | :success + 'PUBLIC' | :developer | false | false | 'PyPi package versions' | :success + 'PUBLIC' | :guest | false | false | 'PyPi package versions' | :success + 'PUBLIC' | :anonymous | false | true | 'PyPi package versions' | :success + 'PRIVATE' | :developer | true | true | 'PyPi package versions' | :success + 'PRIVATE' | :guest | true | true | 'process PyPi api request' | :forbidden + 'PRIVATE' | :developer | true | false | 'process PyPi api request' | :unauthorized + 'PRIVATE' | :guest | true | false | 'process PyPi api request' | :unauthorized + 'PRIVATE' | :developer | false | true | 'process PyPi api request' | :not_found + 'PRIVATE' | :guest | false | true | 'process PyPi api request' | :not_found + 'PRIVATE' | :developer | false | false | 'process PyPi api request' | :unauthorized + 'PRIVATE' | :guest | false | false | 'process PyPi api request' | :unauthorized + 'PRIVATE' | :anonymous | false | true | 'process PyPi api request' | :unauthorized + end + + with_them do + let(:token) { user_token ? personal_access_token.token : 'wrong' } + let(:headers) { user_role == :anonymous ? {} : build_basic_auth_header(user.username, token) } + + subject { get api(url), headers: headers } + + before do + project.update!(visibility_level: Gitlab::VisibilityLevel.const_get(project_visibility_level, false)) + end + + it_behaves_like params[:shared_examples_name], params[:user_role], params[:expected_status], params[:member] + end + end + + it_behaves_like 'deploy token for package GET requests' + + it_behaves_like 'rejects PyPI access with unknown project id' + end + end + + describe 'POST /api/v4/projects/:id/packages/pypi/authorize' do + let_it_be(:workhorse_token) { JWT.encode({ 'iss' => 'gitlab-workhorse' }, Gitlab::Workhorse.secret, 'HS256') } + let_it_be(:workhorse_header) { { 'GitLab-Workhorse' => '1.0', Gitlab::Workhorse::INTERNAL_API_REQUEST_HEADER => workhorse_token } } + let(:url) { "/projects/#{project.id}/packages/pypi/authorize" } + let(:headers) { {} } + + subject { post api(url), headers: headers } + + context 'without the need for a license' do + context 'with valid project' do + using RSpec::Parameterized::TableSyntax + + where(:project_visibility_level, :user_role, :member, :user_token, :shared_examples_name, :expected_status) do + 'PUBLIC' | :developer | true | true | 'process PyPi api request' | :success + 'PUBLIC' | :guest | true | true | 'process PyPi api request' | :forbidden + 'PUBLIC' | :developer | true | false | 'process PyPi api request' | :unauthorized + 'PUBLIC' | :guest | true | false | 'process PyPi api request' | :unauthorized + 'PUBLIC' | :developer | false | true | 'process PyPi api request' | :forbidden + 'PUBLIC' | :guest | false | true | 'process PyPi api request' | :forbidden + 'PUBLIC' | :developer | false | false | 'process PyPi api request' | :unauthorized + 'PUBLIC' | :guest | false | false | 'process PyPi api request' | :unauthorized + 'PUBLIC' | :anonymous | false | true | 'process PyPi api request' | :unauthorized + 'PRIVATE' | :developer | true | true | 'process PyPi api request' | :success + 'PRIVATE' | :guest | true | true | 'process PyPi api request' | :forbidden + 'PRIVATE' | :developer | true | false | 'process PyPi api request' | :unauthorized + 'PRIVATE' | :guest | true | false | 'process PyPi api request' | :unauthorized + 'PRIVATE' | :developer | false | true | 'process PyPi api request' | :not_found + 'PRIVATE' | :guest | false | true | 'process PyPi api request' | :not_found + 'PRIVATE' | :developer | false | false | 'process PyPi api request' | :unauthorized + 'PRIVATE' | :guest | false | false | 'process PyPi api request' | :unauthorized + 'PRIVATE' | :anonymous | false | true | 'process PyPi api request' | :unauthorized + end + + with_them do + let(:token) { user_token ? personal_access_token.token : 'wrong' } + let(:user_headers) { user_role == :anonymous ? {} : build_basic_auth_header(user.username, token) } + let(:headers) { user_headers.merge(workhorse_header) } + + before do + project.update!(visibility_level: Gitlab::VisibilityLevel.const_get(project_visibility_level, false)) + end + + it_behaves_like params[:shared_examples_name], params[:user_role], params[:expected_status], params[:member] + end + end + + it_behaves_like 'deploy token for package uploads' + + it_behaves_like 'rejects PyPI access with unknown project id' + end + end + + describe 'POST /api/v4/projects/:id/packages/pypi' do + let(:workhorse_token) { JWT.encode({ 'iss' => 'gitlab-workhorse' }, Gitlab::Workhorse.secret, 'HS256') } + let(:workhorse_header) { { 'GitLab-Workhorse' => '1.0', Gitlab::Workhorse::INTERNAL_API_REQUEST_HEADER => workhorse_token } } + let_it_be(:file_name) { 'package.whl' } + let(:url) { "/projects/#{project.id}/packages/pypi" } + let(:headers) { {} } + let(:base_params) { { requires_python: '>=3.7', version: '1.0.0', name: 'sample-project', sha256_digest: '123' } } + let(:params) { base_params.merge(content: temp_file(file_name)) } + let(:send_rewritten_field) { true } + + subject do + workhorse_finalize( + api(url), + method: :post, + file_key: :content, + params: params, + headers: headers, + send_rewritten_field: send_rewritten_field + ) + end + + context 'without the need for a license' do + context 'with valid project' do + using RSpec::Parameterized::TableSyntax + + where(:project_visibility_level, :user_role, :member, :user_token, :shared_examples_name, :expected_status) do + 'PUBLIC' | :developer | true | true | 'PyPi package creation' | :created + 'PUBLIC' | :guest | true | true | 'process PyPi api request' | :forbidden + 'PUBLIC' | :developer | true | false | 'process PyPi api request' | :unauthorized + 'PUBLIC' | :guest | true | false | 'process PyPi api request' | :unauthorized + 'PUBLIC' | :developer | false | true | 'process PyPi api request' | :forbidden + 'PUBLIC' | :guest | false | true | 'process PyPi api request' | :forbidden + 'PUBLIC' | :developer | false | false | 'process PyPi api request' | :unauthorized + 'PUBLIC' | :guest | false | false | 'process PyPi api request' | :unauthorized + 'PUBLIC' | :anonymous | false | true | 'process PyPi api request' | :unauthorized + 'PRIVATE' | :developer | true | true | 'process PyPi api request' | :created + 'PRIVATE' | :guest | true | true | 'process PyPi api request' | :forbidden + 'PRIVATE' | :developer | true | false | 'process PyPi api request' | :unauthorized + 'PRIVATE' | :guest | true | false | 'process PyPi api request' | :unauthorized + 'PRIVATE' | :developer | false | true | 'process PyPi api request' | :not_found + 'PRIVATE' | :guest | false | true | 'process PyPi api request' | :not_found + 'PRIVATE' | :developer | false | false | 'process PyPi api request' | :unauthorized + 'PRIVATE' | :guest | false | false | 'process PyPi api request' | :unauthorized + 'PRIVATE' | :anonymous | false | true | 'process PyPi api request' | :unauthorized + end + + with_them do + let(:token) { user_token ? personal_access_token.token : 'wrong' } + let(:user_headers) { user_role == :anonymous ? {} : build_basic_auth_header(user.username, token) } + let(:headers) { user_headers.merge(workhorse_header) } + + before do + project.update!(visibility_level: Gitlab::VisibilityLevel.const_get(project_visibility_level, false)) + end + + it_behaves_like params[:shared_examples_name], params[:user_role], params[:expected_status], params[:member] + end + end + + context 'with an invalid package' do + let(:token) { personal_access_token.token } + let(:user_headers) { build_basic_auth_header(user.username, token) } + let(:headers) { user_headers.merge(workhorse_header) } + + before do + params[:name] = '.$/@!^*' + project.add_developer(user) + end + + it_behaves_like 'returning response status', :bad_request + end + + it_behaves_like 'deploy token for package uploads' + + it_behaves_like 'rejects PyPI access with unknown project id' + end + end + + describe 'GET /api/v4/projects/:id/packages/pypi/files/:sha256/*file_identifier' do + let_it_be(:package_name) { 'Dummy-Package' } + let_it_be(:package) { create(:pypi_package, project: project, name: package_name, version: '1.0.0') } + + let(:url) { "/projects/#{project.id}/packages/pypi/files/#{package.package_files.first.file_sha256}/#{package_name}-1.0.0.tar.gz" } + + subject { get api(url) } + + context 'without the need for a license' do + context 'with valid project' do + using RSpec::Parameterized::TableSyntax + + where(:project_visibility_level, :user_role, :member, :user_token, :shared_examples_name, :expected_status) do + 'PUBLIC' | :developer | true | true | 'PyPi package download' | :success + 'PUBLIC' | :guest | true | true | 'PyPi package download' | :success + 'PUBLIC' | :developer | true | false | 'PyPi package download' | :success + 'PUBLIC' | :guest | true | false | 'PyPi package download' | :success + 'PUBLIC' | :developer | false | true | 'PyPi package download' | :success + 'PUBLIC' | :guest | false | true | 'PyPi package download' | :success + 'PUBLIC' | :developer | false | false | 'PyPi package download' | :success + 'PUBLIC' | :guest | false | false | 'PyPi package download' | :success + 'PUBLIC' | :anonymous | false | true | 'PyPi package download' | :success + 'PRIVATE' | :developer | true | true | 'PyPi package download' | :success + 'PRIVATE' | :guest | true | true | 'PyPi package download' | :success + 'PRIVATE' | :developer | true | false | 'PyPi package download' | :success + 'PRIVATE' | :guest | true | false | 'PyPi package download' | :success + 'PRIVATE' | :developer | false | true | 'PyPi package download' | :success + 'PRIVATE' | :guest | false | true | 'PyPi package download' | :success + 'PRIVATE' | :developer | false | false | 'PyPi package download' | :success + 'PRIVATE' | :guest | false | false | 'PyPi package download' | :success + 'PRIVATE' | :anonymous | false | true | 'PyPi package download' | :success + end + + with_them do + let(:token) { user_token ? personal_access_token.token : 'wrong' } + let(:headers) { user_role == :anonymous ? {} : build_basic_auth_header(user.username, token) } + + subject { get api(url), headers: headers } + + before do + project.update!(visibility_level: Gitlab::VisibilityLevel.const_get(project_visibility_level, false)) + end + + it_behaves_like params[:shared_examples_name], params[:user_role], params[:expected_status], params[:member] + end + end + + context 'with deploy token headers' do + let(:headers) { build_basic_auth_header(deploy_token.username, deploy_token.token) } + + context 'valid token' do + it_behaves_like 'returning response status', :success + end + + context 'invalid token' do + let(:headers) { build_basic_auth_header('foo', 'bar') } + + it_behaves_like 'returning response status', :success + end + end + + it_behaves_like 'rejects PyPI access with unknown project id' + end + end +end diff --git a/spec/serializers/merge_request_widget_entity_spec.rb b/spec/serializers/merge_request_widget_entity_spec.rb index 37645f778d9..aaee47fb981 100644 --- a/spec/serializers/merge_request_widget_entity_spec.rb +++ b/spec/serializers/merge_request_widget_entity_spec.rb @@ -7,6 +7,7 @@ RSpec.describe MergeRequestWidgetEntity do let(:project) { create :project, :repository } let(:resource) { create(:merge_request, source_project: project, target_project: project) } + let(:pipeline) { create(:ci_empty_pipeline, project: project) } let(:user) { create(:user) } let(:request) { double('request', current_user: user, project: project) } @@ -53,6 +54,42 @@ RSpec.describe MergeRequestWidgetEntity do .to eq("/#{resource.project.full_path}/-/merge_requests/#{resource.iid}.diff") end + it 'has blob path data' do + allow(resource).to receive_messages( + base_pipeline: pipeline, + head_pipeline: pipeline + ) + + expect(subject).to include(:blob_path) + expect(subject[:blob_path]).to include(:base_path) + expect(subject[:blob_path]).to include(:head_path) + end + + describe 'codequality report artifacts', :request_store do + before do + project.add_developer(user) + + allow(resource).to receive_messages( + base_pipeline: pipeline, + head_pipeline: pipeline + ) + end + + context "with report artifacts" do + let(:pipeline) { create(:ci_pipeline, :with_codequality_report, project: project) } + + it "has data entry" do + expect(subject).to include(:codeclimate) + end + end + + context "without artifacts" do + it "does not have data entry" do + expect(subject).not_to include(:codeclimate) + end + end + end + describe 'merge_request_add_ci_config_path' do let!(:project_auto_devops) { create(:project_auto_devops, :disabled, project: project) } diff --git a/spec/services/alert_management/alerts/update_service_spec.rb b/spec/services/alert_management/alerts/update_service_spec.rb index a62996abbb9..91b02325bad 100644 --- a/spec/services/alert_management/alerts/update_service_spec.rb +++ b/spec/services/alert_management/alerts/update_service_spec.rb @@ -209,6 +209,19 @@ RSpec.describe AlertManagement::Alerts::UpdateService do expect(response).to be_error expect(response.message).to eq(message) end + + context 'fingerprints are blank' do + let_it_be(:alert) { create(:alert_management_alert, :resolved, project: project, fingerprint: nil) } + let_it_be(:existing_alert) { create(:alert_management_alert, :triggered, fingerprint: alert.fingerprint, project: project) } + + it 'successfully changes the status' do + expect { response }.to change { alert.acknowledged? }.to(true) + expect(response).to be_success + expect(response.payload[:alert]).to eq(alert) + end + + it_behaves_like 'adds a system note' + end end context 'two existing closed alerts' do diff --git a/spec/support/helpers/stub_configuration.rb b/spec/support/helpers/stub_configuration.rb index 83d4fa1aa2d..e19f230d8df 100644 --- a/spec/support/helpers/stub_configuration.rb +++ b/spec/support/helpers/stub_configuration.rb @@ -117,6 +117,10 @@ module StubConfiguration allow(::Gitlab.config.service_desk_email).to receive_messages(to_settings(messages)) end + def stub_packages_setting(messages) + allow(::Gitlab.config.packages).to receive_messages(to_settings(messages)) + end + private # Modifies stubbed messages to also stub possible predicate versions diff --git a/spec/support/helpers/test_env.rb b/spec/support/helpers/test_env.rb index 09d16f306fd..f787aedf7aa 100644 --- a/spec/support/helpers/test_env.rb +++ b/spec/support/helpers/test_env.rb @@ -6,6 +6,8 @@ module TestEnv ComponentFailedToInstallError = Class.new(StandardError) + SHA_REGEX = /\A[0-9a-f]{5,40}\z/i.freeze + # When developing the seed repository, comment out the branch you will modify. BRANCH_SHA = { 'signed-commits' => '6101e87', @@ -508,6 +510,8 @@ module TestEnv # Allow local overrides of the component for tests during development return false if Rails.env.test? && File.symlink?(component_folder) + return false if component_matches_git_sha?(component_folder, expected_version) + version = File.read(File.join(component_folder, 'VERSION')).strip # Notice that this will always yield true when using branch versions @@ -517,6 +521,16 @@ module TestEnv rescue Errno::ENOENT true end + + def component_matches_git_sha?(component_folder, expected_version) + # Not a git SHA, so return early + return false unless expected_version =~ SHA_REGEX + + sha, exit_status = Gitlab::Popen.popen(%W(#{Gitlab.config.git.bin_path} rev-parse HEAD), component_folder) + return false if exit_status != 0 + + expected_version == sha.chomp + end end require_relative('../../../ee/spec/support/helpers/ee/test_env') if Gitlab.ee? diff --git a/spec/support/shared_contexts/presenters/nuget_shared_context.rb b/spec/support/shared_contexts/presenters/nuget_shared_context.rb new file mode 100644 index 00000000000..dd381db5a8b --- /dev/null +++ b/spec/support/shared_contexts/presenters/nuget_shared_context.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +RSpec.shared_context 'with expected presenters dependency groups' do + def expected_dependency_groups(project_id, package_name, package_version) + [ + { + id: "http://localhost/api/v4/projects/#{project_id}/packages/nuget/metadata/#{package_name}/#{package_version}.json#dependencyGroup/.netstandard2.0", + target_framework: '.NETStandard2.0', + type: 'PackageDependencyGroup', + dependencies: [ + { + id: "http://localhost/api/v4/projects/#{project_id}/packages/nuget/metadata/#{package_name}/#{package_version}.json#dependencyGroup/.netstandard2.0/newtonsoft.json", + range: '12.0.3', + name: 'Newtonsoft.Json', + type: 'PackageDependency' + } + ] + }, + { + id: "http://localhost/api/v4/projects/#{project_id}/packages/nuget/metadata/#{package_name}/#{package_version}.json#dependencyGroup", + type: 'PackageDependencyGroup', + dependencies: [ + { + id: "http://localhost/api/v4/projects/#{project_id}/packages/nuget/metadata/#{package_name}/#{package_version}.json#dependencyGroup/castle.core", + range: '4.4.1', + name: 'Castle.Core', + type: 'PackageDependency' + } + ] + } + ] + end + + def create_dependencies_for(package) + dependency1 = Packages::Dependency.find_by(name: 'Newtonsoft.Json', version_pattern: '12.0.3') || create(:packages_dependency, name: 'Newtonsoft.Json', version_pattern: '12.0.3') + dependency2 = Packages::Dependency.find_by(name: 'Castle.Core', version_pattern: '4.4.1') || create(:packages_dependency, name: 'Castle.Core', version_pattern: '4.4.1') + + create(:packages_dependency_link, :with_nuget_metadatum, package: package, dependency: dependency1) + create(:packages_dependency_link, package: package, dependency: dependency2) + end +end diff --git a/spec/support/shared_examples/requests/api/composer_packages_shared_examples.rb b/spec/support/shared_examples/requests/api/composer_packages_shared_examples.rb new file mode 100644 index 00000000000..5257980d7df --- /dev/null +++ b/spec/support/shared_examples/requests/api/composer_packages_shared_examples.rb @@ -0,0 +1,138 @@ +# frozen_string_literal: true + +RSpec.shared_context 'Composer user type' do |user_type, add_member| + before do + group.send("add_#{user_type}", user) if add_member && user_type != :anonymous + project.send("add_#{user_type}", user) if add_member && user_type != :anonymous + end +end + +RSpec.shared_examples 'Composer package index' do |user_type, status, add_member = true| + include_context 'Composer user type', user_type, add_member do + it 'returns the package index' do + subject + + expect(response).to have_gitlab_http_status(status) + expect(response).to match_response_schema('public_api/v4/packages/composer/index') + end + end +end + +RSpec.shared_examples 'Composer empty provider index' do |user_type, status, add_member = true| + include_context 'Composer user type', user_type, add_member do + it 'returns the package index' do + subject + + expect(response).to have_gitlab_http_status(status) + expect(response).to match_response_schema('public_api/v4/packages/composer/provider') + expect(json_response['providers']).to eq({}) + end + end +end + +RSpec.shared_examples 'Composer provider index' do |user_type, status, add_member = true| + include_context 'Composer user type', user_type, add_member do + it 'returns the package index' do + subject + + expect(response).to have_gitlab_http_status(status) + expect(response).to match_response_schema('public_api/v4/packages/composer/provider') + expect(json_response['providers']).to include(package.name) + end + end +end + +RSpec.shared_examples 'Composer package api request' do |user_type, status, add_member = true| + include_context 'Composer user type', user_type, add_member do + it 'returns the package index' do + subject + + expect(response).to have_gitlab_http_status(status) + expect(response).to match_response_schema('public_api/v4/packages/composer/package') + expect(json_response['packages']).to include(package.name) + expect(json_response['packages'][package.name]).to include(package.version) + end + end +end + +RSpec.shared_examples 'Composer package creation' do |user_type, status, add_member = true| + context "for user type #{user_type}" do + before do + group.send("add_#{user_type}", user) if add_member && user_type != :anonymous + project.send("add_#{user_type}", user) if add_member && user_type != :anonymous + end + + it 'creates package files' do + expect { subject } + .to change { project.packages.composer.count }.by(1) + + expect(response).to have_gitlab_http_status(status) + end + it_behaves_like 'a gitlab tracking event', described_class.name, 'register_package' + end +end + +RSpec.shared_examples 'process Composer api request' do |user_type, status, add_member = true| + context "for user type #{user_type}" do + before do + group.send("add_#{user_type}", user) if add_member && user_type != :anonymous + project.send("add_#{user_type}", user) if add_member && user_type != :anonymous + end + + it_behaves_like 'returning response status', status + end +end + +RSpec.shared_context 'Composer auth headers' do |user_role, user_token| + let(:token) { user_token ? personal_access_token.token : 'wrong' } + let(:headers) { user_role == :anonymous ? {} : build_basic_auth_header(user.username, token) } +end + +RSpec.shared_context 'Composer api project access' do |project_visibility_level, user_role, user_token| + include_context 'Composer auth headers', user_role, user_token do + before do + project.update!(visibility_level: Gitlab::VisibilityLevel.const_get(project_visibility_level, false)) + end + end +end + +RSpec.shared_context 'Composer api group access' do |project_visibility_level, user_role, user_token| + include_context 'Composer auth headers', user_role, user_token do + before do + project.update!(visibility_level: Gitlab::VisibilityLevel.const_get(project_visibility_level, false)) + group.update!(visibility_level: Gitlab::VisibilityLevel.const_get(project_visibility_level, false)) + end + end +end + +RSpec.shared_examples 'rejects Composer access with unknown group id' do + context 'with an unknown group' do + let(:group) { double(id: non_existing_record_id) } + + context 'as anonymous' do + it_behaves_like 'process Composer api request', :anonymous, :not_found + end + + context 'as authenticated user' do + subject { get api(url), headers: build_basic_auth_header(user.username, personal_access_token.token) } + + it_behaves_like 'process Composer api request', :anonymous, :not_found + end + end +end + +RSpec.shared_examples 'rejects Composer access with unknown project id' do + context 'with an unknown project' do + let(:project) { double(id: non_existing_record_id) } + + context 'as anonymous' do + it_behaves_like 'process Composer api request', :anonymous, :not_found + end + + context 'as authenticated user' do + subject { get api(url), headers: build_basic_auth_header(user.username, personal_access_token.token) } + + it_behaves_like 'process Composer api request', :anonymous, :not_found + end + end +end diff --git a/spec/support/shared_examples/requests/api/nuget_packages_shared_examples.rb b/spec/support/shared_examples/requests/api/nuget_packages_shared_examples.rb new file mode 100644 index 00000000000..8d8483cae72 --- /dev/null +++ b/spec/support/shared_examples/requests/api/nuget_packages_shared_examples.rb @@ -0,0 +1,408 @@ +# frozen_string_literal: true + +RSpec.shared_examples 'rejects nuget packages access' do |user_type, status, add_member = true| + context "for user type #{user_type}" do + before do + project.send("add_#{user_type}", user) if add_member && user_type != :anonymous + end + + it_behaves_like 'returning response status', status + + if status == :unauthorized + it 'has the correct response header' do + subject + + expect(response.headers['Www-Authenticate: Basic realm']).to eq 'GitLab Packages Registry' + end + end + end +end + +RSpec.shared_examples 'process nuget service index request' do |user_type, status, add_member = true| + context "for user type #{user_type}" do + before do + project.send("add_#{user_type}", user) if add_member && user_type != :anonymous + end + + it_behaves_like 'returning response status', status + + it_behaves_like 'a gitlab tracking event', described_class.name, 'nuget_service_index' + + it 'returns a valid json response' do + subject + + expect(response.media_type).to eq('application/json') + expect(json_response).to match_schema('public_api/v4/packages/nuget/service_index') + expect(json_response).to be_a(Hash) + end + + context 'with invalid format' do + let(:url) { "/projects/#{project.id}/packages/nuget/index.xls" } + + it_behaves_like 'rejects nuget packages access', :anonymous, :not_found + end + end +end + +RSpec.shared_examples 'returning nuget metadata json response with json schema' do |json_schema| + it 'returns a valid json response' do + subject + + expect(response.media_type).to eq('application/json') + expect(json_response).to match_schema(json_schema) + expect(json_response).to be_a(Hash) + end +end + +RSpec.shared_examples 'process nuget metadata request at package name level' do |user_type, status, add_member = true| + context "for user type #{user_type}" do + before do + project.send("add_#{user_type}", user) if add_member && user_type != :anonymous + end + + it_behaves_like 'returning response status', status + + it_behaves_like 'returning nuget metadata json response with json schema', 'public_api/v4/packages/nuget/packages_metadata' + + context 'with invalid format' do + let(:url) { "/projects/#{project.id}/packages/nuget/metadata/#{package_name}/index.xls" } + + it_behaves_like 'rejects nuget packages access', :anonymous, :not_found + end + + context 'with lower case package name' do + let_it_be(:package_name) { 'dummy.package' } + + it_behaves_like 'returning response status', status + + it_behaves_like 'returning nuget metadata json response with json schema', 'public_api/v4/packages/nuget/packages_metadata' + end + end +end + +RSpec.shared_examples 'process nuget metadata request at package name and package version level' do |user_type, status, add_member = true| + context "for user type #{user_type}" do + before do + project.send("add_#{user_type}", user) if add_member && user_type != :anonymous + end + + it_behaves_like 'returning response status', status + + it_behaves_like 'returning nuget metadata json response with json schema', 'public_api/v4/packages/nuget/package_metadata' + + context 'with invalid format' do + let(:url) { "/projects/#{project.id}/packages/nuget/metadata/#{package_name}/#{package.version}.xls" } + + it_behaves_like 'rejects nuget packages access', :anonymous, :not_found + end + + context 'with lower case package name' do + let_it_be(:package_name) { 'dummy.package' } + + it_behaves_like 'returning response status', status + + it_behaves_like 'returning nuget metadata json response with json schema', 'public_api/v4/packages/nuget/package_metadata' + end + end +end + +RSpec.shared_examples 'process nuget workhorse authorization' do |user_type, status, add_member = true| + context "for user type #{user_type}" do + before do + project.send("add_#{user_type}", user) if add_member && user_type != :anonymous + end + + it_behaves_like 'returning response status', status + + it 'has the proper content type' do + subject + + expect(response.media_type).to eq(Gitlab::Workhorse::INTERNAL_API_CONTENT_TYPE) + end + + context 'with a request that bypassed gitlab-workhorse' do + let(:headers) do + build_basic_auth_header(user.username, personal_access_token.token) + .merge(workhorse_header) + .tap { |h| h.delete(Gitlab::Workhorse::INTERNAL_API_REQUEST_HEADER) } + end + + before do + project.add_maintainer(user) + end + + it_behaves_like 'returning response status', :forbidden + end + end +end + +RSpec.shared_examples 'process nuget upload' do |user_type, status, add_member = true| + RSpec.shared_examples 'creates nuget package files' do + it 'creates package files' do + expect(::Packages::Nuget::ExtractionWorker).to receive(:perform_async).once + expect { subject } + .to change { project.packages.count }.by(1) + .and change { Packages::PackageFile.count }.by(1) + expect(response).to have_gitlab_http_status(status) + + package_file = project.packages.last.package_files.reload.last + expect(package_file.file_name).to eq('package.nupkg') + end + end + + context "for user type #{user_type}" do + before do + project.send("add_#{user_type}", user) if add_member && user_type != :anonymous + end + + context 'with object storage disabled' do + before do + stub_package_file_object_storage(enabled: false) + end + + context 'without a file from workhorse' do + let(:send_rewritten_field) { false } + + it_behaves_like 'returning response status', :bad_request + end + + context 'with correct params' do + it_behaves_like 'package workhorse uploads' + it_behaves_like 'creates nuget package files' + it_behaves_like 'a gitlab tracking event', described_class.name, 'push_package' + end + end + + context 'with object storage enabled' do + let(:tmp_object) do + fog_connection.directories.new(key: 'packages').files.create( + key: "tmp/uploads/#{file_name}", + body: 'content' + ) + end + let(:fog_file) { fog_to_uploaded_file(tmp_object) } + let(:params) { { package: fog_file, 'package.remote_id' => file_name } } + + context 'and direct upload enabled' do + let(:fog_connection) do + stub_package_file_object_storage(direct_upload: true) + end + + it_behaves_like 'creates nuget package files' + + ['123123', '../../123123'].each do |remote_id| + context "with invalid remote_id: #{remote_id}" do + let(:params) do + { + package: fog_file, + 'package.remote_id' => remote_id + } + end + + it_behaves_like 'returning response status', :forbidden + end + end + + context 'with crafted package.path param' do + let(:crafted_file) { Tempfile.new('nuget.crafted.package.path') } + let(:url) { "/projects/#{project.id}/packages/nuget?package.path=#{crafted_file.path}" } + let(:params) { { file: temp_file(file_name) } } + let(:file_key) { :file } + + it 'does not create a package file' do + expect { subject }.to change { ::Packages::PackageFile.count }.by(0) + end + + it_behaves_like 'returning response status', :bad_request + end + end + + context 'and direct upload disabled' do + context 'and background upload disabled' do + let(:fog_connection) do + stub_package_file_object_storage(direct_upload: false, background_upload: false) + end + + it_behaves_like 'creates nuget package files' + end + + context 'and background upload enabled' do + let(:fog_connection) do + stub_package_file_object_storage(direct_upload: false, background_upload: true) + end + + it_behaves_like 'creates nuget package files' + end + end + end + + it_behaves_like 'background upload schedules a file migration' + end +end + +RSpec.shared_examples 'process nuget download versions request' do |user_type, status, add_member = true| + RSpec.shared_examples 'returns a valid nuget download versions json response' do + it 'returns a valid json response' do + subject + + expect(response.media_type).to eq('application/json') + expect(json_response).to match_schema('public_api/v4/packages/nuget/download_versions') + expect(json_response).to be_a(Hash) + expect(json_response['versions']).to match_array(packages.map(&:version).sort) + end + end + + context "for user type #{user_type}" do + before do + project.send("add_#{user_type}", user) if add_member && user_type != :anonymous + end + + it_behaves_like 'returning response status', status + + it_behaves_like 'returns a valid nuget download versions json response' + + context 'with invalid format' do + let(:url) { "/projects/#{project.id}/packages/nuget/download/#{package_name}/index.xls" } + + it_behaves_like 'rejects nuget packages access', :anonymous, :not_found + end + + context 'with lower case package name' do + let_it_be(:package_name) { 'dummy.package' } + + it_behaves_like 'returning response status', status + + it_behaves_like 'returns a valid nuget download versions json response' + end + end +end + +RSpec.shared_examples 'process nuget download content request' do |user_type, status, add_member = true| + context "for user type #{user_type}" do + before do + project.send("add_#{user_type}", user) if add_member && user_type != :anonymous + end + + it_behaves_like 'returning response status', status + + it_behaves_like 'a gitlab tracking event', described_class.name, 'pull_package' + + it 'returns a valid package archive' do + subject + + expect(response.media_type).to eq('application/octet-stream') + end + + context 'with invalid format' do + let(:url) { "/projects/#{project.id}/packages/nuget/download/#{package.name}/#{package.version}/#{package.name}.#{package.version}.xls" } + + it_behaves_like 'rejects nuget packages access', :anonymous, :not_found + end + + context 'with lower case package name' do + let_it_be(:package_name) { 'dummy.package' } + + it_behaves_like 'returning response status', status + + it 'returns a valid package archive' do + subject + + expect(response.media_type).to eq('application/octet-stream') + end + end + end +end + +RSpec.shared_examples 'process nuget search request' do |user_type, status, add_member = true| + RSpec.shared_examples 'returns a valid json search response' do |status, total_hits, versions| + it_behaves_like 'returning response status', status + + it 'returns a valid json response' do + subject + + expect(response.media_type).to eq('application/json') + expect(json_response).to be_a(Hash) + expect(json_response).to match_schema('public_api/v4/packages/nuget/search') + expect(json_response['totalHits']).to eq total_hits + expect(json_response['data'].map { |e| e['versions'].size }).to match_array(versions) + end + end + + context "for user type #{user_type}" do + before do + project.send("add_#{user_type}", user) if add_member && user_type != :anonymous + end + + it_behaves_like 'returns a valid json search response', status, 4, [1, 5, 5, 1] + + it_behaves_like 'a gitlab tracking event', described_class.name, 'search_package' + + context 'with skip set to 2' do + let(:skip) { 2 } + + it_behaves_like 'returns a valid json search response', status, 4, [5, 1] + end + + context 'with take set to 2' do + let(:take) { 2 } + + it_behaves_like 'returns a valid json search response', status, 4, [1, 5] + end + + context 'without prereleases' do + let(:include_prereleases) { false } + + it_behaves_like 'returns a valid json search response', status, 3, [1, 5, 5] + end + + context 'with empty search term' do + let(:search_term) { '' } + + it_behaves_like 'returns a valid json search response', status, 5, [1, 5, 5, 1, 1] + end + + context 'with nil search term' do + let(:search_term) { nil } + + it_behaves_like 'returns a valid json search response', status, 5, [1, 5, 5, 1, 1] + end + end +end + +RSpec.shared_examples 'rejects nuget access with invalid project id' do + context 'with a project id with invalid integers' do + using RSpec::Parameterized::TableSyntax + + let(:project) { OpenStruct.new(id: id) } + + where(:id, :status) do + '/../' | :unauthorized + '' | :not_found + '%20' | :unauthorized + '%2e%2e%2f' | :unauthorized + 'NaN' | :unauthorized + 00002345 | :unauthorized + 'anything25' | :unauthorized + end + + with_them do + it_behaves_like 'rejects nuget packages access', :anonymous, params[:status] + end + end +end + +RSpec.shared_examples 'rejects nuget access with unknown project id' do + context 'with an unknown project' do + let(:project) { OpenStruct.new(id: 1234567890) } + + context 'as anonymous' do + it_behaves_like 'rejects nuget packages access', :anonymous, :unauthorized + end + + context 'as authenticated user' do + subject { get api(url), headers: build_basic_auth_header(user.username, personal_access_token.token) } + + it_behaves_like 'rejects nuget packages access', :anonymous, :not_found + end + end +end diff --git a/spec/support/shared_examples/requests/api/packages_shared_examples.rb b/spec/support/shared_examples/requests/api/packages_shared_examples.rb new file mode 100644 index 00000000000..ec15d7a4d2e --- /dev/null +++ b/spec/support/shared_examples/requests/api/packages_shared_examples.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +RSpec.shared_examples 'deploy token for package GET requests' do + context 'with deploy token headers' do + let(:headers) { build_basic_auth_header(deploy_token.username, deploy_token.token) } + + subject { get api(url), headers: headers } + + before do + project.update!(visibility_level: Gitlab::VisibilityLevel::PRIVATE) + end + + context 'valid token' do + it_behaves_like 'returning response status', :success + end + + context 'invalid token' do + let(:headers) { build_basic_auth_header(deploy_token.username, 'bar') } + + it_behaves_like 'returning response status', :unauthorized + end + end +end + +RSpec.shared_examples 'deploy token for package uploads' do + context 'with deploy token headers' do + let(:headers) { build_basic_auth_header(deploy_token.username, deploy_token.token).merge(workhorse_header) } + + before do + project.update!(visibility_level: Gitlab::VisibilityLevel::PRIVATE) + end + + context 'valid token' do + it_behaves_like 'returning response status', :success + end + + context 'invalid token' do + let(:headers) { build_basic_auth_header(deploy_token.username, 'bar').merge(workhorse_header) } + + it_behaves_like 'returning response status', :unauthorized + end + end +end diff --git a/spec/support/shared_examples/requests/api/packages_tags_shared_examples.rb b/spec/support/shared_examples/requests/api/packages_tags_shared_examples.rb new file mode 100644 index 00000000000..a371d380f47 --- /dev/null +++ b/spec/support/shared_examples/requests/api/packages_tags_shared_examples.rb @@ -0,0 +1,185 @@ +# frozen_string_literal: true + +RSpec.shared_examples 'rejects package tags access' do |user_type, status| + context "for user type #{user_type}" do + before do + project.send("add_#{user_type}", user) unless user_type == :no_type + end + + it_behaves_like 'returning response status', status + end +end + +RSpec.shared_examples 'returns package tags' do |user_type| + using RSpec::Parameterized::TableSyntax + + before do + stub_application_setting(npm_package_requests_forwarding: false) + project.send("add_#{user_type}", user) unless user_type == :no_type + end + + it_behaves_like 'returning response status', :success + + it 'returns a valid json response' do + subject + + expect(response.media_type).to eq('application/json') + expect(json_response).to be_a(Hash) + end + + it 'returns two package tags' do + subject + + expect(json_response).to match_schema('public_api/v4/packages/npm_package_tags') + expect(json_response.length).to eq(3) # two tags + latest (auto added) + expect(json_response[package_tag1.name]).to eq(package.version) + expect(json_response[package_tag2.name]).to eq(package.version) + expect(json_response['latest']).to eq(package.version) + end + + context 'with invalid package name' do + where(:package_name, :status) do + '%20' | :bad_request + nil | :forbidden + end + + with_them do + it_behaves_like 'returning response status', params[:status] + end + end +end + +RSpec.shared_examples 'create package tag' do |user_type| + using RSpec::Parameterized::TableSyntax + + before do + project.send("add_#{user_type}", user) unless user_type == :no_type + end + + it_behaves_like 'returning response status', :no_content + + it 'creates the package tag' do + expect { subject }.to change { Packages::Tag.count }.by(1) + + last_tag = Packages::Tag.last + expect(last_tag.name).to eq(tag_name) + expect(last_tag.package).to eq(package) + end + + it 'returns a valid response' do + subject + + expect(response.body).to be_empty + end + + context 'with already existing tag' do + let_it_be(:package2) { create(:npm_package, project: project, name: package.name, version: '5.5.55') } + let_it_be(:tag) { create(:packages_tag, package: package2, name: tag_name) } + + it_behaves_like 'returning response status', :no_content + + it 'reuses existing tag' do + expect(package.tags).to be_empty + expect(package2.tags).to eq([tag]) + expect { subject }.to not_change { Packages::Tag.count } + expect(package.reload.tags).to eq([tag]) + expect(package2.reload.tags).to be_empty + end + + it 'returns a valid response' do + subject + + expect(response.body).to be_empty + end + end + + context 'with invalid package name' do + where(:package_name, :status) do + 'unknown' | :forbidden + '' | :not_found + '%20' | :bad_request + end + + with_them do + it_behaves_like 'returning response status', params[:status] + end + end + + context 'with invalid tag name' do + where(:tag_name, :status) do + '' | :not_found + '%20' | :bad_request + end + + with_them do + it_behaves_like 'returning response status', params[:status] + end + end + + context 'with invalid version' do + where(:version, :status) do + ' ' | :bad_request + '' | :bad_request + nil | :bad_request + end + + with_them do + it_behaves_like 'returning response status', params[:status] + end + end +end + +RSpec.shared_examples 'delete package tag' do |user_type| + using RSpec::Parameterized::TableSyntax + + before do + project.send("add_#{user_type}", user) unless user_type == :no_type + end + + context "for #{user_type} user" do + it_behaves_like 'returning response status', :no_content + + it 'returns a valid response' do + subject + + expect(response.body).to be_empty + end + + it 'destroy the package tag' do + expect(package.tags).to eq([package_tag]) + expect { subject }.to change { Packages::Tag.count }.by(-1) + expect(package.reload.tags).to be_empty + end + + context 'with tag from other package' do + let(:package2) { create(:npm_package, project: project) } + let(:package_tag) { create(:packages_tag, package: package2) } + + it_behaves_like 'returning response status', :not_found + end + + context 'with invalid package name' do + where(:package_name, :status) do + 'unknown' | :forbidden + '' | :not_found + '%20' | :bad_request + end + + with_them do + it_behaves_like 'returning response status', params[:status] + end + end + + context 'with invalid tag name' do + where(:tag_name, :status) do + 'unknown' | :not_found + '' | :not_found + '%20' | :bad_request + end + + with_them do + it_behaves_like 'returning response status', params[:status] + end + end + end +end diff --git a/spec/support/shared_examples/requests/api/pypi_packages_shared_examples.rb b/spec/support/shared_examples/requests/api/pypi_packages_shared_examples.rb new file mode 100644 index 00000000000..fcc166ac87d --- /dev/null +++ b/spec/support/shared_examples/requests/api/pypi_packages_shared_examples.rb @@ -0,0 +1,152 @@ +# frozen_string_literal: true + +RSpec.shared_examples 'PyPi package creation' do |user_type, status, add_member = true| + RSpec.shared_examples 'creating pypi package files' do + it 'creates package files' do + expect { subject } + .to change { project.packages.pypi.count }.by(1) + .and change { Packages::PackageFile.count }.by(1) + .and change { Packages::Pypi::Metadatum.count }.by(1) + expect(response).to have_gitlab_http_status(status) + + package = project.reload.packages.pypi.last + + expect(package.name).to eq params[:name] + expect(package.version).to eq params[:version] + expect(package.pypi_metadatum.required_python).to eq params[:requires_python] + end + end + + context "for user type #{user_type}" do + before do + project.send("add_#{user_type}", user) if add_member && user_type != :anonymous + end + + it_behaves_like 'creating pypi package files' + + context 'with object storage disabled' do + before do + stub_package_file_object_storage(enabled: false) + end + + context 'without a file from workhorse' do + let(:send_rewritten_field) { false } + + it_behaves_like 'returning response status', :bad_request + end + + context 'with correct params' do + it_behaves_like 'package workhorse uploads' + it_behaves_like 'creating pypi package files' + it_behaves_like 'a gitlab tracking event', described_class.name, 'push_package' + end + end + + context 'with object storage enabled' do + let(:tmp_object) do + fog_connection.directories.new(key: 'packages').files.create( + key: "tmp/uploads/#{file_name}", + body: 'content' + ) + end + let(:fog_file) { fog_to_uploaded_file(tmp_object) } + let(:params) { base_params.merge(content: fog_file, 'content.remote_id' => file_name) } + + context 'and direct upload enabled' do + let(:fog_connection) do + stub_package_file_object_storage(direct_upload: true) + end + + it_behaves_like 'creating pypi package files' + + ['123123', '../../123123'].each do |remote_id| + context "with invalid remote_id: #{remote_id}" do + let(:params) { base_params.merge(content: fog_file, 'content.remote_id' => remote_id) } + + it_behaves_like 'returning response status', :forbidden + end + end + end + + context 'and direct upload disabled' do + context 'and background upload disabled' do + let(:fog_connection) do + stub_package_file_object_storage(direct_upload: false, background_upload: false) + end + + it_behaves_like 'creating pypi package files' + end + + context 'and background upload enabled' do + let(:fog_connection) do + stub_package_file_object_storage(direct_upload: false, background_upload: true) + end + + it_behaves_like 'creating pypi package files' + end + end + end + + it_behaves_like 'background upload schedules a file migration' + end +end + +RSpec.shared_examples 'PyPi package versions' do |user_type, status, add_member = true| + context "for user type #{user_type}" do + before do + project.send("add_#{user_type}", user) if add_member && user_type != :anonymous + end + + it 'returns the package listing' do + subject + + expect(response.body).to match(package.package_files.first.file_name) + end + + it_behaves_like 'returning response status', status + it_behaves_like 'a gitlab tracking event', described_class.name, 'list_package' + end +end + +RSpec.shared_examples 'PyPi package download' do |user_type, status, add_member = true| + context "for user type #{user_type}" do + before do + project.send("add_#{user_type}", user) if add_member && user_type != :anonymous + end + + it 'returns the package listing' do + subject + + expect(response.body).to eq(File.open(package.package_files.first.file.path, "rb").read) + end + + it_behaves_like 'returning response status', status + it_behaves_like 'a gitlab tracking event', described_class.name, 'pull_package' + end +end + +RSpec.shared_examples 'process PyPi api request' do |user_type, status, add_member = true| + context "for user type #{user_type}" do + before do + project.send("add_#{user_type}", user) if add_member && user_type != :anonymous + end + + it_behaves_like 'returning response status', status + end +end + +RSpec.shared_examples 'rejects PyPI access with unknown project id' do + context 'with an unknown project' do + let(:project) { OpenStruct.new(id: 1234567890) } + + context 'as anonymous' do + it_behaves_like 'process PyPi api request', :anonymous, :not_found + end + + context 'as authenticated user' do + subject { get api(url), headers: build_basic_auth_header(user.username, personal_access_token.token) } + + it_behaves_like 'process PyPi api request', :anonymous, :not_found + end + end +end diff --git a/spec/uploaders/packages/package_file_uploader_spec.rb b/spec/uploaders/packages/package_file_uploader_spec.rb new file mode 100644 index 00000000000..1fe65649d7a --- /dev/null +++ b/spec/uploaders/packages/package_file_uploader_spec.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true +require 'spec_helper' + +RSpec.describe Packages::PackageFileUploader do + let(:package_file) { create(:package_file, :xml) } + let(:uploader) { described_class.new(package_file, :file) } + let(:path) { Gitlab.config.packages.storage_path } + + subject { uploader } + + it_behaves_like "builds correct paths", + store_dir: %r[\h{2}/\h{2}], + cache_dir: %r[/packages/tmp/cache], + work_dir: %r[/packages/tmp/work] + + context 'object store is remote' do + before do + stub_packages_object_storage + end + + include_context 'with storage', described_class::Store::REMOTE + + it_behaves_like "builds correct paths", + store_dir: %r[\h{2}/\h{2}] + end + + describe 'remote file' do + let(:package_file) { create(:package_file, :object_storage, :xml) } + + context 'with object storage enabled' do + before do + stub_packages_object_storage + end + + it 'can store file remotely' do + allow(ObjectStorage::BackgroundMoveWorker).to receive(:perform_async) + + package_file + + expect(package_file.file_store).to eq(described_class::Store::REMOTE) + expect(package_file.file.path).not_to be_blank + end + end + end +end diff --git a/spec/workers/packages/nuget/extraction_worker_spec.rb b/spec/workers/packages/nuget/extraction_worker_spec.rb new file mode 100644 index 00000000000..35b5f1baed5 --- /dev/null +++ b/spec/workers/packages/nuget/extraction_worker_spec.rb @@ -0,0 +1,94 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Packages::Nuget::ExtractionWorker, type: :worker do + describe '#perform' do + let!(:package) { create(:nuget_package) } + let(:package_file) { package.package_files.first } + let(:package_file_id) { package_file.id } + + let_it_be(:package_name) { 'DummyProject.DummyPackage' } + let_it_be(:package_version) { '1.0.0' } + + subject { described_class.new.perform(package_file_id) } + + context 'with valid package file' do + it 'updates package and package file' do + expect { subject } + .to not_change { Packages::Package.count } + .and not_change { Packages::PackageFile.count } + end + + context 'with exisiting package' do + let!(:existing_package) { create(:nuget_package, project: package.project, name: package_name, version: package_version) } + + it 'reuses existing package and updates package file' do + expect { subject } + .to change { Packages::Package.count }.by(-1) + .and change { existing_package.reload.package_files.count }.by(1) + .and not_change { Packages::PackageFile.count } + end + end + end + + context 'with invalid package file id' do + let(:package_file_id) { 5555 } + + it "doesn't update package and package file" do + expect { subject } + .to not_change { package.reload.name } + .and not_change { package.version } + .and not_change { package_file.reload.file_name } + end + end + + context 'with package file not containing a nuspec file' do + before do + allow_any_instance_of(Zip::File).to receive(:glob).and_return([]) + end + + it 'removes the package and the package file' do + expect(Gitlab::ErrorTracking).to receive(:log_exception).with( + instance_of(::Packages::Nuget::MetadataExtractionService::ExtractionError), + project_id: package.project_id + ) + expect { subject } + .to change { Packages::Package.count }.by(-1) + .and change { Packages::PackageFile.count }.by(-1) + end + end + + context 'with package file with a blank package name' do + before do + allow_any_instance_of(::Packages::Nuget::UpdatePackageFromMetadataService).to receive(:package_name).and_return('') + end + + it 'removes the package and the package file' do + expect(Gitlab::ErrorTracking).to receive(:log_exception).with( + instance_of(::Packages::Nuget::UpdatePackageFromMetadataService::InvalidMetadataError), + project_id: package.project_id + ) + expect { subject } + .to change { Packages::Package.count }.by(-1) + .and change { Packages::PackageFile.count }.by(-1) + end + end + + context 'with package file with a blank package version' do + before do + allow_any_instance_of(::Packages::Nuget::UpdatePackageFromMetadataService).to receive(:package_version).and_return('') + end + + it 'removes the package and the package file' do + expect(Gitlab::ErrorTracking).to receive(:log_exception).with( + instance_of(::Packages::Nuget::UpdatePackageFromMetadataService::InvalidMetadataError), + project_id: package.project_id + ) + expect { subject } + .to change { Packages::Package.count }.by(-1) + .and change { Packages::PackageFile.count }.by(-1) + end + end + end +end diff --git a/vendor/project_templates/jsonnet.tar.gz b/vendor/project_templates/jsonnet.tar.gz Binary files differnew file mode 100644 index 00000000000..8da4227530a --- /dev/null +++ b/vendor/project_templates/jsonnet.tar.gz |