diff --git a/tests/functional/api/test_simple.py b/tests/functional/api/test_simple.py index 7d97f8dc284a..909037bc1699 100644 --- a/tests/functional/api/test_simple.py +++ b/tests/functional/api/test_simple.py @@ -12,7 +12,12 @@ from http import HTTPStatus -from ...common.db.packaging import FileFactory, ProjectFactory, ReleaseFactory +from ...common.db.packaging import ( + FileFactory, + ProjectFactory, + ProvenanceFactory, + ReleaseFactory, +) def test_simple_api_html(webtest): @@ -34,3 +39,24 @@ def test_simple_api_detail(webtest): assert resp.html.h1.string == f"Links for {project.normalized_name}" # There should be a link for every file assert len(resp.html.find_all("a")) == 2 + + +def test_simple_api_has_provenance(webtest): + project = ProjectFactory.create() + release = ReleaseFactory.create(project=project) + files = FileFactory.create_batch(2, release=release, packagetype="bdist_wheel") + + for file in files: + ProvenanceFactory.create(file=file) + + resp = webtest.get(f"/simple/{project.normalized_name}/", status=HTTPStatus.OK) + links = resp.html.find_all("a") + + for file in files: + link = next(link for link in links if link.text == file.filename) + provenance_url = link.get("data-provenance") + + assert provenance_url == ( + f"http://localhost/integrity/{file.release.project.normalized_name}/" + f"{file.release.version}/{file.filename}/provenance" + ) diff --git a/tests/functional/forklift/test_legacy.py b/tests/functional/forklift/test_legacy.py index ed79477a52ec..3f0bc7dbd684 100644 --- a/tests/functional/forklift/test_legacy.py +++ b/tests/functional/forklift/test_legacy.py @@ -329,3 +329,14 @@ def test_provenance_upload(webtest): assert len(attestations) == 1 attestation = attestations[0] assert attestation == json.loads(attestation_contents) + + # While we needed to be authenticated to upload a project, this is no longer + # required to view it. + webtest.authorization = None + expected_filename = "sampleproject-3.0.0.tar.gz" + + response = webtest.get( + f"/integrity/{project.name}/3.0.0/{expected_filename}/provenance", + status=HTTPStatus.OK, + ) + assert response.json == project.releases[0].files[0].provenance.provenance diff --git a/tests/unit/api/test_simple.py b/tests/unit/api/test_simple.py index 518a3dea2b95..ce05298afd91 100644 --- a/tests/unit/api/test_simple.py +++ b/tests/unit/api/test_simple.py @@ -17,6 +17,7 @@ from pyramid.httpexceptions import HTTPMovedPermanently from pyramid.testing import DummyRequest +from tests.common.db.oidc import GitHubPublisherFactory from warehouse.api import simple from warehouse.packaging.utils import API_VERSION, _valid_simple_detail_context @@ -26,6 +27,7 @@ FileFactory, JournalEntryFactory, ProjectFactory, + ProvenanceFactory, ReleaseFactory, ) @@ -298,6 +300,7 @@ def test_with_files_no_serial(self, db_request, content_type, renderer_override) "upload-time": f.upload_time.isoformat() + "Z", "data-dist-info-metadata": False, "core-metadata": False, + "provenance": None, } for f in files ], @@ -349,6 +352,7 @@ def test_with_files_with_serial(self, db_request, content_type, renderer_overrid "upload-time": f.upload_time.isoformat() + "Z", "data-dist-info-metadata": False, "core-metadata": False, + "provenance": None, } for f in files ], @@ -445,6 +449,7 @@ def test_with_files_with_version_multi_digit( if f.metadata_file_sha256_digest is not None else False ), + "provenance": None, } for f in files ], @@ -489,6 +494,126 @@ def test_with_files_quarantined_omitted_from_index( if renderer_override is not None: assert db_request.override_renderer == renderer_override + @pytest.mark.parametrize( + ("content_type", "renderer_override"), + CONTENT_TYPE_PARAMS, + ) + def test_with_files_varying_provenance( + self, + db_request, + integrity_service, + dummy_attestation, + content_type, + renderer_override, + ): + db_request.accept = content_type + db_request.oidc_publisher = GitHubPublisherFactory.create() + + project = ProjectFactory.create() + release = ReleaseFactory.create(project=project, version="1.0.0") + + # wheel with provenance, sdist with no provenance + wheel = FileFactory.create( + release=release, + filename=f"{project.name}-1.0.0.whl", + packagetype="bdist_wheel", + metadata_file_sha256_digest="deadbeefdeadbeefdeadbeefdeadbeef", + ) + + provenance = ProvenanceFactory.create(file=wheel) + assert wheel.provenance == provenance + + sdist = FileFactory.create( + release=release, + filename=f"{project.name}-1.0.0.tar.gz", + packagetype="sdist", + ) + + files = [sdist, wheel] + + db_request.matchdict["name"] = project.normalized_name + + def route_url(route, **kw): + # Rendering the simple index calls route_url once for each file, + # and zero or one times per file depending on whether the file + # has provenance. We emulate this while testing by maintaining + # a dictionary of expected URLs for each route, which are + # pulled from as appropriate when a route_url call is made. + route_urls = { + "packaging.file": {f.path: f"/file/{f.filename}" for f in files}, + "integrity.provenance": { + ( + wheel.release.project.normalized_name, + wheel.release.version, + wheel.filename, + ): ( + f"/integrity/{wheel.release.project.normalized_name}/" + f"{wheel.release.version}/{wheel.filename}/provenance" + ) + }, + } + + match route: + case "packaging.file": + return route_urls[route].get(kw.get("path"), "") + case "integrity.provenance": + key = ( + kw.get("project_name"), + kw.get("release"), + kw.get("filename"), + ) + return route_urls[route].get(key, "") + case _: + pytest.fail(f"unexpected route: {route}") + + db_request.route_url = route_url + + user = UserFactory.create() + je = JournalEntryFactory.create(name=project.name, submitted_by=user) + + context = { + "meta": {"_last-serial": je.id, "api-version": API_VERSION}, + "name": project.normalized_name, + "versions": ["1.0.0"], + "files": [ + { + "filename": f.filename, + "url": f"/file/{f.filename}", + "hashes": {"sha256": f.sha256_digest}, + "requires-python": f.requires_python, + "yanked": False, + "size": f.size, + "upload-time": f.upload_time.isoformat() + "Z", + "data-dist-info-metadata": ( + {"sha256": "deadbeefdeadbeefdeadbeefdeadbeef"} + if f.metadata_file_sha256_digest is not None + else False + ), + "core-metadata": ( + {"sha256": "deadbeefdeadbeefdeadbeefdeadbeef"} + if f.metadata_file_sha256_digest is not None + else False + ), + "provenance": ( + ( + f"/integrity/{f.release.project.normalized_name}/" + f"{f.release.version}/{f.filename}/provenance" + ) + if f.provenance is not None + else None + ), + } + for f in files + ], + "alternate-locations": [], + } + context = _update_context(context, content_type, renderer_override) + + assert simple.simple_detail(project, db_request) == context + + if renderer_override is not None: + assert db_request.override_renderer == renderer_override + def _update_context(context, content_type, renderer_override): if renderer_override != "json" or content_type in [ diff --git a/warehouse/packaging/utils.py b/warehouse/packaging/utils.py index 282c45747f75..7397cf45a740 100644 --- a/warehouse/packaging/utils.py +++ b/warehouse/packaging/utils.py @@ -22,7 +22,7 @@ from warehouse.packaging.interfaces import ISimpleStorage from warehouse.packaging.models import File, LifecycleStatus, Project, Release -API_VERSION = "1.2" +API_VERSION = "1.3" def _simple_index(request, serial): @@ -101,6 +101,16 @@ def _simple_detail(project, request): if file.metadata_file_sha256_digest else False ), + "provenance": ( + request.route_url( + "integrity.provenance", + project_name=project.normalized_name, + release=file.release.version, + filename=file.filename, + ) + if file.provenance + else None + ), } for file in files ], diff --git a/warehouse/templates/api/simple/detail.html b/warehouse/templates/api/simple/detail.html index 28a8f49c815d..77a73dc63fb6 100644 --- a/warehouse/templates/api/simple/detail.html +++ b/warehouse/templates/api/simple/detail.html @@ -23,7 +23,7 @@

Links for {{ name }}

{% for file in files -%} - {{ file.filename }}
+ {{ file.filename }}
{% endfor -%}