diff --git a/tests/unit/packaging/test_models.py b/tests/unit/packaging/test_models.py index aac3cc37433b..ef99bf6d5963 100644 --- a/tests/unit/packaging/test_models.py +++ b/tests/unit/packaging/test_models.py @@ -11,7 +11,9 @@ # limitations under the License. from collections import OrderedDict +from datetime import datetime, timedelta +import freezegun import pretend import pytest @@ -479,6 +481,47 @@ def test_deletion_macaroon_with_macaroon_warning( == 0 ) + def test_in_deletion_window(self, db_session): + project = DBProjectFactory.create() + + # Empty project, trivially deletable. + assert project.in_deletion_window + + fake_now = datetime(year=2000, month=1, day=1) + + # Releases are all deletable, so the project is deletable. + release1 = DBReleaseFactory.create(project=project) + DBFileFactory.create( + release=release1, upload_time=fake_now, packagetype="bdist_wheel" + ) + DBFileFactory.create( + release=release1, + upload_time=fake_now - timedelta(hours=1), + packagetype="bdist_wheel", + ) + + release2 = DBReleaseFactory.create(project=project) + DBFileFactory.create( + release=release2, + upload_time=fake_now - timedelta(days=1), + packagetype="bdist_wheel", + ) + DBFileFactory.create( + release=release2, + upload_time=fake_now - timedelta(days=2), + packagetype="bdist_wheel", + ) + + with freezegun.freeze_time(fake_now): + assert project.in_deletion_window + + # One release is not deletable, so the entire project is not deletable. + release3 = DBReleaseFactory.create(project=project) + DBFileFactory.create(release=release3, upload_time=fake_now - timedelta(days=4)) + with freezegun.freeze_time(fake_now): + assert not release3.in_deletion_window + assert not project.in_deletion_window + class TestDependency: def test_repr(self, db_session): @@ -1122,6 +1165,43 @@ def test_description_relationship(self, db_request): assert release in db_request.db.deleted assert description in db_request.db.deleted + def test_in_deletion_window(self, db_session): + project = DBProjectFactory.create() + release = DBReleaseFactory.create(project=project) + + # No files, trivially deletable. + assert release.in_deletion_window + + fake_now = datetime(year=2000, month=1, day=1) + + DBFileFactory.create( + release=release, upload_time=fake_now, packagetype="bdist_wheel" + ) + DBFileFactory.create( + release=release, + upload_time=fake_now - timedelta(hours=1), + packagetype="bdist_wheel", + ) + DBFileFactory.create( + release=release, + upload_time=fake_now - timedelta(days=1, hours=1), + packagetype="bdist_wheel", + ) + + with freezegun.freeze_time(fake_now): + # All files are deletable, so release is deletable. + assert release.in_deletion_window + + DBFileFactory.create( + release=release, + upload_time=fake_now - timedelta(days=3, hours=1), + packagetype="bdist_wheel", + ) + + with freezegun.freeze_time(fake_now): + # One file is not deletable, so the entire release is not deletable. + assert not release.in_deletion_window + class TestFile: def test_requires_python(self, db_session): @@ -1250,3 +1330,40 @@ def test_pretty_wheel_tags(self, db_session): ) assert rfile.pretty_wheel_tags == ["Source"] + + @pytest.mark.parametrize( + ("upload_time", "is_prerelease", "deletable"), + [ + # Deletable at the instant of upload + (datetime(year=2000, month=1, day=1), False, True), + # Deletable within the normal period + (datetime(year=2000, month=1, day=1) - timedelta(hours=1), False, True), + (datetime(year=2000, month=1, day=1) - timedelta(days=1), False, True), + (datetime(year=2000, month=1, day=1) - timedelta(days=2), False, True), + # Not deletable if uploaded 72 hours after the upload time + # PEP-753 specifies deletion period is less than 72 hours + (datetime(year=2000, month=1, day=1) - timedelta(days=3), False, False), + # Deletable when inexplicably uploaded in the future + (datetime(year=2000, month=1, day=1) + timedelta(hours=1), False, True), + # Not deletable outside of the deletion period + ( + datetime(year=2000, month=1, day=1) - timedelta(days=3, seconds=1), + False, + False, + ), + (datetime(year=2000, month=1, day=1) - timedelta(days=4), False, False), + # Deletable when it's a prerelease and outside the deletion period + (datetime(year=2000, month=1, day=1) - timedelta(days=4), True, False), + ], + ) + def test_in_deletion_window( + self, db_session, upload_time, is_prerelease, deletable + ): + project = DBProjectFactory.create() + release = DBReleaseFactory.create(project=project, is_prerelease=is_prerelease) + + fake_now = datetime(year=2000, month=1, day=1) + + with freezegun.freeze_time(fake_now): + file = DBFileFactory.create(release=release, upload_time=upload_time) + assert file.in_deletion_window == deletable diff --git a/warehouse/packaging/models.py b/warehouse/packaging/models.py index 2e4eb05fb2f7..006ee2dca322 100644 --- a/warehouse/packaging/models.py +++ b/warehouse/packaging/models.py @@ -15,6 +15,7 @@ import typing from collections import OrderedDict +from datetime import datetime, timedelta from uuid import UUID import packaging.utils @@ -459,6 +460,17 @@ def latest_version(self): .first() ) + @property + def in_deletion_window(self) -> bool: + """ + A project can be deleted by a non-admin owner if it's within its + "deletion window," i.e. each of its releases and constituent files + are within their respective deletion windows. + + See `Release.in_deletion_window`. + """ + return all(release.in_deletion_window for release in self.releases) + class DependencyKind(enum.IntEnum): requires = 1 @@ -824,6 +836,18 @@ def trusted_published(self) -> bool: return False return all(file.uploaded_via_trusted_publisher for file in files) + @property + def in_deletion_window(self) -> bool: + """ + A release can be deleted by a non-admin owner if its within its + "deletion window," i.e. each of its files is within its respective + deletion window. + + See `File.in_deletion_window`. + """ + files = self.files.all() # type: ignore[attr-defined] + return all(file.in_deletion_window for file in files) + class PackageType(str, enum.Enum): bdist_dmg = "bdist_dmg" @@ -939,6 +963,18 @@ def validates_requires_python(self, *args, **kwargs): def pretty_wheel_tags(self) -> list[str]: return wheel.filename_to_pretty_tags(self.filename) + @property + def in_deletion_window(self) -> bool: + """ + A file can be deleted by a non-admin owner if it's within its + "deletion window," i.e. was uploaded less than 72 hours ago OR + it has a pre-release version specifier. + """ + return ( + self.release.is_prerelease + or self.upload_time > datetime.now() - timedelta(hours=72) + ) + class Filename(db.ModelBase): __tablename__ = "file_registry"