summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2020-07-17 06:09:11 +0000
committerGitLab Bot <gitlab-bot@gitlab.com>2020-07-17 06:09:11 +0000
commit6110935892876a26d8dfcb919d8c955c92ecc1e5 (patch)
tree09c4393c8eb9d3807df842d6d707fb319ffbd7ea
parent4bc0e064023a13d90da5acc4fd152fca66926ea2 (diff)
downloadgitlab-ce-6110935892876a26d8dfcb919d8c955c92ecc1e5.tar.gz
Add latest changes from gitlab-org/gitlab@master
-rw-r--r--.gitlab/ci/docs.gitlab-ci.yml9
-rw-r--r--.haml-lint.yml2
-rw-r--r--.rubocop_todo.yml16
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals.vue212
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals_summary.vue70
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals_summary_optional.vue50
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/approvals/messages.js11
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/mixins/approvals.js19
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue14
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/services/mr_widget_service.js16
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js19
-rw-r--r--app/assets/stylesheets/utilities.scss8
-rw-r--r--app/controllers/concerns/issuable_collections.rb5
-rw-r--r--app/graphql/resolvers/packages_resolver.rb19
-rw-r--r--app/graphql/types/package_type.rb16
-rw-r--r--app/graphql/types/package_type_enum.rb9
-rw-r--r--app/graphql/types/project_type.rb4
-rw-r--r--app/models/ci/job_artifact.rb6
-rw-r--r--app/models/ci/pipeline.rb32
-rw-r--r--app/models/packages/package_file.rb2
-rw-r--r--app/presenters/ci/pipeline_presenter.rb11
-rw-r--r--app/presenters/packages/composer/packages_presenter.rb71
-rw-r--r--app/presenters/packages/conan/package_presenter.rb114
-rw-r--r--app/presenters/packages/detail/package_presenter.rb75
-rw-r--r--app/presenters/packages/go/module_version_presenter.rb19
-rw-r--r--app/presenters/packages/npm/package_presenter.rb87
-rw-r--r--app/presenters/packages/nuget/package_metadata_presenter.rb25
-rw-r--r--app/presenters/packages/nuget/packages_metadata_presenter.rb63
-rw-r--r--app/presenters/packages/nuget/packages_versions_presenter.rb15
-rw-r--r--app/presenters/packages/nuget/presenter_helpers.rb113
-rw-r--r--app/presenters/packages/nuget/search_results_presenter.rb56
-rw-r--r--app/presenters/packages/nuget/service_index_presenter.rb85
-rw-r--r--app/presenters/packages/pypi/package_presenter.rb75
-rw-r--r--app/serializers/merge_request_widget_entity.rb30
-rw-r--r--app/services/alert_management/alerts/update_service.rb2
-rw-r--r--app/services/labels/transfer_service.rb4
-rw-r--r--app/views/projects/merge_requests/_approvals_count.html.haml13
-rw-r--r--app/views/projects/merge_requests/_merge_request.html.haml2
-rw-r--r--app/views/projects/merge_requests/_widget.html.haml1
-rw-r--r--app/views/projects/services/prometheus/_external_alerts.html.haml2
-rw-r--r--app/workers/all_queues.yml772
-rw-r--r--app/workers/packages/nuget/extraction_worker.rb25
-rw-r--r--changelogs/unreleased/213929-move-package-apis-to-core.yml5
-rw-r--r--changelogs/unreleased/faster-label-transfer-queries.yml5
-rw-r--r--changelogs/unreleased/jsonnet-template.yml5
-rw-r--r--changelogs/unreleased/ph-approvalsFEToFoss.yml5
-rw-r--r--doc/README.md2
-rw-r--r--doc/administration/incoming_email.md2
-rw-r--r--doc/api/projects.md2
-rw-r--r--doc/development/documentation/site_architecture/global_nav.md2
-rw-r--r--doc/development/emails.md4
-rw-r--r--doc/development/telemetry/usage_ping.md4
-rw-r--r--doc/user/group/epics/img/epics_list_view_v12.5.pngbin116123 -> 0 bytes
-rw-r--r--doc/user/group/epics/img/new_epic_form_v13.2.pngbin0 -> 96690 bytes
-rw-r--r--doc/user/group/epics/img/new_epic_from_groups_v13.2.pngbin0 -> 78168 bytes
-rw-r--r--doc/user/group/epics/index.md12
-rw-r--r--doc/user/group/epics/manage_epics.md49
-rw-r--r--doc/user/index.md2
-rw-r--r--doc/user/project/integrations/img/jira/open_jira_issues_list_v13.2.pngbin130755 -> 90251 bytes
-rw-r--r--doc/user/project/integrations/img/jira_service_page_v12_2.pngbin57327 -> 0 bytes
-rw-r--r--doc/user/project/integrations/jira.md2
-rw-r--r--doc/user/project/issues/managing_issues.md5
-rw-r--r--doc/user/project/service_desk.md9
-rw-r--r--doc/user/project/settings/index.md2
-rw-r--r--lib/api/api.rb10
-rw-r--r--lib/api/composer_packages.rb156
-rw-r--r--lib/api/conan_packages.rb309
-rw-r--r--lib/api/entities/go_module_version.rb10
-rw-r--r--lib/api/entities/package.rb4
-rwxr-xr-xlib/api/go_proxy.rb135
-rw-r--r--lib/api/group_packages.rb44
-rw-r--r--lib/api/helpers/packages/basic_auth_helpers.rb57
-rw-r--r--lib/api/helpers/packages/conan/api_helpers.rb225
-rw-r--r--lib/api/helpers/packages/dependency_proxy_helpers.rb36
-rw-r--r--lib/api/helpers/packages_helpers.rb52
-rw-r--r--lib/api/helpers/packages_manager_clients_helpers.rb63
-rw-r--r--lib/api/maven_packages.rb251
-rw-r--r--lib/api/npm_packages.rb173
-rw-r--r--lib/api/nuget_packages.rb221
-rw-r--r--lib/api/package_files.rb33
-rw-r--r--lib/api/project_packages.rb71
-rw-r--r--lib/api/pypi_packages.rb148
-rw-r--r--lib/gitlab/conan_token.rb64
-rw-r--r--lib/gitlab/project_template.rb1
-rw-r--r--locale/gitlab.pot3
-rw-r--r--spec/factories/ci/builds.rb6
-rw-r--r--spec/factories/ci/pipelines.rb8
-rw-r--r--spec/features/merge_request/user_approves_spec.rb41
-rw-r--r--spec/frontend/vue_mr_widget/components/approvals/approvals_spec.js391
-rw-r--r--spec/frontend/vue_mr_widget/components/approvals/approvals_summary_optional_spec.js57
-rw-r--r--spec/frontend/vue_mr_widget/components/approvals/approvals_summary_spec.js93
-rw-r--r--spec/graphql/resolvers/packages_resolver_spec.rb17
-rw-r--r--spec/graphql/types/package_type_enum_spec.rb9
-rw-r--r--spec/graphql/types/package_type_spec.rb15
-rw-r--r--spec/lib/api/helpers/packages/dependency_proxy_helpers_spec.rb72
-rw-r--r--spec/lib/api/helpers/packages_helpers_spec.rb104
-rw-r--r--spec/lib/api/helpers/packages_manager_clients_helpers_spec.rb154
-rw-r--r--spec/lib/gitlab/conan_token_spec.rb97
-rw-r--r--spec/lib/gitlab/project_template_spec.rb2
-rw-r--r--spec/models/ci/job_artifact_spec.rb15
-rw-r--r--spec/models/ci/pipeline_spec.rb10
-rw-r--r--spec/presenters/packages/composer/packages_presenter_spec.rb78
-rw-r--r--spec/presenters/packages/conan/package_presenter_spec.rb181
-rw-r--r--spec/presenters/packages/detail/package_presenter_spec.rb98
-rw-r--r--spec/presenters/packages/npm/package_presenter_spec.rb65
-rw-r--r--spec/presenters/packages/nuget/package_metadata_presenter_spec.rb52
-rw-r--r--spec/presenters/packages/nuget/packages_metadata_presenter_spec.rb66
-rw-r--r--spec/presenters/packages/nuget/packages_versions_presenter_spec.rb14
-rw-r--r--spec/presenters/packages/nuget/search_results_presenter_spec.rb59
-rw-r--r--spec/presenters/packages/nuget/service_index_presenter_spec.rb28
-rw-r--r--spec/presenters/packages/pypi/package_presenter_spec.rb49
-rw-r--r--spec/requests/api/composer_packages_spec.rb302
-rw-r--r--spec/requests/api/conan_packages_spec.rb840
-rw-r--r--spec/requests/api/go_proxy_spec.rb465
-rw-r--r--spec/requests/api/graphql/project/packages_spec.rb69
-rw-r--r--spec/requests/api/group_packages_spec.rb147
-rw-r--r--spec/requests/api/maven_packages_spec.rb569
-rw-r--r--spec/requests/api/npm_packages_spec.rb550
-rw-r--r--spec/requests/api/nuget_packages_spec.rb482
-rw-r--r--spec/requests/api/package_files_spec.rb81
-rw-r--r--spec/requests/api/project_packages_spec.rb272
-rw-r--r--spec/requests/api/pypi_packages_spec.rb259
-rw-r--r--spec/serializers/merge_request_widget_entity_spec.rb37
-rw-r--r--spec/services/alert_management/alerts/update_service_spec.rb13
-rw-r--r--spec/support/helpers/stub_configuration.rb4
-rw-r--r--spec/support/helpers/test_env.rb14
-rw-r--r--spec/support/shared_contexts/presenters/nuget_shared_context.rb41
-rw-r--r--spec/support/shared_examples/requests/api/composer_packages_shared_examples.rb138
-rw-r--r--spec/support/shared_examples/requests/api/nuget_packages_shared_examples.rb408
-rw-r--r--spec/support/shared_examples/requests/api/packages_shared_examples.rb43
-rw-r--r--spec/support/shared_examples/requests/api/packages_tags_shared_examples.rb185
-rw-r--r--spec/support/shared_examples/requests/api/pypi_packages_shared_examples.rb152
-rw-r--r--spec/uploaders/packages/package_file_uploader_spec.rb45
-rw-r--r--spec/workers/packages/nuget/extraction_worker_spec.rb94
-rw-r--r--vendor/project_templates/jsonnet.tar.gzbin0 -> 3857 bytes
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
deleted file mode 100644
index 6e3c39009be..00000000000
--- a/doc/user/group/epics/img/epics_list_view_v12.5.png
+++ /dev/null
Binary files differ
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
new file mode 100644
index 00000000000..3d24763d105
--- /dev/null
+++ b/doc/user/group/epics/img/new_epic_form_v13.2.png
Binary files differ
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
new file mode 100644
index 00000000000..85bc4255595
--- /dev/null
+++ b/doc/user/group/epics/img/new_epic_from_groups_v13.2.png
Binary files differ
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
index 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
Binary files differ
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
deleted file mode 100644
index ba7dad9b438..00000000000
--- a/doc/user/project/integrations/img/jira_service_page_v12_2.png
+++ /dev/null
Binary files differ
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) { '&gt;=2.7' }
+ let(:package) { package1 }
+ end
+
+ it_behaves_like "pypi package presenter" do
+ let(:python_version) { '"><script>alert(1)</script>' }
+ let(:expected_python_version) { '&quot;&gt;&lt;script&gt;alert(1)&lt;/script&gt;' }
+ let(:package) { package1 }
+ end
+
+ it_behaves_like "pypi package presenter" do
+ let(:python_version) { '>=2.7, !=3.0' }
+ let(:expected_python_version) { '&gt;=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
new file mode 100644
index 00000000000..8da4227530a
--- /dev/null
+++ b/vendor/project_templates/jsonnet.tar.gz
Binary files differ