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