You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@tooling.apache.org by ar...@apache.org on 2026/04/20 16:30:56 UTC

(tooling-trusted-releases) 02/02: #510 - Tests for archived projects, and restrictions on actions *on* archived projects.

This is an automated email from the ASF dual-hosted git repository.

alitheg pushed a commit to branch project_archival
in repository https://gitbox.apache.org/repos/asf/tooling-trusted-releases.git

commit 5f40e55d412e8194323265657bb68c261d9c6a0e
Author: Alastair McFarlane <ar...@apache.org>
AuthorDate: Mon Apr 20 17:30:39 2026 +0100

    #510 - Tests for archived projects, and restrictions on actions *on* archived projects.
---
 atr/api/__init__.py                           | 49 +++++++++++++++++++++++++++
 atr/get/announce.py                           |  3 ++
 atr/get/compose.py                            |  4 +++
 atr/get/finish.py                             |  3 ++
 atr/get/vote.py                               |  2 ++
 atr/get/voting.py                             |  3 ++
 atr/shared/web.py                             |  9 +++++
 atr/storage/writers/announce.py               |  1 +
 atr/storage/writers/checks.py                 |  2 ++
 atr/storage/writers/distributions.py          | 16 +++++++++
 atr/storage/writers/release.py                |  6 ++++
 atr/storage/writers/revision.py               |  5 +++
 atr/storage/writers/sbom.py                   | 16 +++++++++
 atr/storage/writers/vote.py                   |  6 ++++
 atr/templates/check-selected.html             |  4 +++
 atr/util.py                                   | 10 ++++++
 playwright/test.py                            | 46 +++++++++++++++++++++++++
 tests/e2e/tokens/helpers.py                   |  2 +-
 tests/unit/test_create_revision.py            |  4 +++
 tests/unit/test_create_revision_quarantine.py |  6 ++++
 tests/unit/test_quarantine_task.py            |  8 +++++
 tests/unit/test_vote_recipients.py            |  2 ++
 tests/unit/test_vote_resolution.py            |  9 +++--
 23 files changed, 212 insertions(+), 4 deletions(-)

diff --git a/atr/api/__init__.py b/atr/api/__init__.py
index 86d81a3c..2a7feef7 100644
--- a/atr/api/__init__.py
+++ b/atr/api/__init__.py
@@ -21,6 +21,7 @@ from typing import Any, Final, Literal
 import aiofiles.os
 import asfquart.base as base
 import pgpy
+import pydantic
 import quart
 import quart_rate_limiter as rate_limiter
 import quart_schema
@@ -1664,6 +1665,54 @@ async def vote_tabulate(
     ).model_dump(mode="json"), 200
 
 
+class _TestProjectStatusArgs(pydantic.BaseModel):
+    project_key: safe.ProjectKey
+
+
+@api.typed
+async def test_activate_project(
+    _test_activate_project: Literal["test/activate-project"],
+    data: _TestProjectStatusArgs,
+) -> DictResponse:
+    """
+    URL: POST /test/activate-project
+
+    Restore a project to ACTIVE status (test mode only).
+    """
+    if not config.is_test_mode():
+        return quart.abort(404)
+    async with db.session() as session:
+        project = await session.project(key=str(data.project_key)).get()
+        if not project:
+            return quart.abort(404)
+        project = await session.merge(project)
+        project.status = sql.ProjectStatus.ACTIVE
+        await session.commit()
+    return {"ok": True, "project_key": str(data.project_key)}, 200
+
+
+@api.typed
+async def test_archive_project(
+    _test_archive_project: Literal["test/archive-project"],
+    data: _TestProjectStatusArgs,
+) -> DictResponse:
+    """
+    URL: POST /test/archive-project
+
+    Set a project to RETIRED status (test mode only).
+    """
+    if not config.is_test_mode():
+        return quart.abort(404)
+    async with db.session() as session:
+        project = await session.project(key=str(data.project_key)).get()
+        if not project:
+            return quart.abort(404)
+        project = await session.merge(project)
+        project.status = sql.ProjectStatus.RETIRED
+        await session.commit()
+    return {"ok": True, "project_key": str(data.project_key)}, 200
+
+
 def _committee_keys_path(committee: sql.Committee) -> safe.StatePath:
     downloads_dir = paths.get_downloads_dir()
     if committee.is_podling:
diff --git a/atr/get/announce.py b/atr/get/announce.py
index e3614862..93801506 100644
--- a/atr/get/announce.py
+++ b/atr/get/announce.py
@@ -191,6 +191,9 @@ async def _render_page(
     page.append(_render_release_card(release))
     page.h2["Announce this release"]
 
+    if banner := shared.web.archived_project_banner(release.project):
+        page.append(banner)
+
     announce_msg = ""
     policy = release.release_policy or release.project.release_policy
     if policy and policy.file_tag_mappings:
diff --git a/atr/get/compose.py b/atr/get/compose.py
index 79d5c111..3b09bc6d 100644
--- a/atr/get/compose.py
+++ b/atr/get/compose.py
@@ -196,11 +196,15 @@ async def selected(
         )[""],
     ]
 
+    archived_banner = shared.web.archived_project_banner(release.project)
+    archived_banner_html = str(archived_banner) if archived_banner is not None else ""
+
     return await template.render(
         "check-selected.html",
         project_key=release.project.key,
         version_key=release.version,
         release=release,
+        archived_banner_html=archived_banner_html,
         paths=all_paths,
         info=info,
         revision_editor=revision_editor,
diff --git a/atr/get/finish.py b/atr/get/finish.py
index c90ee0fd..1924e013 100644
--- a/atr/get/finish.py
+++ b/atr/get/finish.py
@@ -311,6 +311,9 @@ async def _render_page(
         htm.em[release.version],
     ]
 
+    if banner := shared.web.archived_project_banner(release.project):
+        page.append(banner)
+
     # Release info card
     page.append(_render_release_card(release, announce_disable_message))
 
diff --git a/atr/get/vote.py b/atr/get/vote.py
index 8e23724b..437e3783 100644
--- a/atr/get/vote.py
+++ b/atr/get/vote.py
@@ -599,6 +599,8 @@ async def _render_vote_authenticated(
         version_key=release.version,
     )
     vote_comment_template = release.project.policy_vote_comment_template
+    if banner := shared.web.archived_project_banner(release.project):
+        page.append(banner)
     cast_vote_form = await form.render(
         model_cls=shared.vote.CastVoteForm,
         action=vote_action_url,
diff --git a/atr/get/voting.py b/atr/get/voting.py
index 81f507e4..f315be96 100644
--- a/atr/get/voting.py
+++ b/atr/get/voting.py
@@ -177,6 +177,9 @@ async def _render_page(
         htm.em[release.version],
     ]
 
+    if banner := shared.web.archived_project_banner(release.project):
+        page.append(banner)
+
     page.div(".px-3.py-4.mb-4.bg-light.border.rounded")[
         htm.p(".mb-0")[
             "Starting a vote for this draft release will cause an email to be sent to the appropriate mailing list, "
diff --git a/atr/shared/web.py b/atr/shared/web.py
index 809b227c..dddf2922 100644
--- a/atr/shared/web.py
+++ b/atr/shared/web.py
@@ -24,6 +24,15 @@ import atr.storage.types as types
 import atr.util as util
 
 
+def archived_project_banner(project: sql.Project) -> htm.Element | None:
+    # Shown on the five top-level release pages when the PMC has archived the
+    # project. The storage layer still refuses the write, but the banner tells
+    # the viewer why the buttons are inert.
+    if project.status != sql.ProjectStatus.RETIRED:
+        return None
+    return htm.div(".alert.alert-warning.mb-4")["This project is archived. Release actions are disabled."]
+
+
 def render_checks_summary(
     info: types.PathInfo | None, project_key: safe.ProjectKey, version_key: safe.VersionKey
 ) -> htm.Element | None:
diff --git a/atr/storage/writers/announce.py b/atr/storage/writers/announce.py
index 26e36b4c..2cdc32bb 100644
--- a/atr/storage/writers/announce.py
+++ b/atr/storage/writers/announce.py
@@ -152,6 +152,7 @@ class CommitteeMember(CommitteeParticipant):
                 f"Release {project_key!s} {version_key!s} {preview_revision_number!s} does not exist",
             )
         )
+        util.ensure_project_active(release.project)
         if (committee := release.project.committee) is None:
             raise storage.AccessError("Release has no committee - Invalid state")
 
diff --git a/atr/storage/writers/checks.py b/atr/storage/writers/checks.py
index 09c8d456..0f24c0c6 100644
--- a/atr/storage/writers/checks.py
+++ b/atr/storage/writers/checks.py
@@ -27,6 +27,7 @@ import atr.models.safe as safe
 import atr.models.sql as sql
 import atr.models.validation as validation
 import atr.storage as storage
+import atr.util as util
 
 
 class GeneralPublic:
@@ -185,6 +186,7 @@ class CommitteeMember(CommitteeParticipant):
             raise storage.AccessError(f"Project {project_key} not found")
         if project.committee_key != self.__committee_key:
             raise storage.AccessError(f"Project {project_key} is not in committee {self.__committee_key}")
+        util.ensure_project_active(project)
 
 
 def _validate_ignore_patterns(*patterns: str | None) -> None:
diff --git a/atr/storage/writers/distributions.py b/atr/storage/writers/distributions.py
index a7eef55c..7b85b4e7 100644
--- a/atr/storage/writers/distributions.py
+++ b/atr/storage/writers/distributions.py
@@ -106,6 +106,10 @@ class CommitteeMember(CommitteeParticipant):
         version: models.safe.VersionKey,
         staging: bool,
     ) -> models.sql.Task:
+        project = await self.__data.project(key=str(project_key)).demand(
+            storage.AccessError(f"Project '{project_key}' not found.")
+        )
+        util.ensure_project_active(project)
         dist_task = models.sql.Task(
             task_type=models.sql.TaskType.DISTRIBUTION_WORKFLOW,
             task_args=args.DistributionWorkflow(
@@ -153,6 +157,10 @@ class CommitteeMember(CommitteeParticipant):
         api_url: str | None = None,
         web_url: str | None = None,
     ) -> tuple[models.sql.Distribution, bool]:
+        release = await self.__data.release(key=str(release_key), _project=True).demand(
+            storage.AccessError(f"Release '{release_key}' not found.")
+        )
+        util.ensure_project_active(release.project)
         namespace = str(owner_namespace) if owner_namespace else ""
         existing = await self.__data.distribution(
             str(release_key), platform, namespace, str(package), str(version)
@@ -220,6 +228,10 @@ class CommitteeMember(CommitteeParticipant):
         dd: models.distribution.Data,
         allow_retries: bool = False,
     ) -> tuple[models.sql.Distribution, bool, models.distribution.Metadata | None]:
+        release = await self.__data.release(key=str(release_key), _project=True).demand(
+            storage.AccessError(f"Release '{release_key}' not found.")
+        )
+        util.ensure_project_active(release.project)
         api_url = distribution.get_api_url(dd, staging)
         if dd.platform == models.sql.DistributionPlatform.MAVEN:
             api_oc = await distribution.json_from_maven_xml(api_url, dd.version)
@@ -308,6 +320,10 @@ class CommitteeMember(CommitteeParticipant):
         package: str,
         version: str,
     ) -> None:
+        release = await self.__data.release(key=str(release_key), _project=True).demand(
+            storage.AccessError(f"Release '{release_key}' not found.")
+        )
+        util.ensure_project_active(release.project)
         distribution = await self.__data.distribution(
             release_key=str(release_key),
             platform=platform,
diff --git a/atr/storage/writers/release.py b/atr/storage/writers/release.py
index d26245e4..3aebdbbc 100644
--- a/atr/storage/writers/release.py
+++ b/atr/storage/writers/release.py
@@ -104,6 +104,7 @@ class CommitteeParticipant(FoundationCommitter):
         release = await self.__data.release(
             project_key=str(project_key), version=str(version), phase=phase, _committee=True
         ).demand(storage.AccessError(f"Release '{project_key!s} {version!s}' not found."))
+        util.ensure_project_active(release.project)
         release_dirs = [
             paths.release_directory_base(release),
             paths.get_attestable_dir() / str(project_key) / str(version),
@@ -292,6 +293,10 @@ class CommitteeParticipant(FoundationCommitter):
         svn_revision: str,
         target_subdirectory: safe.RelPath | None,
     ) -> sql.Task:
+        project = await self.__data.project(key=str(project_key)).demand(
+            storage.AccessError(f"Project '{project_key}' not found.")
+        )
+        util.ensure_project_active(project)
         task_args = {
             "svn_url": svn_url,
             "revision": svn_revision,
@@ -357,6 +362,7 @@ class CommitteeParticipant(FoundationCommitter):
         release_for_pre_checks = await self.__data.release(key=str(release_key), _project=True).demand(
             storage.AccessError("Release candidate draft not found")
         )
+        util.ensure_project_active(release_for_pre_checks.project)
         project_key = release_for_pre_checks.safe_project_key
         version_key = release_for_pre_checks.safe_version_key
         revision_number = release_for_pre_checks.safe_latest_revision_number
diff --git a/atr/storage/writers/revision.py b/atr/storage/writers/revision.py
index 730dafad..6bfafbe7 100644
--- a/atr/storage/writers/revision.py
+++ b/atr/storage/writers/revision.py
@@ -356,6 +356,10 @@ class CommitteeParticipant(FoundationCommitter):
         version_key: safe.VersionKey,
         quarantined_id: int,
     ) -> None:
+        project = await self.__data.project(key=str(project_key)).demand(
+            storage.AccessError(f"Project '{project_key}' not found.")
+        )
+        util.ensure_project_active(project)
         release_key = sql.release_key(str(project_key), str(version_key))
         quarantined = await self.__data.quarantined(
             id=quarantined_id, release_key=release_key, status=sql.QuarantineStatus.FAILED
@@ -390,6 +394,7 @@ class CommitteeParticipant(FoundationCommitter):
             release = await data.release(key=release_key, _release_policy=True, _project_release_policy=True).demand(
                 RuntimeError("Release does not exist for new revision creation")
             )
+            util.ensure_project_active(release.project)
             if release.phase not in allowed_phases:
                 raise types.PhaseMismatchError(
                     f"Cannot create revision: release phase is {release.phase.value}, "
diff --git a/atr/storage/writers/sbom.py b/atr/storage/writers/sbom.py
index 0e2e16ed..14cb7920 100644
--- a/atr/storage/writers/sbom.py
+++ b/atr/storage/writers/sbom.py
@@ -79,6 +79,10 @@ class CommitteeParticipant(FoundationCommitter):
         revision_number: safe.RevisionNumber,
         rel_path: safe.RelPath,
     ) -> sql.Task:
+        project = await self.__data.project(key=str(project_key)).demand(
+            storage.AccessError(f"Project '{project_key}' not found.")
+        )
+        util.ensure_project_active(project)
         sbom_task = sql.Task(
             task_type=sql.TaskType.SBOM_AUGMENT,
             task_args=args.FileArgs(
@@ -109,6 +113,10 @@ class CommitteeParticipant(FoundationCommitter):
         file_path: safe.StatePath,
         sbom_path: safe.StatePath,
     ) -> sql.Task:
+        project = await self.__data.project(key=str(project_key)).demand(
+            storage.AccessError(f"Project '{project_key}' not found.")
+        )
+        util.ensure_project_active(project)
         sbom_task = sql.Task(
             task_type=sql.TaskType.SBOM_CONVERT,
             task_args=args.ConvertCycloneDX(
@@ -136,6 +144,10 @@ class CommitteeParticipant(FoundationCommitter):
         path_in_new_revision: safe.StatePath,
         sbom_path_in_new_revision: safe.StatePath,
     ) -> sql.Task:
+        project = await self.__data.project(key=str(project_key)).demand(
+            storage.AccessError(f"Project '{project_key}' not found.")
+        )
+        util.ensure_project_active(project)
         # Create and queue the task, using paths within the new revision
         # We still need release.name for the task metadata
         artifact_path = await asyncio.to_thread(_resolved_path_str, path_in_new_revision)
@@ -165,6 +177,10 @@ class CommitteeParticipant(FoundationCommitter):
         revision_number: safe.RevisionNumber,
         rel_path: safe.RelPath,
     ) -> sql.Task:
+        project = await self.__data.project(key=str(project_key)).demand(
+            storage.AccessError(f"Project '{project_key}' not found.")
+        )
+        util.ensure_project_active(project)
         sbom_task = sql.Task(
             task_type=sql.TaskType.SBOM_OSV_SCAN,
             task_args=args.FileArgs(
diff --git a/atr/storage/writers/vote.py b/atr/storage/writers/vote.py
index c72aa02a..3735c77a 100644
--- a/atr/storage/writers/vote.py
+++ b/atr/storage/writers/vote.py
@@ -87,6 +87,7 @@ class CommitteeParticipant(FoundationCommitter):
         fullname: str,
         is_binding: bool = False,
     ) -> tuple[list[str], str]:
+        util.ensure_project_active(release.project)
         # Get the email thread
         latest_vote_task = await interaction.release_latest_vote_task(release)
         if latest_vote_task is None:
@@ -166,6 +167,7 @@ class CommitteeParticipant(FoundationCommitter):
                 _project=True,
                 _committee=True,
             ).demand(storage.AccessError("Release not found"))
+        util.ensure_project_active(release.project)
         if release.committee is None:
             raise storage.AccessError("Release has no committee")
         if permitted_recipients is None:
@@ -270,6 +272,7 @@ class CommitteeMember(CommitteeParticipant):
             _project=True,
             _committee=True,
         ).demand(storage.AccessError("Release not found"))
+        util.ensure_project_active(release.project)
 
         is_podling = False
         if release.project.committee is not None:
@@ -317,6 +320,7 @@ class CommitteeMember(CommitteeParticipant):
             _project=True,
             _committee=True,
         ).demand(storage.AccessError("Release not found"))
+        util.ensure_project_active(release.project)
 
         if not release.vote_manual:
             raise ValueError("Release is not configured for manual voting")
@@ -383,6 +387,7 @@ class CommitteeMember(CommitteeParticipant):
     ) -> tuple[sql.Release, int | None, str, str | None]:
         # Attach the existing release to the session
         release = await self.__data.merge(release)
+        util.ensure_project_active(release.project)
         # Update the release phase based on vote result
         extra_destination = None
         if (voting_round == 1) and (vote_result == "passed"):
@@ -515,6 +520,7 @@ class CommitteeMember(CommitteeParticipant):
         latest_vote_task: sql.Task,
         extra_destination: tuple[str, str] | None = None,
     ) -> str | None:
+        util.ensure_project_active(release.project)
         # Get the email thread
         vote_thread_mid = interaction.task_mid_get(latest_vote_task)
         if vote_thread_mid is None:
diff --git a/atr/templates/check-selected.html b/atr/templates/check-selected.html
index f27aebc1..a1ba4c0a 100644
--- a/atr/templates/check-selected.html
+++ b/atr/templates/check-selected.html
@@ -79,6 +79,10 @@
     </p>
   {% endif %}
 
+  {% if archived_banner_html %}
+    {{ archived_banner_html|safe }}
+  {% endif %}
+
   {% include "check-selected-release-info.html" %}
 
   {% if quarantined_pending %}
diff --git a/atr/util.py b/atr/util.py
index 3304fcd3..b36e09f5 100644
--- a/atr/util.py
+++ b/atr/util.py
@@ -359,6 +359,16 @@ def email_from_uid(uid: str) -> str | None:
     return None
 
 
+def ensure_project_active(project: sql.Project) -> None:
+    # Fail closed on any write that targets a non-active project. Lazy import of
+    # storage keeps util importable by storage writers without a cycle.
+    if project.status == sql.ProjectStatus.ACTIVE:
+        return
+    import atr.storage as storage
+
+    raise storage.AccessError(f"Project '{project.key}' is archived; release actions are disabled.")
+
+
 async def email_mid_from_thread_id(thread_id: str) -> tuple[str, str]:
     async for _thread_id, msg_json in thread_messages(thread_id):
         # The .get("to", "") value may be redacted, e.g. "us...@tooling.apache.org"
diff --git a/playwright/test.py b/playwright/test.py
index 75b9da53..a0d1516a 100755
--- a/playwright/test.py
+++ b/playwright/test.py
@@ -562,6 +562,12 @@ def test_all(page: Page, credentials: Credentials, skip_slow: bool) -> None:
         test_session_01_banned_user_is_rejected,
         test_session_02_recheck_allows_active_user,
     ]
+    tests["archive"] = [
+        test_archive_01_banner_absent_when_active,
+        test_archive_02_archive_project,
+        test_archive_03_banner_visible_on_compose,
+        test_archive_04_activate_project,
+    ]
 
     # Order between our tests must be preserved
     # Insertion order is reliable since Python 3.6
@@ -573,6 +579,46 @@ def test_all(page: Page, credentials: Credentials, skip_slow: bool) -> None:
     logging.info(f"Tests took {round(finish - start, 3)} seconds")
 
 
+def test_archive_01_banner_absent_when_active(page: Page, credentials: Credentials) -> None:
+    version_key = "0.2"
+    logging.info(f"Verifying no archived banner on /compose/{TEST_PROJECT}/{version_key}")
+    go_to_path(page, f"/compose/{TEST_PROJECT}/{version_key}")
+    banner_locator = page.locator(".alert-warning:has-text('archived')")
+    expect(banner_locator).not_to_be_visible()
+    logging.info("No archived banner present — project is active")
+
+
+def test_archive_02_archive_project(page: Page, credentials: Credentials) -> None:
+    logging.info(f"Archiving project {TEST_PROJECT} via admin API")
+    response = page.request.post(
+        f"{ATR_BASE_URL}/api/test/archive-project",
+        data={"project_key": TEST_PROJECT},
+    )
+    if not response.ok:
+        raise RuntimeError(f"Archive request failed with status {response.status}: {response.text()}")
+    logging.info(f"Project {TEST_PROJECT} archived successfully")
+
+
+def test_archive_03_banner_visible_on_compose(page: Page, credentials: Credentials) -> None:
+    version_key = "0.2"
+    logging.info(f"Verifying archived banner on /compose/{TEST_PROJECT}/{version_key}")
+    go_to_path(page, f"/compose/{TEST_PROJECT}/{version_key}")
+    banner_locator = page.locator(".alert-warning:has-text('archived')")
+    expect(banner_locator).to_be_visible()
+    logging.info("Archived banner is visible on compose page")
+
+
+def test_archive_04_activate_project(page: Page, credentials: Credentials) -> None:
+    logging.info(f"Restoring project {TEST_PROJECT} to active state via admin API")
+    response = page.request.post(
+        f"{ATR_BASE_URL}/api/test/activate-project",
+        data={"project_key": TEST_PROJECT},
+    )
+    if not response.ok:
+        raise RuntimeError(f"Activate request failed with status {response.status}: {response.text()}")
+    logging.info(f"Project {TEST_PROJECT} activated successfully")
+
+
 def test_checks_01_hashing_sha512(page: Page, credentials: Credentials) -> None:
     project_key = TEST_PROJECT
     version_key = "0.2"
diff --git a/tests/e2e/tokens/helpers.py b/tests/e2e/tokens/helpers.py
index a221b69f..eab7fcdb 100644
--- a/tests/e2e/tokens/helpers.py
+++ b/tests/e2e/tokens/helpers.py
@@ -26,7 +26,7 @@ TOKENS_PATH: Final[str] = "/tokens"
 def delete_token_by_label(page: Page, label: str) -> None:
     row = get_token_row_by_label(page, label)
     if row.count() > 0:
-        row.get_by_role("button", name="Delete").click()
+        row.get_by_role("button", name="Revoke").click()
         page.wait_for_load_state()
 
 
diff --git a/tests/unit/test_create_revision.py b/tests/unit/test_create_revision.py
index 864111dd..8a7090e9 100644
--- a/tests/unit/test_create_revision.py
+++ b/tests/unit/test_create_revision.py
@@ -110,6 +110,7 @@ async def test_clone_from_older_revision_skips_merge_without_intervening_change(
     release = mock.MagicMock()
     release.phase = sql.ReleasePhase.RELEASE_PREVIEW
     release.project = mock.MagicMock()
+    release.project.status = sql.ProjectStatus.ACTIVE
     release.project.release_policy = None
     release.release_policy = None
     release_key = sql.release_key("proj", "1.0")
@@ -203,6 +204,7 @@ async def test_intervening_revision_triggers_merge_and_uses_latest_parent(tmp_pa
     release = mock.MagicMock()
     release.phase = sql.ReleasePhase.RELEASE_PREVIEW
     release.project = mock.MagicMock()
+    release.project.status = sql.ProjectStatus.ACTIVE
     release.project.release_policy = None
     release.release_policy = None
     release_key = sql.release_key("proj", "1.0")
@@ -285,6 +287,7 @@ async def test_modify_failed_error_propagates_and_cleans_up(tmp_path: pathlib.Pa
 
     release = mock.MagicMock()
     release.phase = sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT
+    release.project.status = sql.ProjectStatus.ACTIVE
     release.project.key = "proj"
     release.version = "1.0"
     release.latest_revision_number = "00001"
@@ -316,6 +319,7 @@ async def test_v1_previous_attestable_suppresses_file_state_rows(tmp_path: pathl
     release = mock.MagicMock()
     release.phase = sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT
     release.project = mock.MagicMock()
+    release.project.status = sql.ProjectStatus.ACTIVE
     release.project.release_policy = None
     release.release_policy = None
     release_key = sql.release_key("proj", "1.0")
diff --git a/tests/unit/test_create_revision_quarantine.py b/tests/unit/test_create_revision_quarantine.py
index af420f1f..33de17a4 100644
--- a/tests/unit/test_create_revision_quarantine.py
+++ b/tests/unit/test_create_revision_quarantine.py
@@ -116,6 +116,7 @@ async def test_no_quarantine_returns_revision_when_no_archives(tmp_path: pathlib
     release = mock.MagicMock()
     release.phase = sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT
     release.project = mock.MagicMock()
+    release.project.status = sql.ProjectStatus.ACTIVE
     release.project.release_policy = None
     release.project.key = "proj"
     release.version = "1.0"
@@ -172,6 +173,7 @@ async def test_phase_gate_allows_matching_phase(tmp_path: pathlib.Path):
     release = mock.MagicMock()
     release.phase = sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT
     release.project = mock.MagicMock()
+    release.project.status = sql.ProjectStatus.ACTIVE
     release.project.release_policy = None
     release.project.key = "proj"
     release.version = "1.0"
@@ -225,6 +227,7 @@ async def test_phase_gate_allows_matching_phase(tmp_path: pathlib.Path):
 async def test_phase_gate_rejects_mismatched_phase():
     release = mock.MagicMock()
     release.phase = sql.ReleasePhase.RELEASE_CANDIDATE
+    release.project.status = sql.ProjectStatus.ACTIVE
 
     mock_session = _mock_db_session(release)
     participant = _make_participant()
@@ -248,6 +251,7 @@ async def test_quarantine_branch_returns_quarantined_when_archives_detected(tmp_
     release = mock.MagicMock()
     release.phase = sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT
     release.project = mock.MagicMock()
+    release.project.status = sql.ProjectStatus.ACTIVE
     release.project.key = "proj"
     release.version = "1.0"
     release.key = sql.release_key("proj", "1.0")
@@ -326,6 +330,7 @@ async def test_quarantine_dedup_applied_to_task_args(tmp_path: pathlib.Path):
     release = mock.MagicMock()
     release.phase = sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT
     release.project = mock.MagicMock()
+    release.project.status = sql.ProjectStatus.ACTIVE
     release.project.key = "proj"
     release.version = "1.0"
     release.key = sql.release_key("proj", "1.0")
@@ -413,6 +418,7 @@ async def test_quarantine_stores_prior_revision_key_from_lock(tmp_path: pathlib.
     release = mock.MagicMock()
     release.phase = sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT
     release.project = mock.MagicMock()
+    release.project.status = sql.ProjectStatus.ACTIVE
     release.key = sql.release_key("proj", "1.0")
 
     old_revision = mock.MagicMock()
diff --git a/tests/unit/test_quarantine_task.py b/tests/unit/test_quarantine_task.py
index 80cafeba..1f1a53cf 100644
--- a/tests/unit/test_quarantine_task.py
+++ b/tests/unit/test_quarantine_task.py
@@ -38,6 +38,10 @@ import atr.tasks.quarantine as quarantine
 @pytest.mark.asyncio
 async def test_clear_quarantine_raises_when_not_found():
     mock_data = mock.AsyncMock()
+    active_project = mock.MagicMock(status=sql.ProjectStatus.ACTIVE)
+    active_project_query = mock.MagicMock()
+    active_project_query.demand = mock.AsyncMock(return_value=active_project)
+    mock_data.project = mock.MagicMock(return_value=active_project_query)
     mock_query = mock.MagicMock()
     mock_query.get = mock.AsyncMock(return_value=None)
     mock_data.quarantined = mock.MagicMock(return_value=mock_query)
@@ -56,6 +60,10 @@ async def test_clear_quarantine_transitions_failed_to_acknowledged():
     quarantined_row.status = sql.QuarantineStatus.FAILED
 
     mock_data = mock.AsyncMock()
+    active_project = mock.MagicMock(status=sql.ProjectStatus.ACTIVE)
+    active_project_query = mock.MagicMock()
+    active_project_query.demand = mock.AsyncMock(return_value=active_project)
+    mock_data.project = mock.MagicMock(return_value=active_project_query)
     mock_query = mock.MagicMock()
     mock_query.get = mock.AsyncMock(return_value=quarantined_row)
     mock_data.quarantined = mock.MagicMock(return_value=mock_query)
diff --git a/tests/unit/test_vote_recipients.py b/tests/unit/test_vote_recipients.py
index acb960b0..12664bed 100644
--- a/tests/unit/test_vote_recipients.py
+++ b/tests/unit/test_vote_recipients.py
@@ -25,6 +25,7 @@ import atr.db.interaction as interaction
 import atr.models.args as args
 import atr.models.results as results
 import atr.models.safe as safe
+import atr.models.sql as sql
 import atr.storage.writers.vote as vote
 import atr.tasks.vote
 import atr.util as util
@@ -98,6 +99,7 @@ async def test_send_resolution_reuses_original_vote_recipients() -> None:
     release = SimpleNamespace(
         project=SimpleNamespace(
             key="project",
+            status=sql.ProjectStatus.ACTIVE,
             display_name="Project",
         ),
         version="1.0.0",
diff --git a/tests/unit/test_vote_resolution.py b/tests/unit/test_vote_resolution.py
index fdd21c4c..adbd6696 100644
--- a/tests/unit/test_vote_resolution.py
+++ b/tests/unit/test_vote_resolution.py
@@ -59,7 +59,7 @@ def test_automatic_vote_resolve_section_links_to_standard_resolve(monkeypatch: p
     page = htm.Block()
     release = SimpleNamespace(
         vote_manual=False,
-        project=SimpleNamespace(key="project"),
+        project=SimpleNamespace(key="project", status=sql.ProjectStatus.ACTIVE),
         version="1.0.0",
     )
 
@@ -279,7 +279,7 @@ async def test_manual_resolve_page_explains_cancellation_notice_url(
     monkeypatch.setattr(sessions, "form_error_pop", _no_form_errors)
 
     release = SimpleNamespace(
-        project=SimpleNamespace(key="project"),
+        project=SimpleNamespace(key="project", status=sql.ProjectStatus.ACTIVE),
         version="1.0.0",
         short_display_name="Project 1.0.0",
     )
@@ -331,7 +331,7 @@ def test_manual_vote_resolve_section_links_to_manual_resolve(monkeypatch: pytest
     page = htm.Block()
     release = SimpleNamespace(
         vote_manual=True,
-        project=SimpleNamespace(key="project"),
+        project=SimpleNamespace(key="project", status=sql.ProjectStatus.ACTIVE),
         version="1.0.0",
     )
 
@@ -545,6 +545,7 @@ async def test_send_resolution_cancelled_builds_cancelled_subject() -> None:
     release = SimpleNamespace(
         project=SimpleNamespace(
             key="project",
+            status=sql.ProjectStatus.ACTIVE,
             display_name="Project",
         ),
         version="1.0.0",
@@ -635,6 +636,7 @@ def _candidate_release(podling_thread_id: str | None = None) -> SimpleNamespace:
         ),
         project=SimpleNamespace(
             key="project",
+            status=sql.ProjectStatus.ACTIVE,
             display_name="Project",
             short_display_name="Project",
             committee=SimpleNamespace(
@@ -702,6 +704,7 @@ def _manual_candidate_release(podling_thread_id: str | None = None) -> SimpleNam
         safe_version_key="1.0.0",
         project=SimpleNamespace(
             key="project",
+            status=sql.ProjectStatus.ACTIVE,
             display_name="Project",
             committee=SimpleNamespace(
                 key="project",


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@tooling.apache.org
For additional commands, e-mail: commits-help@tooling.apache.org