From 5b349bfeb9c73bf088aa4dc3aa95ff482ae77b00 Mon Sep 17 00:00:00 2001 From: Aliaksei Klimau Date: Wed, 8 Jul 2026 17:28:44 +0200 Subject: [PATCH] Add per-user Cargo token authentication Co-Authored-By: Claude Opus 4.6 (1M context) --- CHANGES/24.feature | 3 + pulp_rust/app/auth.py | 52 ++--- .../app/migrations/0003_rustcargotoken.py | 34 +++ ...4_alter_rustcargotoken_options_and_more.py | 21 ++ pulp_rust/app/models.py | 16 ++ pulp_rust/app/serializers.py | 16 ++ pulp_rust/app/views.py | 41 ++-- pulp_rust/app/viewsets.py | 45 +++- pulp_rust/tests/functional/api/test_auth.py | 219 ++++++++++++++---- .../tests/functional/api/test_publish.py | 59 +++-- pulp_rust/tests/functional/api/test_rbac.py | 43 ---- pulp_rust/tests/functional/api/test_yank.py | 57 +++-- pulp_rust/tests/functional/conftest.py | 62 +++++ pulp_rust/tests/functional/utils.py | 6 +- 14 files changed, 496 insertions(+), 178 deletions(-) create mode 100644 CHANGES/24.feature create mode 100644 pulp_rust/app/migrations/0003_rustcargotoken.py create mode 100644 pulp_rust/app/migrations/0004_alter_rustcargotoken_options_and_more.py diff --git a/CHANGES/24.feature b/CHANGES/24.feature new file mode 100644 index 0000000..95c0ecc --- /dev/null +++ b/CHANGES/24.feature @@ -0,0 +1,3 @@ +Added per-user Cargo token authentication for Cargo API endpoints (publish, yank, unyank, /me), +replacing the hardcoded stub token. Tokens are created via the REST API and sent by Cargo +in the `Authorization` header. diff --git a/pulp_rust/app/auth.py b/pulp_rust/app/auth.py index 22aefea..e00862c 100644 --- a/pulp_rust/app/auth.py +++ b/pulp_rust/app/auth.py @@ -1,37 +1,29 @@ -"""Stub authentication for Cargo API endpoints. +"""Cargo token authentication for Cargo API endpoints.""" -This is a temporary placeholder — it validates the Authorization header against -a hardcoded token so that state-changing endpoints (publish, yank, unyank) are -not completely open. It will be replaced by proper token-based auth later. -""" +import hashlib -import functools -import json +from django.utils import timezone +from rest_framework.authentication import BaseAuthentication +from rest_framework.exceptions import AuthenticationFailed -from django.http import HttpResponse +from pulp_rust.app.models import RustCargoToken -STUB_TOKEN = "i_understand_that_pulp_rust_does_not_support_proper_auth_yet" +class CargoTokenAuthentication(BaseAuthentication): + """Authenticate Cargo requests via the Authorization header token.""" -def require_cargo_token(view_method): - """Decorator that validates the Cargo Authorization header against the stub token. - - Returns a 403 with a Cargo-style JSON error if the token is missing or incorrect. - """ - - @functools.wraps(view_method) - def wrapper(self, request, *args, **kwargs): + def authenticate(self, request): token = request.META.get("HTTP_AUTHORIZATION") - if token == STUB_TOKEN: - return view_method(self, request, *args, **kwargs) - if not token: - detail = "this endpoint requires an authorization token" - else: - detail = "invalid authorization token" - return HttpResponse( - json.dumps({"errors": [{"detail": detail}]}), - content_type="application/json", - status=403, - ) - - return wrapper + if not token or not token.startswith("crg_"): + return None + token_hash = hashlib.sha256(token.encode()).hexdigest() + try: + cargo_token = RustCargoToken.objects.select_related("user").get(token_hash=token_hash) + except RustCargoToken.DoesNotExist: + raise AuthenticationFailed("invalid cargo token") + cargo_token.last_used = timezone.now() + cargo_token.save(update_fields=["last_used"]) + return (cargo_token.user, cargo_token) + + def authenticate_header(self, request): + return "CargoToken" diff --git a/pulp_rust/app/migrations/0003_rustcargotoken.py b/pulp_rust/app/migrations/0003_rustcargotoken.py new file mode 100644 index 0000000..7b50413 --- /dev/null +++ b/pulp_rust/app/migrations/0003_rustcargotoken.py @@ -0,0 +1,34 @@ +# Generated by Django 5.2.14 on 2026-07-07 12:33 + +import django.db.models.deletion +import django_lifecycle.mixins +import pulpcore.app.models.base +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('rust', '0002_add_rbac'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='RustCargoToken', + fields=[ + ('pulp_id', models.UUIDField(default=pulpcore.app.models.base.pulp_uuid, editable=False, primary_key=True, serialize=False)), + ('pulp_created', models.DateTimeField(auto_now_add=True)), + ('pulp_last_updated', models.DateTimeField(auto_now=True, null=True)), + ('name', models.CharField(max_length=255)), + ('token_hash', models.CharField(db_index=True, max_length=64, unique=True)), + ('last_used', models.DateTimeField(blank=True, null=True)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='cargo_tokens', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'abstract': False, + }, + bases=(django_lifecycle.mixins.LifecycleModelMixin, models.Model), + ), + ] diff --git a/pulp_rust/app/migrations/0004_alter_rustcargotoken_options_and_more.py b/pulp_rust/app/migrations/0004_alter_rustcargotoken_options_and_more.py new file mode 100644 index 0000000..719632a --- /dev/null +++ b/pulp_rust/app/migrations/0004_alter_rustcargotoken_options_and_more.py @@ -0,0 +1,21 @@ +# Generated by Django 5.2.14 on 2026-07-13 08:54 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('rust', '0003_rustcargotoken'), + ] + + operations = [ + migrations.AlterModelOptions( + name='rustcargotoken', + options={'default_related_name': '%(app_label)s_%(model_name)s'}, + ), + migrations.AlterModelOptions( + name='rustdistribution', + options={'default_related_name': '%(app_label)s_%(model_name)s', 'permissions': [('manage_roles_rustdistribution', 'Can manage roles on rust distributions'), ('publish_rustdistribution', 'Can publish crates to this distribution'), ('yank_rustdistribution', 'Can yank/unyank crates in this distribution')]}, + ), + ] diff --git a/pulp_rust/app/models.py b/pulp_rust/app/models.py index 779176c..fa7bbbc 100755 --- a/pulp_rust/app/models.py +++ b/pulp_rust/app/models.py @@ -2,11 +2,13 @@ import urllib.request from logging import getLogger +from django.conf import settings from django.db import models from django_lifecycle import AFTER_CREATE, hook from pulpcore.plugin.models import ( AutoAddObjPermsMixin, + BaseModel, Content, Distribution, Remote, @@ -352,4 +354,18 @@ class Meta: default_related_name = "%(app_label)s_%(model_name)s" permissions = [ ("manage_roles_rustdistribution", "Can manage roles on rust distributions"), + ("publish_rustdistribution", "Can publish crates to this distribution"), + ("yank_rustdistribution", "Can yank/unyank crates in this distribution"), ] + + +class RustCargoToken(BaseModel): + user = models.ForeignKey( + settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="cargo_tokens" + ) + name = models.CharField(max_length=255, blank=False, null=False) + token_hash = models.CharField(max_length=64, unique=True, db_index=True) + last_used = models.DateTimeField(null=True, blank=True) + + class Meta: + default_related_name = "%(app_label)s_%(model_name)s" diff --git a/pulp_rust/app/serializers.py b/pulp_rust/app/serializers.py index acc854a..9d5e8a7 100755 --- a/pulp_rust/app/serializers.py +++ b/pulp_rust/app/serializers.py @@ -275,6 +275,22 @@ class Meta: model = models.RustDistribution +class CargoTokenSerializer(core_serializers.ModelSerializer): + pulp_href = core_serializers.IdentityField(view_name="cargo/tokens-detail") + token = serializers.CharField( + read_only=True, help_text=_("The token value. Shown once at creation.") + ) + + class Meta: + model = models.RustCargoToken + fields = core_serializers.ModelSerializer.Meta.fields + ( + "name", + "token", + "last_used", + ) + read_only_fields = ("token", "last_used") + + class YankSerializer(serializers.Serializer): """Serializer for yank/unyank operations on a repository.""" diff --git a/pulp_rust/app/views.py b/pulp_rust/app/views.py index af581cb..0f81e0d 100644 --- a/pulp_rust/app/views.py +++ b/pulp_rust/app/views.py @@ -17,6 +17,7 @@ from drf_spectacular.utils import extend_schema from dynaconf import settings from rest_framework.exceptions import Throttled +from rest_framework.permissions import IsAuthenticated from rest_framework.renderers import BaseRenderer, JSONRenderer from rest_framework.views import APIView from rest_framework.viewsets import ViewSet @@ -24,7 +25,7 @@ from pulpcore.plugin.tasking import dispatch from pulpcore.plugin.util import get_domain -from pulp_rust.app.auth import require_cargo_token +from pulp_rust.app.auth import CargoTokenAuthentication from pulp_rust.app.models import ( RustContent, RustDistribution, @@ -292,11 +293,10 @@ class CargoMeApiView(APIView): See: https://doc.rust-lang.org/cargo/reference/registry-web-api.html """ - authentication_classes = [] - permission_classes = [] + authentication_classes = [CargoTokenAuthentication] + permission_classes = [IsAuthenticated] renderer_classes = [JSONRenderer] - @require_cargo_token def get(self, request, **kwargs): return HttpResponse(json.dumps({"ok": True}), content_type="application/json") @@ -311,10 +311,8 @@ class CargoPublishApiView(APIView): See: https://doc.rust-lang.org/cargo/reference/registry-web-api.html#publish """ - # Authentication uses a stub token via @require_cargo_token decorator. - # TODO: Replace with proper per-user token auth and RBAC integration. - authentication_classes = [] - permission_classes = [] + authentication_classes = [CargoTokenAuthentication] + permission_classes = [IsAuthenticated] renderer_classes = [JSONRenderer] def get_distribution(self): @@ -330,7 +328,6 @@ def _error_response(detail, status=400): status=status, ) - @require_cargo_token def put(self, request, **kwargs): """ Handle ``cargo publish`` requests. @@ -341,6 +338,9 @@ def put(self, request, **kwargs): """ distro = self.get_distribution() + if not request.user.has_perm("rust.publish_rustdistribution", distro): + return self._error_response("insufficient permissions", status=403) + if not distro.allow_uploads: return self._error_response("this registry does not allow uploads", status=403) @@ -419,11 +419,22 @@ class CargoDownloadApiView(APIView): View for Cargo's crate download, readme, yank, and unyank endpoints. """ - # Authentication disabled for now - authentication_classes = [] - permission_classes = [] + authentication_classes = [CargoTokenAuthentication] renderer_classes = [PlainTextRenderer, JSONRenderer] + def get_permissions(self): + if self.request.method in ("DELETE", "PUT"): + return [IsAuthenticated()] + return [] + + @staticmethod + def _error_response(detail, status=400): + return HttpResponse( + json.dumps({"errors": [{"detail": detail}]}), + content_type="application/json", + status=status, + ) + def get_full_path(self, base_path, pulp_domain=None): if settings.DOMAIN_ENABLED: domain = pulp_domain or get_domain() @@ -458,7 +469,6 @@ def get(self, request, name, version, rest, **kwargs): else: raise Http404(f"Unknown action: {rest}") - @require_cargo_token def delete(self, request, name, version, rest, **kwargs): """ Responds to DELETE requests for yanking crate versions. @@ -470,6 +480,8 @@ def delete(self, request, name, version, rest, **kwargs): raise Http404(f"Unknown action: {rest}") distro = self.get_distribution() + if not request.user.has_perm("rust.yank_rustdistribution", distro): + return self._error_response("insufficient permissions", status=403) if not distro.repository: raise Http404("No repository associated with this distribution") @@ -499,7 +511,6 @@ def delete(self, request, name, version, rest, **kwargs): has_task_completed(task) return HttpResponse(json.dumps({"ok": True}), content_type="application/json") - @require_cargo_token def put(self, request, name, version, rest, **kwargs): """ Responds to PUT requests for unyanking crate versions. @@ -511,6 +522,8 @@ def put(self, request, name, version, rest, **kwargs): raise Http404(f"Unknown action: {rest}") distro = self.get_distribution() + if not request.user.has_perm("rust.yank_rustdistribution", distro): + return self._error_response("insufficient permissions", status=403) if not distro.repository: raise Http404("No repository associated with this distribution") diff --git a/pulp_rust/app/viewsets.py b/pulp_rust/app/viewsets.py index 26511bb..c065de6 100755 --- a/pulp_rust/app/viewsets.py +++ b/pulp_rust/app/viewsets.py @@ -1,6 +1,11 @@ +import hashlib +import secrets + from django_filters import CharFilter from drf_spectacular.utils import extend_schema from rest_framework.decorators import action +from rest_framework.mixins import CreateModelMixin, DestroyModelMixin, ListModelMixin +from rest_framework.response import Response from pulpcore.plugin import viewsets as core from pulpcore.plugin.actions import ModifyRepositoryActionMixin @@ -9,7 +14,7 @@ RepositorySyncURLSerializer, ) from pulpcore.plugin.tasking import dispatch -from pulpcore.plugin.viewsets import RemoteFilter +from pulpcore.plugin.viewsets import NamedModelViewSet, RemoteFilter from . import models, serializers, tasks @@ -82,6 +87,37 @@ class Meta: ] +class CargoTokenViewSet(NamedModelViewSet, CreateModelMixin, ListModelMixin, DestroyModelMixin): + """Manage Cargo API tokens for the current user.""" + + endpoint_name = "cargo/tokens" + queryset = models.RustCargoToken.objects.all() + serializer_class = serializers.CargoTokenSerializer + + DEFAULT_ACCESS_POLICY = { + "statements": [ + { + "action": ["create", "list", "retrieve", "destroy"], + "principal": "authenticated", + "effect": "allow", + }, + ], + } + + def get_queryset(self): + return super().get_queryset().filter(user=self.request.user) + + def create(self, request, *args, **kwargs): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + raw_token = f"crg_{secrets.token_hex(20)}" + token_hash = hashlib.sha256(raw_token.encode()).hexdigest() + serializer.save(user=request.user, token_hash=token_hash) + data = serializer.data + data["token"] = raw_token + return Response(data, status=201) + + class RustRemoteViewSet(core.RemoteViewSet, core.RolesMixin): """ A ViewSet for RustRemote. @@ -425,6 +461,13 @@ class RustDistributionViewSet(core.DistributionViewSet, core.RolesMixin): "rust.change_rustdistribution", "rust.delete_rustdistribution", "rust.manage_roles_rustdistribution", + "rust.publish_rustdistribution", + "rust.yank_rustdistribution", + ], + "rust.rustdistribution_publisher": [ + "rust.view_rustdistribution", + "rust.publish_rustdistribution", + "rust.yank_rustdistribution", ], "rust.rustdistribution_viewer": ["rust.view_rustdistribution"], } diff --git a/pulp_rust/tests/functional/api/test_auth.py b/pulp_rust/tests/functional/api/test_auth.py index fd79dd3..fe1e748 100644 --- a/pulp_rust/tests/functional/api/test_auth.py +++ b/pulp_rust/tests/functional/api/test_auth.py @@ -1,128 +1,125 @@ -"""Tests for stub authentication on Cargo API endpoints.""" +"""Tests for Cargo token authentication on Cargo API endpoints.""" from urllib.parse import urljoin from pulp_rust.tests.functional.utils import ( - CARGO_AUTH_HEADERS, cargo_api_request, download_file, get_index_entry, minimal_publish_request, ) -# --- 403 tests for missing/wrong token --- +# --- Unauthenticated requests --- -def test_publish_without_token_returns_403( +def test_publish_without_token_returns_401( rust_repo_factory, rust_distribution_factory, cargo_registry_url, ): - """Publish without an Authorization header should return 403.""" + """Publish without an Authorization header should return 401.""" repository = rust_repo_factory() distribution = rust_distribution_factory(repository=repository.pulp_href, allow_uploads=True) base = cargo_registry_url(distribution.base_path) response = minimal_publish_request(base) - assert response.status_code == 403 - errors = response.json()["errors"] - assert any("authorization token" in e["detail"] for e in errors) + assert response.status_code == 401 -def test_publish_with_wrong_token_returns_403( +def test_yank_without_token_returns_401( rust_repo_factory, rust_distribution_factory, cargo_registry_url, ): - """Publish with an incorrect token should return 403.""" + """Yank without an Authorization header should return 401.""" repository = rust_repo_factory() - distribution = rust_distribution_factory(repository=repository.pulp_href, allow_uploads=True) + distribution = rust_distribution_factory(repository=repository.pulp_href) base = cargo_registry_url(distribution.base_path) - response = minimal_publish_request(base, headers={"Authorization": "wrong-token"}) - assert response.status_code == 403 - errors = response.json()["errors"] - assert any("invalid" in e["detail"] for e in errors) + yank_url = urljoin(base, "api/v1/crates/fake/0.0.1/yank") + response = cargo_api_request("DELETE", yank_url) + assert response.status_code == 401 -def test_yank_without_token_returns_403( +def test_unyank_without_token_returns_401( rust_repo_factory, rust_distribution_factory, cargo_registry_url, ): - """Yank without an Authorization header should return 403.""" + """Unyank without an Authorization header should return 401.""" repository = rust_repo_factory() distribution = rust_distribution_factory(repository=repository.pulp_href) base = cargo_registry_url(distribution.base_path) - yank_url = urljoin(base, "api/v1/crates/fake/0.0.1/yank") - response = cargo_api_request("DELETE", yank_url) - assert response.status_code == 403 - errors = response.json()["errors"] - assert any("authorization token" in e["detail"] for e in errors) + unyank_url = urljoin(base, "api/v1/crates/fake/0.0.1/unyank") + response = cargo_api_request("PUT", unyank_url) + assert response.status_code == 401 -def test_unyank_without_token_returns_403( +# --- Invalid token --- + + +def test_publish_with_wrong_token_returns_401( rust_repo_factory, rust_distribution_factory, cargo_registry_url, ): - """Unyank without an Authorization header should return 403.""" + """Publish with an invalid crg_ token should return 401.""" repository = rust_repo_factory() - distribution = rust_distribution_factory(repository=repository.pulp_href) + distribution = rust_distribution_factory(repository=repository.pulp_href, allow_uploads=True) base = cargo_registry_url(distribution.base_path) - unyank_url = urljoin(base, "api/v1/crates/fake/0.0.1/unyank") - response = cargo_api_request("PUT", unyank_url) - assert response.status_code == 403 - errors = response.json()["errors"] - assert any("authorization token" in e["detail"] for e in errors) - - -# --- /me endpoint (cargo login verification) --- + response = minimal_publish_request(base, headers={"Authorization": "crg_wrongtoken"}) + assert response.status_code == 401 -def test_me_with_valid_token( +def test_me_with_wrong_token_returns_401( rust_repo_factory, rust_distribution_factory, cargo_registry_url, ): - """/me with a valid token should return 200 {"ok": true}.""" + """/me with an invalid crg_ token should return 401.""" repository = rust_repo_factory() distribution = rust_distribution_factory(repository=repository.pulp_href) base = cargo_registry_url(distribution.base_path) - response = cargo_api_request("GET", urljoin(base, "me"), headers=CARGO_AUTH_HEADERS) - assert response.status_code == 200 - assert response.json()["ok"] is True + response = cargo_api_request("GET", urljoin(base, "me"), headers={"Authorization": "crg_wrong"}) + assert response.status_code == 401 + +# --- Valid token --- -def test_me_without_token_returns_403( + +def test_me_with_valid_token( rust_repo_factory, rust_distribution_factory, cargo_registry_url, + rust_token_factory, + pulp_admin_user, ): - """/me without a token should return 403.""" + """/me with a valid token should return 200 {"ok": true}.""" repository = rust_repo_factory() distribution = rust_distribution_factory(repository=repository.pulp_href) base = cargo_registry_url(distribution.base_path) + token = rust_token_factory(pulp_admin_user) - response = cargo_api_request("GET", urljoin(base, "me")) - assert response.status_code == 403 + response = cargo_api_request("GET", urljoin(base, "me"), headers={"Authorization": token.token}) + assert response.status_code == 200 + assert response.json()["ok"] is True -def test_me_with_wrong_token_returns_403( +def test_me_without_token_returns_401( rust_repo_factory, rust_distribution_factory, cargo_registry_url, ): - """/me with an invalid token should return 403.""" + """/me without a token should return 401.""" repository = rust_repo_factory() distribution = rust_distribution_factory(repository=repository.pulp_href) base = cargo_registry_url(distribution.base_path) - response = cargo_api_request("GET", urljoin(base, "me"), headers={"Authorization": "wrong"}) - assert response.status_code == 403 + response = cargo_api_request("GET", urljoin(base, "me")) + assert response.status_code == 401 # --- Public endpoints remain accessible without token --- @@ -142,3 +139,133 @@ def test_index_without_token_succeeds(populated_repo): entry = get_index_entry(base_url, "it/oa/itoa", "1.0.0") assert entry is not None assert entry["name"] == "itoa" + + +# --- Token lifecycle --- + + +def test_token_list_only_shows_own_tokens(gen_user, rust_token_factory, rust_token_api_client): + """Users should only see their own tokens, not other users' tokens.""" + alice = gen_user() + bob = gen_user() + + rust_token_factory(alice, name="alice-token") + rust_token_factory(bob, name="bob-token") + + with alice: + alice_tokens = rust_token_api_client.list() + names = [t.name for t in alice_tokens.results] + assert "alice-token" in names + assert "bob-token" not in names + + +def test_revoked_token_returns_401( + rust_token_factory, + rust_token_api_client, + pulp_admin_user, + rust_repo_factory, + rust_distribution_factory, + cargo_registry_url, +): + """A deleted token should no longer authenticate.""" + token = rust_token_factory(pulp_admin_user) + headers = {"Authorization": token.token} + + repository = rust_repo_factory() + distribution = rust_distribution_factory(repository=repository.pulp_href) + base = cargo_registry_url(distribution.base_path) + + # Works before revocation + response = cargo_api_request("GET", urljoin(base, "me"), headers=headers) + assert response.status_code == 200 + + # Revoke + rust_token_api_client.delete(token.pulp_href) + + # Fails after revocation + response = cargo_api_request("GET", urljoin(base, "me"), headers=headers) + assert response.status_code == 401 + + +def test_token_not_returned_on_list( + rust_token_factory, + rust_token_api_client, + pulp_admin_user, +): + """The raw token value should not be visible when listing tokens.""" + rust_token_factory(pulp_admin_user) + + tokens = rust_token_api_client.list() + assert tokens.count >= 1 + for t in tokens.results: + assert t.token is None + + +# --- Distribution-scoped permissions --- + + +def test_publish_requires_distribution_permission( + gen_user, + rust_token_factory, + rust_repo_factory, + rust_distro_api_client, + rust_distribution_factory, + cargo_registry_url, +): + """A user without publish permission on the distribution should get 403.""" + alice = gen_user() + token = rust_token_factory(alice) + headers = {"Authorization": token.token} + + repository = rust_repo_factory() + distribution = rust_distribution_factory(repository=repository.pulp_href, allow_uploads=True) + base = cargo_registry_url(distribution.base_path) + + # Alice has no publish permission — should get 403 + response = minimal_publish_request(base, headers=headers) + assert response.status_code == 403 + + # Grant publisher role on the distribution + rust_distro_api_client.add_role( + distribution.pulp_href, + {"role": "rust.rustdistribution_publisher", "users": [alice.username]}, + ) + + # Now Alice can publish + response = minimal_publish_request(base, headers=headers) + # Won't be 403 anymore — might be 400 (fake crate) but not a permission error + assert response.status_code != 403 + + +def test_yank_requires_distribution_permission( + gen_user, + rust_token_factory, + rust_repo_factory, + rust_distro_api_client, + rust_distribution_factory, + cargo_registry_url, + populated_repo, +): + """A user without yank permission on the distribution should get 403.""" + alice = gen_user() + token = rust_token_factory(alice) + headers = {"Authorization": token.token} + + base_url = populated_repo["base_url"] + distro_href = populated_repo["distribution"].pulp_href + + yank_url = urljoin(base_url, "api/v1/crates/itoa/1.0.0/yank") + + # Alice has no yank permission — should get 403 + response = cargo_api_request("DELETE", yank_url, headers=headers) + assert response.status_code == 403 + + # Grant publisher role (includes yank) + rust_distro_api_client.add_role( + distro_href, + {"role": "rust.rustdistribution_publisher", "users": [alice.username]}, + ) + + # Now Alice can yank + response = cargo_api_request("DELETE", yank_url, headers=headers) + assert response.status_code == 200 diff --git a/pulp_rust/tests/functional/api/test_publish.py b/pulp_rust/tests/functional/api/test_publish.py index f054ef1..ac3c0c4 100644 --- a/pulp_rust/tests/functional/api/test_publish.py +++ b/pulp_rust/tests/functional/api/test_publish.py @@ -12,6 +12,8 @@ from urllib.parse import urljoin +import pytest + from pulp_rust.tests.functional.utils import ( CRATES_IO_URL, assert_index_entry_matches_upstream, @@ -23,12 +25,20 @@ ) +@pytest.fixture +def admin_auth_headers(rust_token_factory, pulp_admin_user): + """Create a Cargo token for the admin user and return auth headers.""" + token = rust_token_factory(pulp_admin_user) + return {"Authorization": token.token} + + def test_cargo_publish_and_index_fidelity( delete_orphans_pre, rust_repo_factory, rust_distribution_factory, cargo_registry_url, upstream_index_entry, + admin_auth_headers, ): """Publish a crate via the Cargo publish API and verify the index matches crates.io.""" crate_name = "serde" @@ -44,7 +54,7 @@ def test_cargo_publish_and_index_fidelity( distribution = rust_distribution_factory(repository=repository.pulp_href, allow_uploads=True) base = cargo_registry_url(distribution.base_path) - response = cargo_publish(base, metadata, crate_bytes) + response = cargo_publish(base, metadata, crate_bytes, headers=admin_auth_headers) assert response.status_code == 200, response.text result = response.json() assert "warnings" in result @@ -59,6 +69,7 @@ def test_cargo_publish_duplicate_rejected( rust_repo_factory, rust_distribution_factory, cargo_registry_url, + admin_auth_headers, ): """Publishing the same crate version twice should be rejected.""" crate_name = "serde" @@ -75,11 +86,11 @@ def test_cargo_publish_duplicate_rejected( base = cargo_registry_url(distribution.base_path) # First publish should succeed - response = cargo_publish(base, metadata, crate_bytes) + response = cargo_publish(base, metadata, crate_bytes, headers=admin_auth_headers) assert response.status_code == 200, response.text # Second publish of the same version should be rejected - response = cargo_publish(base, metadata, crate_bytes) + response = cargo_publish(base, metadata, crate_bytes, headers=admin_auth_headers) assert response.status_code == 400 errors = response.json()["errors"] assert any("already uploaded" in e["detail"] for e in errors) @@ -91,6 +102,7 @@ def test_cargo_publish_ignores_tampered_json_metadata( rust_distribution_factory, cargo_registry_url, upstream_index_entry, + admin_auth_headers, ): """Tampered JSON metadata should be ignored in favor of the Cargo.toml in the tarball. @@ -125,7 +137,7 @@ def test_cargo_publish_ignores_tampered_json_metadata( distribution = rust_distribution_factory(repository=repository.pulp_href, allow_uploads=True) base = cargo_registry_url(distribution.base_path) - response = cargo_publish(base, metadata, crate_bytes) + response = cargo_publish(base, metadata, crate_bytes, headers=admin_auth_headers) assert response.status_code == 200, response.text # The index entry should match crates.io (i.e. the Cargo.toml), not the tampered JSON @@ -138,6 +150,7 @@ def test_cargo_publish_octet_stream_content_type( rust_repo_factory, rust_distribution_factory, cargo_registry_url, + admin_auth_headers, ): """Publish should accept Content-Type: application/octet-stream. @@ -157,7 +170,13 @@ def test_cargo_publish_octet_stream_content_type( distribution = rust_distribution_factory(repository=repository.pulp_href, allow_uploads=True) base = cargo_registry_url(distribution.base_path) - response = cargo_publish(base, metadata, crate_bytes, content_type="application/octet-stream") + response = cargo_publish( + base, + metadata, + crate_bytes, + content_type="application/octet-stream", + headers=admin_auth_headers, + ) assert response.status_code == 200, response.text @@ -165,6 +184,7 @@ def test_cargo_publish_uploads_disabled( rust_repo_factory, rust_distribution_factory, cargo_registry_url, + admin_auth_headers, ): """Publishing to a distribution with allow_uploads=False should be rejected.""" repository = rust_repo_factory() @@ -172,7 +192,7 @@ def test_cargo_publish_uploads_disabled( base = cargo_registry_url(distribution.base_path) metadata = {"name": "foo", "vers": "0.1.0", "deps": [], "features": {}} - response = cargo_publish(base, metadata, b"fake-crate-data") + response = cargo_publish(base, metadata, b"fake-crate-data", headers=admin_auth_headers) assert response.status_code == 403 errors = response.json()["errors"] assert any("does not allow uploads" in e["detail"] for e in errors) @@ -182,6 +202,7 @@ def test_cargo_publish_invalid_name_rejected( rust_repo_factory, rust_distribution_factory, cargo_registry_url, + admin_auth_headers, ): """Publishing with an invalid crate name should be rejected.""" repository = rust_repo_factory() @@ -190,7 +211,7 @@ def test_cargo_publish_invalid_name_rejected( # Starts with a digit metadata = {"name": "123invalid", "vers": "0.1.0", "deps": [], "features": {}} - response = cargo_publish(base, metadata, b"fake") + response = cargo_publish(base, metadata, b"fake", headers=admin_auth_headers) assert response.status_code == 400 assert "crate name" in response.json()["errors"][0]["detail"] @@ -199,6 +220,7 @@ def test_cargo_publish_name_too_long_rejected( rust_repo_factory, rust_distribution_factory, cargo_registry_url, + admin_auth_headers, ): """Publishing with a crate name exceeding 64 characters should be rejected.""" repository = rust_repo_factory() @@ -206,7 +228,7 @@ def test_cargo_publish_name_too_long_rejected( base = cargo_registry_url(distribution.base_path) metadata = {"name": "a" * 65, "vers": "0.1.0", "deps": [], "features": {}} - response = cargo_publish(base, metadata, b"fake") + response = cargo_publish(base, metadata, b"fake", headers=admin_auth_headers) assert response.status_code == 400 assert "maximum length" in response.json()["errors"][0]["detail"] @@ -215,6 +237,7 @@ def test_cargo_publish_invalid_version_rejected( rust_repo_factory, rust_distribution_factory, cargo_registry_url, + admin_auth_headers, ): """Publishing with an invalid version should be rejected.""" repository = rust_repo_factory() @@ -222,7 +245,7 @@ def test_cargo_publish_invalid_version_rejected( base = cargo_registry_url(distribution.base_path) metadata = {"name": "validname", "vers": "not-a-version", "deps": [], "features": {}} - response = cargo_publish(base, metadata, b"fake") + response = cargo_publish(base, metadata, b"fake", headers=admin_auth_headers) assert response.status_code == 400 assert "semver" in response.json()["errors"][0]["detail"] @@ -232,6 +255,7 @@ def test_cargo_publish_canonical_name_conflict_rejected( rust_repo_factory, rust_distribution_factory, cargo_registry_url, + admin_auth_headers, ): """Publishing with a canonically-equivalent name (case or hyphen/underscore) should fail.""" crate_name = "serde" @@ -248,12 +272,12 @@ def test_cargo_publish_canonical_name_conflict_rejected( base = cargo_registry_url(distribution.base_path) # First publish should succeed - response = cargo_publish(base, metadata, crate_bytes) + response = cargo_publish(base, metadata, crate_bytes, headers=admin_auth_headers) assert response.status_code == 200, response.text # Uppercase variant should be rejected as a duplicate metadata_upper = dict(metadata, name="Serde") - response = cargo_publish(base, metadata_upper, b"fake") + response = cargo_publish(base, metadata_upper, b"fake", headers=admin_auth_headers) assert response.status_code == 400 assert "already uploaded" in response.json()["errors"][0]["detail"] @@ -265,6 +289,7 @@ def test_cargo_publish_cross_repo_reuses_content( rust_distribution_factory, rust_content_api_client, cargo_registry_url, + admin_auth_headers, ): """Publishing the same crate to two repos should reuse the global content object.""" crate_name = "serde" @@ -281,7 +306,7 @@ def test_cargo_publish_cross_repo_reuses_content( distro_a = rust_distribution_factory(repository=repo_a.pulp_href, allow_uploads=True) base_a = cargo_registry_url(distro_a.base_path) - response = cargo_publish(base_a, metadata, crate_bytes) + response = cargo_publish(base_a, metadata, crate_bytes, headers=admin_auth_headers) assert response.status_code == 200, response.text # Publish to second repository — should succeed @@ -289,7 +314,7 @@ def test_cargo_publish_cross_repo_reuses_content( distro_b = rust_distribution_factory(repository=repo_b.pulp_href, allow_uploads=True) base_b = cargo_registry_url(distro_b.base_path) - response = cargo_publish(base_b, metadata, crate_bytes) + response = cargo_publish(base_b, metadata, crate_bytes, headers=admin_auth_headers) assert response.status_code == 200, response.text # Verify the same content object is present in both repos (same pulp_href) @@ -313,6 +338,7 @@ def test_cargo_publish_build_metadata_collision_rejected( rust_repo_factory, rust_distribution_factory, cargo_registry_url, + admin_auth_headers, ): """Publishing versions that differ only in build metadata should be rejected. @@ -333,12 +359,12 @@ def test_cargo_publish_build_metadata_collision_rejected( base = cargo_registry_url(distribution.base_path) # First publish should succeed - response = cargo_publish(base, metadata, crate_bytes) + response = cargo_publish(base, metadata, crate_bytes, headers=admin_auth_headers) assert response.status_code == 200, response.text # Same version with build metadata appended should be rejected metadata_with_build = dict(metadata, vers="1.0.210+build1") - response = cargo_publish(base, metadata_with_build, b"fake") + response = cargo_publish(base, metadata_with_build, b"fake", headers=admin_auth_headers) assert response.status_code == 400 assert "already uploaded" in response.json()["errors"][0]["detail"] @@ -351,6 +377,7 @@ def test_cargo_publish_cross_repo_reuses_pull_through_content( rust_distribution_factory, rust_content_api_client, cargo_registry_url, + admin_auth_headers, ): """Publishing a crate that was already cached via pull-through should reuse the same global RustContent object. @@ -390,7 +417,7 @@ def test_cargo_publish_cross_repo_reuses_pull_through_content( pub_distro = rust_distribution_factory(repository=pub_repo.pulp_href, allow_uploads=True) pub_base = cargo_registry_url(pub_distro.base_path) - response = cargo_publish(pub_base, metadata, crate_bytes) + response = cargo_publish(pub_base, metadata, crate_bytes, headers=admin_auth_headers) assert response.status_code == 200, response.text # --- Verify: same content object in both repos --- diff --git a/pulp_rust/tests/functional/api/test_rbac.py b/pulp_rust/tests/functional/api/test_rbac.py index b1541ea..c64d1ae 100644 --- a/pulp_rust/tests/functional/api/test_rbac.py +++ b/pulp_rust/tests/functional/api/test_rbac.py @@ -7,49 +7,6 @@ from pulp_rust.tests.functional.utils import CRATES_IO_URL -@pytest.fixture -def gen_users(gen_user): - """Create three users with viewer, creator, and no roles for the given resources.""" - - def _gen_users(role_names=None): - if role_names is None: - role_names = [] - if isinstance(role_names, str): - role_names = [role_names] - viewer_roles = [f"rust.{role}_viewer" for role in role_names] - creator_roles = [f"rust.{role}_creator" for role in role_names] - alice = gen_user(model_roles=viewer_roles) - bob = gen_user(model_roles=creator_roles) - charlie = gen_user() - return alice, bob, charlie - - return _gen_users - - -@pytest.fixture -def try_action(monitor_task): - """Attempt an API action as a given user and assert the expected HTTP status.""" - - def _try_action(user, api_client, action, expected_status, *args, **kwargs): - action_api = getattr(api_client, f"{action}_with_http_info") - try: - with user: - response = action_api(*args, **kwargs) - data = response.data - status_code = response.status_code - if hasattr(data, "task") and data.task: - data = monitor_task(data.task) - except ApiException as e: - assert e.status == expected_status, f"Expected {expected_status}, got {e.status}: {e}" - else: - assert status_code == expected_status, ( - f"Expected {expected_status}, got {status_code} for {action}" - ) - return data - - return _try_action - - class TestRepositoryRBAC: """Test RBAC for Rust repositories.""" diff --git a/pulp_rust/tests/functional/api/test_yank.py b/pulp_rust/tests/functional/api/test_yank.py index ec5e908..48c99d5 100644 --- a/pulp_rust/tests/functional/api/test_yank.py +++ b/pulp_rust/tests/functional/api/test_yank.py @@ -8,7 +8,6 @@ from requests.exceptions import HTTPError from pulp_rust.tests.functional.utils import ( - CARGO_AUTH_HEADERS, CRATES_IO_URL, cargo_api_request, download_file, @@ -16,9 +15,16 @@ ) -def cargo_yank_request(url, method="DELETE"): +@pytest.fixture +def admin_auth_headers(rust_token_factory, pulp_admin_user): + """Create a Cargo token for the admin user and return auth headers.""" + token = rust_token_factory(pulp_admin_user) + return {"Authorization": token.token} + + +def cargo_yank_request(url, method="DELETE", headers=None): """Make an authenticated DELETE or PUT request to the Cargo yank/unyank API.""" - response = cargo_api_request(method, url, headers=CARGO_AUTH_HEADERS) + response = cargo_api_request(method, url, headers=headers) response.raise_for_status() return response.json() @@ -69,7 +75,7 @@ def populated_repo( # --- Cargo API happy path --- -def test_yank_happy_path(populated_repo): +def test_yank_happy_path(populated_repo, admin_auth_headers): """Yanking a crate version via the Cargo API should mark it as yanked in the index.""" base_url = populated_repo["base_url"] @@ -80,7 +86,7 @@ def test_yank_happy_path(populated_repo): # Yank via Cargo API yank_url = urljoin(base_url, "api/v1/crates/itoa/1.0.0/yank") - result = cargo_yank_request(yank_url, method="DELETE") + result = cargo_yank_request(yank_url, method="DELETE", headers=admin_auth_headers) assert result["ok"] is True # Verify now yanked @@ -88,20 +94,20 @@ def test_yank_happy_path(populated_repo): assert entry["yanked"] is True -def test_unyank_happy_path(populated_repo): +def test_unyank_happy_path(populated_repo, admin_auth_headers): """Unyanking a crate version should restore it in the index.""" base_url = populated_repo["base_url"] # Yank first yank_url = urljoin(base_url, "api/v1/crates/itoa/1.0.0/yank") - cargo_yank_request(yank_url, method="DELETE") + cargo_yank_request(yank_url, method="DELETE", headers=admin_auth_headers) entry = get_index_entry(base_url, "it/oa/itoa", "1.0.0") assert entry["yanked"] is True # Unyank unyank_url = urljoin(base_url, "api/v1/crates/itoa/1.0.0/unyank") - result = cargo_yank_request(unyank_url, method="PUT") + result = cargo_yank_request(unyank_url, method="PUT", headers=admin_auth_headers) assert result["ok"] is True # Verify no longer yanked @@ -116,6 +122,7 @@ def test_yank_nonexistent_package( rust_repo_factory, rust_distribution_factory, cargo_registry_url, + admin_auth_headers, ): """Yanking a crate that doesn't exist in the repo should fail.""" repository = rust_repo_factory() @@ -124,13 +131,14 @@ def test_yank_nonexistent_package( yank_url = urljoin(base_url, "api/v1/crates/nonexistent/0.0.0/yank") with pytest.raises(HTTPError) as exc: - cargo_yank_request(yank_url, method="DELETE") + cargo_yank_request(yank_url, method="DELETE", headers=admin_auth_headers) assert exc.value.response.status_code == 404 def test_yank_no_repository( rust_distribution_factory, cargo_registry_url, + admin_auth_headers, ): """Yanking on a distribution with no repository should 404.""" distribution = rust_distribution_factory() @@ -138,33 +146,33 @@ def test_yank_no_repository( yank_url = urljoin(base_url, "api/v1/crates/itoa/1.0.0/yank") with pytest.raises(HTTPError) as exc: - cargo_yank_request(yank_url, method="DELETE") + cargo_yank_request(yank_url, method="DELETE", headers=admin_auth_headers) assert exc.value.response.status_code == 404 # --- Idempotency --- -def test_yank_idempotent(populated_repo, rust_repo_api_client): +def test_yank_idempotent(populated_repo, rust_repo_api_client, admin_auth_headers): """Yanking the same version twice should be a no-op the second time.""" base_url = populated_repo["base_url"] repo_href = populated_repo["repository"].pulp_href yank_url = urljoin(base_url, "api/v1/crates/itoa/1.0.0/yank") - cargo_yank_request(yank_url, method="DELETE") + cargo_yank_request(yank_url, method="DELETE", headers=admin_auth_headers) repo_after_first = rust_repo_api_client.read(repo_href) first_version = repo_after_first.latest_version_href # Yank again — should be no-op - result = cargo_yank_request(yank_url, method="DELETE") + result = cargo_yank_request(yank_url, method="DELETE", headers=admin_auth_headers) assert result["ok"] is True repo_after_second = rust_repo_api_client.read(repo_href) assert repo_after_second.latest_version_href == first_version -def test_unyank_idempotent(populated_repo, rust_repo_api_client): +def test_unyank_idempotent(populated_repo, rust_repo_api_client, admin_auth_headers): """Unyanking something not yanked should be a no-op.""" base_url = populated_repo["base_url"] repo_href = populated_repo["repository"].pulp_href @@ -173,7 +181,7 @@ def test_unyank_idempotent(populated_repo, rust_repo_api_client): before_version = repo_before.latest_version_href unyank_url = urljoin(base_url, "api/v1/crates/itoa/1.0.0/unyank") - result = cargo_yank_request(unyank_url, method="PUT") + result = cargo_yank_request(unyank_url, method="PUT", headers=admin_auth_headers) assert result["ok"] is True repo_after = rust_repo_api_client.read(repo_href) @@ -190,6 +198,7 @@ def test_yank_isolation_across_repositories( rust_distro_api_client, monitor_task, cargo_registry_url, + admin_auth_headers, ): """Yank state is per-repository: yanking/unyanking in one repo must not affect another.""" remote = rust_remote_factory(url=CRATES_IO_URL) @@ -215,7 +224,7 @@ def test_yank_isolation_across_repositories( # Yank in repo A only yank_url = urljoin(repos["a"]["base_url"], "api/v1/crates/itoa/1.0.0/yank") - cargo_yank_request(yank_url, method="DELETE") + cargo_yank_request(yank_url, method="DELETE", headers=admin_auth_headers) # Repo A should show yanked, repo B should not entry_a = get_index_entry(repos["a"]["base_url"], "it/oa/itoa", "1.0.0") @@ -225,10 +234,10 @@ def test_yank_isolation_across_repositories( # Yank in repo B too, then unyank only in A yank_url_b = urljoin(repos["b"]["base_url"], "api/v1/crates/itoa/1.0.0/yank") - cargo_yank_request(yank_url_b, method="DELETE") + cargo_yank_request(yank_url_b, method="DELETE", headers=admin_auth_headers) unyank_url = urljoin(repos["a"]["base_url"], "api/v1/crates/itoa/1.0.0/unyank") - cargo_yank_request(unyank_url, method="PUT") + cargo_yank_request(unyank_url, method="PUT", headers=admin_auth_headers) # A should be not-yanked, B should remain yanked entry_a = get_index_entry(repos["a"]["base_url"], "it/oa/itoa", "1.0.0") @@ -240,7 +249,7 @@ def test_yank_isolation_across_repositories( # --- Repository versioning --- -def test_yank_creates_new_repo_version(populated_repo, rust_repo_api_client): +def test_yank_creates_new_repo_version(populated_repo, rust_repo_api_client, admin_auth_headers): """Yanking should create a new repository version.""" base_url = populated_repo["base_url"] repo_href = populated_repo["repository"].pulp_href @@ -249,7 +258,7 @@ def test_yank_creates_new_repo_version(populated_repo, rust_repo_api_client): version_before = repo_before.latest_version_href yank_url = urljoin(base_url, "api/v1/crates/itoa/1.0.0/yank") - cargo_yank_request(yank_url, method="DELETE") + cargo_yank_request(yank_url, method="DELETE", headers=admin_auth_headers) repo_after = rust_repo_api_client.read(repo_href) assert repo_after.latest_version_href != version_before @@ -258,13 +267,13 @@ def test_yank_creates_new_repo_version(populated_repo, rust_repo_api_client): # --- Partial yank (multiple versions) --- -def test_partial_yank(populated_repo): +def test_partial_yank(populated_repo, admin_auth_headers): """Yanking one version should not affect other versions of the same crate.""" base_url = populated_repo["base_url"] # Yank only 1.0.0 yank_url = urljoin(base_url, "api/v1/crates/itoa/1.0.0/yank") - cargo_yank_request(yank_url, method="DELETE") + cargo_yank_request(yank_url, method="DELETE", headers=admin_auth_headers) # 1.0.0 should be yanked entry_100 = get_index_entry(base_url, "it/oa/itoa", "1.0.0") @@ -278,7 +287,7 @@ def test_partial_yank(populated_repo): # --- Download after yank --- -def test_download_still_works_after_yank(populated_repo): +def test_download_still_works_after_yank(populated_repo, admin_auth_headers): """Per Cargo spec, yanked crates must remain downloadable.""" base_url = populated_repo["base_url"] @@ -289,7 +298,7 @@ def test_download_still_works_after_yank(populated_repo): # Yank yank_url = urljoin(base_url, "api/v1/crates/itoa/1.0.0/yank") - cargo_yank_request(yank_url, method="DELETE") + cargo_yank_request(yank_url, method="DELETE", headers=admin_auth_headers) # Download should still work after = download_file(download_url) diff --git a/pulp_rust/tests/functional/conftest.py b/pulp_rust/tests/functional/conftest.py index ba9fe4d..cc17641 100644 --- a/pulp_rust/tests/functional/conftest.py +++ b/pulp_rust/tests/functional/conftest.py @@ -5,11 +5,13 @@ from pulpcore.client.pulp_rust import ( ApiClient, + CargoTokensApi, ContentPackagesApi, DistributionsRustApi, RemotesRustApi, RepositoriesRustApi, ) +from pulpcore.client.pulp_rust.exceptions import ApiException from pulp_rust.tests.functional.utils import ( CRATES_IO_URL, @@ -46,6 +48,23 @@ def rust_remote_api_client(rust_client): return RemotesRustApi(rust_client) +@pytest.fixture(scope="session") +def rust_token_api_client(rust_client): + return CargoTokensApi(rust_client) + + +@pytest.fixture +def rust_token_factory(rust_token_api_client, add_to_cleanup): + def _rust_token_factory(user, **kwargs): + kwargs.setdefault("name", str(uuid.uuid4())) + with user: + token_response = rust_token_api_client.create(kwargs) + add_to_cleanup(rust_token_api_client, token_response.pulp_href) + return token_response + + return _rust_token_factory + + @pytest.fixture def rust_distribution_factory(rust_distro_api_client, gen_object_with_cleanup): def _rust_distribution_factory(**kwargs): @@ -144,3 +163,46 @@ def populated_repo( def upstream_index_entry(): """Fetch the canonical index entry for serde 1.0.210 from crates.io (once per module).""" return get_index_entry("https://index.crates.io/", "se/rd/serde", "1.0.210") + + +@pytest.fixture +def gen_users(gen_user): + """Create three users with viewer, creator, and no roles for the given resources.""" + + def _gen_users(role_names=None): + if role_names is None: + role_names = [] + if isinstance(role_names, str): + role_names = [role_names] + viewer_roles = [f"rust.{role}_viewer" for role in role_names] + creator_roles = [f"rust.{role}_creator" for role in role_names] + alice = gen_user(model_roles=viewer_roles) + bob = gen_user(model_roles=creator_roles) + charlie = gen_user() + return alice, bob, charlie + + return _gen_users + + +@pytest.fixture +def try_action(monitor_task): + """Attempt an API action as a given user and assert the expected HTTP status.""" + + def _try_action(user, api_client, action, expected_status, *args, **kwargs): + action_api = getattr(api_client, f"{action}_with_http_info") + try: + with user: + response = action_api(*args, **kwargs) + data = response.data + status_code = response.status_code + if hasattr(data, "task") and data.task: + data = monitor_task(data.task) + except ApiException as e: + assert e.status == expected_status, f"Expected {expected_status}, got {e.status}: {e}" + else: + assert status_code == expected_status, ( + f"Expected {expected_status}, got {status_code} for {action}" + ) + return data + + return _try_action diff --git a/pulp_rust/tests/functional/utils.py b/pulp_rust/tests/functional/utils.py index 8177b2b..322e893 100644 --- a/pulp_rust/tests/functional/utils.py +++ b/pulp_rust/tests/functional/utils.py @@ -8,12 +8,10 @@ import aiohttp import requests -from pulp_rust.app.auth import STUB_TOKEN from pulp_rust.app.utils import extract_cargo_toml, extract_dependencies CRATES_IO_URL = "sparse+https://index.crates.io/" CRATES_IO_DOWNLOAD_URL = "https://static.crates.io/crates/" -CARGO_AUTH_HEADERS = {"Authorization": STUB_TOKEN} # Fields from the sparse index that Pulp should faithfully reproduce. # "yanked" is excluded because it depends on per-repo yank state, not upstream. @@ -88,7 +86,7 @@ def _build_cargo_publish_body(metadata, crate_bytes): ) -def cargo_publish(url, metadata, crate_bytes, content_type=None): +def cargo_publish(url, metadata, crate_bytes, content_type=None, headers=None): """Send a publish request mimicking ``cargo publish``. The body is a custom binary format (length-prefixed JSON metadata + @@ -97,7 +95,7 @@ def cargo_publish(url, metadata, crate_bytes, content_type=None): ``"application/octet-stream"`` matches the fix proposed upstream. """ body = _build_cargo_publish_body(metadata, crate_bytes) - headers = dict(CARGO_AUTH_HEADERS) + headers = dict(headers or {}) if content_type is not None: headers["Content-Type"] = content_type return cargo_api_request(