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

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


The following commit(s) were added to refs/heads/main by this push:
     new 50ce059  Migrate browser tests to a dedicated test committee
50ce059 is described below

commit 50ce0593aa9deea9c03e8173d717b61f3208ab15
Author: Sean B. Palmer <[email protected]>
AuthorDate: Tue Oct 14 09:49:41 2025 +0100

    Migrate browser tests to a dedicated test committee
---
 ...=> 0924BA4B875A1A472D147C1EB11EB02801756B9E.asc |   0
 Makefile                                           |   4 +-
 atr/blueprints/admin/admin.py                      |  31 ++++
 atr/config.py                                      |   4 +
 atr/principal.py                                   |  38 ++++-
 atr/route.py                                       |  17 +-
 atr/routes/projects.py                             |   6 +-
 atr/routes/root.py                                 |  30 +++-
 atr/server.py                                      |  36 ++++
 atr/storage/writers/keys.py                        |  59 ++++++-
 atr/user.py                                        |  14 +-
 .../68FF2E20F02B070D73D416188DE8CC167FE2663A.asc   |  42 +++++
 playwright/apache-test-0.2/apache-test-0.2.tar.gz  | Bin 0 -> 4391 bytes
 .../apache-test-0.2/apache-test-0.2.tar.gz.asc     |   8 +
 .../apache-test-0.2/apache-test-0.2.tar.gz.sha512  |   1 +
 .../apache-tooling-test-example-0.2.tar.gz         | Bin 4432 -> 0 bytes
 .../apache-tooling-test-example-0.2.tar.gz.asc     |   8 -
 .../apache-tooling-test-example-0.2.tar.gz.sha512  |   1 -
 playwright/mk.sh                                   |  11 +-
 playwright/test.py                                 | 182 ++++++++++++---------
 20 files changed, 372 insertions(+), 120 deletions(-)

diff --git a/playwright/0924BA4B875A1A472D147C1EB11EB02801756B9E.asc 
b/0924BA4B875A1A472D147C1EB11EB02801756B9E.asc
similarity index 100%
rename from playwright/0924BA4B875A1A472D147C1EB11EB02801756B9E.asc
rename to 0924BA4B875A1A472D147C1EB11EB02801756B9E.asc
diff --git a/Makefile b/Makefile
index 92e68ca..1462618 100644
--- a/Makefile
+++ b/Makefile
@@ -79,8 +79,8 @@ serve:
          atr.server:app --debug --reload
 
 serve-local:
-       APP_HOST=localhost.apache.org:8080 LOCAL_DEBUG=1 
SECRET_KEY=insecure-local-key \
-         SSH_HOST=127.0.0.1 uv run hypercorn --bind $(BIND) \
+       APP_HOST=localhost.apache.org:8080 SECRET_KEY=insecure-local-key \
+         ALLOW_TESTS=1 SSH_HOST=127.0.0.1 uv run hypercorn --bind $(BIND) \
          --keyfile localhost.apache.org+3-key.pem --certfile 
localhost.apache.org+3.pem \
          atr.server:app --debug --reload
 
diff --git a/atr/blueprints/admin/admin.py b/atr/blueprints/admin/admin.py
index adf81bb..69fe8fd 100644
--- a/atr/blueprints/admin/admin.py
+++ b/atr/blueprints/admin/admin.py
@@ -266,6 +266,37 @@ async def admin_data(model: str = "Committee") -> str:
         )
 
 
[email protected]("/delete-test-openpgp-keys", methods=["GET", "POST"])
+async def admin_delete_test_openpgp_keys() -> quart.Response | 
response.Response:
+    """Delete all test user OpenPGP keys and their links."""
+    if not config.get().ALLOW_TESTS:
+        raise base.ASFQuartException("Test key deletion not enabled", 
errorcode=404)
+
+    test_uid = "test"
+
+    if quart.request.method != "POST":
+        empty_form = await forms.Empty.create_form()
+        return quart.Response(
+            f"""
+<form method="post">
+  <button type="submit">Delete all OpenPGP keys for {test_uid} user</button>
+  {empty_form.hidden_tag()}
+</form>
+""",
+            mimetype="text/html",
+        )
+
+    # This is a POST request
+    await util.validate_empty_form()
+
+    async with storage.write() as write:
+        wafc = write.as_foundation_committer()
+        outcome = await wafc.keys.test_user_delete_all(test_uid)
+        outcome.result_or_raise()
+
+    return quart.redirect("/keys")
+
+
 @admin.BLUEPRINT.route("/delete-committee-keys", methods=["GET", "POST"])
 async def admin_delete_committee_keys() -> str | response.Response:
     form = await DeleteCommitteeKeysForm.create_form()
diff --git a/atr/config.py b/atr/config.py
index ca6b0e5..a80cdea 100644
--- a/atr/config.py
+++ b/atr/config.py
@@ -43,6 +43,7 @@ def _config_secrets(key: str, state_dir: str, default: str | 
None = None, cast:
 
 
 class AppConfig:
+    ALLOW_TESTS = decouple.config("ALLOW_TESTS", default=False, cast=bool)
     APP_HOST = decouple.config("APP_HOST", default="localhost")
     SSH_HOST = decouple.config("SSH_HOST", default="0.0.0.0")
     SSH_PORT = decouple.config("SSH_PORT", default=2222, cast=int)
@@ -133,6 +134,9 @@ def get() -> type[AppConfig]:
     except KeyError:
         exit("Error: Invalid <mode>. Expected values [Debug, Production, 
Profiling].")
 
+    if config.ALLOW_TESTS and (get_mode() != Mode.Debug):
+        raise RuntimeError("ALLOW_TESTS can only be enabled in Debug mode")
+
     absolute_paths = [
         (config.PROJECT_ROOT, "PROJECT_ROOT"),
         (config.STATE_DIR, "STATE_DIR"),
diff --git a/atr/principal.py b/atr/principal.py
index 8ad1d5d..ccfad52 100644
--- a/atr/principal.py
+++ b/atr/principal.py
@@ -250,10 +250,15 @@ class AuthoriserASFQuart:
         if not isinstance(asfquart_session, session.ClientSession):
             # Defense in depth runtime check, already validated by the type 
checker
             raise AuthenticationError("ASFQuart session is not a 
ClientSession")
+
+        committees = frozenset(asfquart_session.committees)
+        projects = frozenset(asfquart_session.projects)
+        committees, projects = _augment_test_membership(committees, projects)
+
         # We do not check that the ASF UID is the same as the one in the 
session
         # It is the caller's responsibility to ensure this
-        self.__cache.member_of[asf_uid] = 
frozenset(asfquart_session.committees)
-        self.__cache.participant_of[asf_uid] = 
frozenset(asfquart_session.projects)
+        self.__cache.member_of[asf_uid] = committees
+        self.__cache.participant_of[asf_uid] = projects
         self.__cache.last_refreshed = int(time.time())
 
 
@@ -279,11 +284,26 @@ class AuthoriserLDAP:
     async def cache_refresh(self, asf_uid: str) -> None:
         if not self.__cache.outdated():
             return
+
+        if config.get().ALLOW_TESTS and (asf_uid == "test"):
+            # The test user does not exist in LDAP, so we hardcode their data
+            committees = frozenset({"test"})
+            projects = frozenset({"test"})
+            self.__cache.member_of[asf_uid] = committees
+            self.__cache.participant_of[asf_uid] = projects
+            self.__cache.last_refreshed = int(time.time())
+            return
+
         try:
             c = Committer(asf_uid)
             await asyncio.to_thread(c.verify)
-            self.__cache.member_of[asf_uid] = frozenset(c.pmcs)
-            self.__cache.participant_of[asf_uid] = frozenset(c.projects)
+
+            committees = frozenset(c.pmcs)
+            projects = frozenset(c.projects)
+            committees, projects = _augment_test_membership(committees, 
projects)
+
+            self.__cache.member_of[asf_uid] = committees
+            self.__cache.participant_of[asf_uid] = projects
             self.__cache.last_refreshed = int(time.time())
         except CommitterError as e:
             raise AuthenticationError(f"Failed to verify committer: {e}") from 
e
@@ -371,3 +391,13 @@ class Authorisation(AsyncObject):
         if self.__asf_uid is None:
             return frozenset()
         return self.__authoriser.member_of(self.__asf_uid)
+
+
+def _augment_test_membership(
+    committees: frozenset[str],
+    projects: frozenset[str],
+) -> tuple[frozenset[str], frozenset[str]]:
+    if config.get().ALLOW_TESTS:
+        committees = committees.union({"test"})
+        projects = projects.union({"test"})
+    return committees, projects
diff --git a/atr/route.py b/atr/route.py
index ab7bac1..9b8ff19 100644
--- a/atr/route.py
+++ b/atr/route.py
@@ -222,11 +222,7 @@ class CommitterSession:
         self, route: CommitterRouteHandler[R], success: str | None = None, 
error: str | None = None, **kwargs: Any
     ) -> response.Response:
         """Redirect to a route with a success or error message."""
-        if success is not None:
-            await quart.flash(success, "success")
-        elif error is not None:
-            await quart.flash(error, "error")
-        return quart.redirect(util.as_url(route, **kwargs))
+        return await redirect(route, success, error, **kwargs)
 
     async def release(
         self,
@@ -510,6 +506,17 @@ def public(
     return decorator
 
 
+async def redirect[R](
+    route: RouteHandler[R], success: str | None = None, error: str | None = 
None, **kwargs: Any
+) -> response.Response:
+    """Redirect to a route with a success or error message."""
+    if success is not None:
+        await quart.flash(success, "success")
+    elif error is not None:
+        await quart.flash(error, "error")
+    return quart.redirect(util.as_url(route, **kwargs))
+
+
 def _authentication_failed() -> NoReturn:
     """Handle authentication failure with an exception."""
     # NOTE: This is a separate function to fix a problem with analysis flow in 
mypy
diff --git a/atr/routes/projects.py b/atr/routes/projects.py
index 80270a7..ccb93c1 100644
--- a/atr/routes/projects.py
+++ b/atr/routes/projects.py
@@ -27,6 +27,7 @@ from typing import TYPE_CHECKING, Any
 import asfquart.base as base
 import quart
 
+import atr.config as config
 import atr.db as db
 import atr.db.interaction as interaction
 import atr.forms as forms
@@ -291,13 +292,16 @@ async def select(session: route.CommitterSession) -> str:
     if session.uid:
         async with db.session() as data:
             # TODO: Move this filtering logic somewhere else
+            # The ALLOW_TESTS line allows test projects to be shown
+            conf = config.get()
             all_projects = await data.project(status=sql.ProjectStatus.ACTIVE, 
_committee=True).all()
             user_projects = [
                 p
                 for p in all_projects
                 if p.committee
                 and (
-                    (session.uid in p.committee.committee_members)
+                    (conf.ALLOW_TESTS and (p.committee.name == "test"))
+                    or (session.uid in p.committee.committee_members)
                     or (session.uid in p.committee.committers)
                     or (session.uid in p.committee.release_managers)
                 )
diff --git a/atr/routes/root.py b/atr/routes/root.py
index 41f8c5e..e5f340d 100644
--- a/atr/routes/root.py
+++ b/atr/routes/root.py
@@ -21,11 +21,13 @@ import pathlib
 from typing import Final
 
 import aiofiles
+import asfquart.base as base
 import asfquart.session
 import htpy
-import quart.wrappers.response as response
+import quart.wrappers.response as quart_response
 import sqlalchemy.orm as orm
 import sqlmodel
+import werkzeug.wrappers.response as response
 
 import atr.config as config
 import atr.db as db
@@ -74,7 +76,7 @@ async def about(session: route.CommitterSession) -> str:
 
 
 @route.public("/")
-async def index(session: route.CommitterSession | None) -> response.Response | 
str:
+async def index(session: route.CommitterSession | None) -> 
quart_response.Response | str:
     """Show public info or an entry portal for participants."""
     session_data = await asfquart.session.read()
     if session_data:
@@ -149,11 +151,11 @@ async def index(session: route.CommitterSession | None) 
-> response.Response | s
 
 
 @route.public("/miscellaneous/resolved.json")
-async def resolved_json(session: route.CommitterSession | None) -> 
response.Response:
+async def resolved_json(session: route.CommitterSession | None) -> 
quart_response.Response:
     json_path = pathlib.Path(config.get().PROJECT_ROOT) / "atr" / "static" / 
"json" / "resolved.json"
     async with aiofiles.open(json_path) as f:
         content = await f.read()
-    return response.Response(content, mimetype="application/json")
+    return quart_response.Response(content, mimetype="application/json")
 
 
 @route.public("/policies")
@@ -161,6 +163,26 @@ async def policies(session: route.CommitterSession | None) 
-> str:
     return await template.blank("Policies", content=_POLICIES)
 
 
[email protected]("/test-login")
+async def test_login(session: route.CommitterSession | None) -> 
response.Response:
+    if not config.get().ALLOW_TESTS:
+        raise base.ASFQuartException("Test login not enabled", errorcode=404)
+
+    session_data = {
+        "uid": "test",
+        "fullname": "Test User",
+        "committees": ["test"],
+        "projects": ["test"],
+        "isMember": False,
+        "isChair": False,
+        "isRole": False,
+        "metadata": {},
+    }
+
+    asfquart.session.write(session_data)
+    return await route.redirect(index)
+
+
 @route.committer("/todo", methods=["POST"])
 async def todo(session: route.CommitterSession) -> str:
     """POST target for development."""
diff --git a/atr/server.py b/atr/server.py
index fa78a06..4a7f79c 100644
--- a/atr/server.py
+++ b/atr/server.py
@@ -19,6 +19,7 @@
 
 import asyncio
 import contextlib
+import datetime
 import os
 import queue
 from collections.abc import Iterable
@@ -44,6 +45,7 @@ import atr.db.interaction as interaction
 import atr.filters as filters
 import atr.log as log
 import atr.manager as manager
+import atr.models.sql as sql
 import atr.preload as preload
 import atr.ssh as ssh
 import atr.svn.pubsub as pubsub
@@ -156,6 +158,8 @@ def app_setup_lifecycle(app: base.QuartApp) -> None:
         worker_manager = manager.get_worker_manager()
         await worker_manager.start()
 
+        await initialise_test_environment()
+
         conf = config.get()
         pubsub_url = conf.PUBSUB_URL
         pubsub_user = conf.PUBSUB_USER
@@ -303,6 +307,38 @@ def create_app(app_config: type[config.AppConfig]) -> 
base.QuartApp:
     return app
 
 
+async def initialise_test_environment() -> None:
+    if not config.get().ALLOW_TESTS:
+        return
+
+    async with db.session() as data:
+        test_committee = await data.committee(name="test").get()
+        if not test_committee:
+            test_committee = sql.Committee(
+                name="test",
+                full_name="Test Committee",
+                is_podling=False,
+                committee_members=["test"],
+                committers=["test"],
+                release_managers=["test"],
+            )
+            data.add(test_committee)
+            await data.commit()
+
+        test_project = await data.project(name="test").get()
+        if not test_project:
+            test_project = sql.Project(
+                name="test",
+                full_name="Apache Test",
+                status=sql.ProjectStatus.ACTIVE,
+                committee_name="test",
+                created=datetime.datetime.now(datetime.UTC),
+                created_by="test",
+            )
+            data.add(test_project)
+            await data.commit()
+
+
 def main() -> None:
     """Quart debug server"""
     global app
diff --git a/atr/storage/writers/keys.py b/atr/storage/writers/keys.py
index b8f4053..942c080 100644
--- a/atr/storage/writers/keys.py
+++ b/atr/storage/writers/keys.py
@@ -32,7 +32,9 @@ import aiofiles.os
 import pgpy
 import pgpy.constants as constants
 import sqlalchemy.dialects.sqlite as sqlite
+import sqlmodel
 
+import atr.config as config
 import atr.db as db
 import atr.log as log
 import atr.models.sql as sql
@@ -92,7 +94,11 @@ class FoundationCommitter(GeneralPublic):
         return await self.__ensure_one(key_file_text, associate=False)
 
     def keyring_fingerprint_model(
-        self, keyring: pgpy.PGPKeyring, fingerprint: str, ldap_data: dict[str, 
str]
+        self,
+        keyring: pgpy.PGPKeyring,
+        fingerprint: str,
+        ldap_data: dict[str, str],
+        original_key_block: str | None = None,
     ) -> sql.PublicSigningKey | None:
         with keyring.key(fingerprint) as key:
             if not key.is_primary:
@@ -113,6 +119,9 @@ class FoundationCommitter(GeneralPublic):
             else:
                 raise ValueError(f"Key size is not an integer: 
{type(key_size)}, {key_size}")
 
+            # Use the original key block if available
+            ascii_armored = original_key_block if original_key_block else 
str(key)
+
             return sql.PublicSigningKey(
                 fingerprint=str(key.fingerprint).lower(),
                 algorithm=key.key_algorithm.value,
@@ -123,7 +132,7 @@ class FoundationCommitter(GeneralPublic):
                 primary_declared_uid=uids[0],
                 secondary_declared_uids=uids[1:],
                 apache_uid=asf_uid,
-                ascii_armored_key=str(key),
+                ascii_armored_key=ascii_armored,
             )
 
     async def keys_file_text(self, committee_name: str) -> str:
@@ -169,6 +178,30 @@ class FoundationCommitter(GeneralPublic):
             key_blocks_str=key_blocks_str,
         )
 
+    async def test_user_delete_all(self, test_uid: str) -> 
outcome.Outcome[int]:
+        """Delete all OpenPGP keys and their links for a test user."""
+        if not config.get().ALLOW_TESTS:
+            return outcome.Error(storage.AccessError("Test key deletion not 
enabled"))
+
+        try:
+            test_user_keys = await 
self.__data.public_signing_key(apache_uid=test_uid).all()
+
+            deleted_count = 0
+            for key in test_user_keys:
+                keylinks_query = 
sqlmodel.select(sql.KeyLink).where(sql.KeyLink.key_fingerprint == 
key.fingerprint)
+                keylinks_result = await self.__data.execute(keylinks_query)
+                keylinks = keylinks_result.all()
+                for keylink_row in keylinks:
+                    await self.__data.delete(keylink_row[0])
+
+                await self.__data.delete(key)
+                deleted_count += 1
+
+            await self.__data.commit()
+            return outcome.Result(deleted_count)
+        except Exception as e:
+            return outcome.Error(e)
+
     def __block_model(self, key_block: str, ldap_data: dict[str, str]) -> 
types.Key:
         # This cache is only held for the session
         if key_block in self.__key_block_models_cache:
@@ -186,7 +219,9 @@ class FoundationCommitter(GeneralPublic):
             key = None
             for fingerprint in fingerprints:
                 try:
-                    key_model = self.keyring_fingerprint_model(keyring, 
fingerprint, ldap_data)
+                    key_model = self.keyring_fingerprint_model(
+                        keyring, fingerprint, ldap_data, 
original_key_block=key_block
+                    )
                     if key_model is None:
                         # Was not a primary key, so skip it
                         continue
@@ -292,12 +327,16 @@ and was published by the committee.\
         test_key_uids = [
             "Apache Tooling (For test use only) 
<[email protected]>",
         ]
-        is_admin = user.is_admin(self.__asf_uid)
-        if (uids == test_key_uids) and is_admin:
+
+        if uids == test_key_uids:
             # Allow the test key
-            # TODO: We should fix the test key, not add an exception for it
-            # But the admin check probably makes this safe enough
-            return self.__asf_uid
+            if config.get().ALLOW_TESTS and (self.__asf_uid == "test"):
+                # TODO: "test" is already an admin user
+                # But we want to narrow that down to only actions like this
+                # TODO: Add include_test: bool to user.is_admin?
+                return "test"
+            if user.is_admin(self.__asf_uid):
+                return self.__asf_uid
 
         # Regular data
         emails = []
@@ -456,7 +495,9 @@ class CommitteeParticipant(FoundationCommitter):
             key_list = []
             for fingerprint in fingerprints:
                 try:
-                    key_model = self.keyring_fingerprint_model(keyring, 
fingerprint, ldap_data)
+                    key_model = self.keyring_fingerprint_model(
+                        keyring, fingerprint, ldap_data, 
original_key_block=key_block
+                    )
                     if key_model is None:
                         # Was not a primary key, so skip it
                         continue
diff --git a/atr/user.py b/atr/user.py
index 0e9e081..cfb1c1d 100644
--- a/atr/user.py
+++ b/atr/user.py
@@ -39,7 +39,12 @@ async def candidate_drafts(uid: str, user_projects: 
list[sql.Project] | None = N
 
 @functools.cache
 def get_admin_users() -> set[str]:
-    return set(config.get().ADMIN_USERS)
+    admin_users = set(config.get().ADMIN_USERS)
+    if config.get().ALLOW_TESTS:
+        # TODO: Just for debugging, but ideally we would do this in a targeted 
way
+        # We need this, for example, for deleting releases
+        admin_users.add("test")
+    return admin_users
 
 
 def is_admin(user_id: str | None) -> bool:
@@ -71,6 +76,13 @@ async def projects(uid: str, committee_only: bool = False, 
super_project: bool =
         for p in projects:
             if p.committee is None:
                 continue
+
+            # Allow access to test project when ALLOW_TESTS is enabled
+            # This means that the Test project will show in the user interface 
for everyone
+            if config.get().ALLOW_TESTS and (p.committee.name == "test"):
+                user_projects.append(p)
+                continue
+
             if committee_only:
                 if uid in p.committee.committee_members:
                     user_projects.append(p)
diff --git a/playwright/68FF2E20F02B070D73D416188DE8CC167FE2663A.asc 
b/playwright/68FF2E20F02B070D73D416188DE8CC167FE2663A.asc
new file mode 100644
index 0000000..942dfef
--- /dev/null
+++ b/playwright/68FF2E20F02B070D73D416188DE8CC167FE2663A.asc
@@ -0,0 +1,42 @@
+-----BEGIN PGP PUBLIC KEY BLOCK-----
+Comment: 68FF 2E20 F02B 070D 73D4  1618 8DE8 CC16 7FE2 663A
+Comment: Apache Tooling (For test use only) <apache-tooling@exam
+
+xjMEaO1PsBYJKwYBBAHaRw8BAQdApoMTlP31+p4iNHPXRsluuFJD7/n7ZfvbXTaW
+nuPqcNXCwBEEHxYKAIMFgmjtT7AFiQWkj70DCwkHCRCN6MwWf+JmOkcUAAAAAAAe
+ACBzYWx0QG5vdGF0aW9ucy5zZXF1b2lhLXBncC5vcmc62pYvwVHI/mS0pKwOQyF2
+4zTbGfhRAUmA5Lb0wiCA8wMVCggCm4ECHgkWIQRo/y4g8CsHDXPUFhiN6MwWf+Jm
+OgAAaCsA/juShbk2IlhDKf2KxwTMr88Ce/zDsw4+eiWtrthgBUeTAP4grngx+xez
+Ih3T0oHijFiRRFHeCrKtrD7qUYiRWZQrDc1DQXBhY2hlIFRvb2xpbmcgKEZvciB0
+ZXN0IHVzZSBvbmx5KSA8YXBhY2hlLXRvb2xpbmdAZXhhbXBsZS5pbnZhbGlkPsLA
+FAQTFgoAhgWCaO1PsAWJBaSPvQMLCQcJEI3ozBZ/4mY6RxQAAAAAAB4AIHNhbHRA
+bm90YXRpb25zLnNlcXVvaWEtcGdwLm9yZy/CVowGIUPB/Q2pB93lBODdG76rdB57
+tRtDUd1vvJTbAxUKCAKZAQKbgQIeCRYhBGj/LiDwKwcNc9QWGI3ozBZ/4mY6AAC2
+ggD/RzmgDOdwDbYa3yunGlaeNdXX9mKZr6l1ZZxPgxjCUUgA/iv14Q0jdlQTlnCE
+mU2KerEx3u4DKxTL3vSzrt7/YfYFzjMEaO1PsBYJKwYBBAHaRw8BAQdAOj8uLbY9
+uV5X5dHQouxpqNmBEiVL3yULoRGZIIcyjgzCwMUEGBYKATcFgmjtT7AFiQWkj70J
+EI3ozBZ/4mY6RxQAAAAAAB4AIHNhbHRAbm90YXRpb25zLnNlcXVvaWEtcGdwLm9y
+Z/rQQDjxu84TK0fnjT6/eJfY7Ry9NZPUC4OnZ22u3H2eApugvqAEGRYKAG8Fgmjt
+T7AJEB4aFkanXZJrRxQAAAAAAB4AIHNhbHRAbm90YXRpb25zLnNlcXVvaWEtcGdw
+Lm9yZ3usYbMZaif3OJ191geiqbHhOJKxQAnKeYrTTRIBRtuSFiEEHnCR6AIvyq8N
+clBVHhoWRqddkmsAANfnAP9q5S4wzYCzBWXBMgT8GkGQ6qlozPKbDrLlSBByuPWx
+1AD/bQxhQOSOyoTl93gX+LEgzOl90eilDC36YcwlAAvPSQcWIQRo/y4g8CsHDXPU
+FhiN6MwWf+JmOgAAf1MBAM6fEH6DqxAej4v5JMSvhFK/VSMy9l74KEjdS4zQqGf+
+AP0b2hno+V/sKwsIZeLcT2WW8ymesnwcMlyJAKRawsGODs4zBGjtT7AWCSsGAQQB
+2kcPAQEHQOnskJXKy8myDOIN96kyCybjuusSj0UMKKOcKpKgCbjBwsDFBBgWCgE3
+BYJo7U+wBYkFpI+9CRCN6MwWf+JmOkcUAAAAAAAeACBzYWx0QG5vdGF0aW9ucy5z
+ZXF1b2lhLXBncC5vcmetuP08aKM3bgGpJSqcY0BFDIgHZVRzbhqkS6jW70k5rgKb
+gr6gBBkWCgBvBYJo7U+wCRCBsaC42dIMukcUAAAAAAAeACBzYWx0QG5vdGF0aW9u
+cy5zZXF1b2lhLXBncC5vcmdvv5LhZuUYSxJ371orAeZNemqPQFs2TVnNAkqcOvQl
+xhYhBKMvD8z/SMlPuXH5hYGxoLjZ0gy6AABPrQEA/68vHpMtFSLciL11wPdwlQya
+7IgXmd+1ex/fKd0lL8UBAKs6o8npS3TFMOdQ/lY77+2i4mQ8R/06LG7UqJlRA9YF
+FiEEaP8uIPArBw1z1BYYjejMFn/iZjoAAILfAQCTsMRBr0p26+mM9DyAfloTXjtC
+b64WedgkrliFylIEswEAhgoBH5hKugQuQoGGMAaCgfiQiDjzKmy5Qq5apdqdzgjO
+OARo7U+wEgorBgEEAZdVAQUBAQdA2SplsmuOmaNAaFJv5/6ZIU4fo2/tAhtiB4mZ
+IwtSkWoDAQgHwsAGBBgWCgB4BYJo7U+wBYkFpI+9CRCN6MwWf+JmOkcUAAAAAAAe
+ACBzYWx0QG5vdGF0aW9ucy5zZXF1b2lhLXBncC5vcmdlwwnRv1QM8Bh2keRKFRz4
+8KCfPtinCmGR/RXxuR3A+wKbjBYhBGj/LiDwKwcNc9QWGI3ozBZ/4mY6AABx9QEA
+rTRZ8U7JxlVC/iX8fMzPNC3Ynmh6cH9feo7IP/DBVmAA/AmpD4UbPIrViv9jCHIN
+EQUjXZv3x5ZYr8Fx4ic4ldgF
+=eVnv
+-----END PGP PUBLIC KEY BLOCK-----
diff --git a/playwright/apache-test-0.2/apache-test-0.2.tar.gz 
b/playwright/apache-test-0.2/apache-test-0.2.tar.gz
new file mode 100644
index 0000000..cdf2aa6
Binary files /dev/null and b/playwright/apache-test-0.2/apache-test-0.2.tar.gz 
differ
diff --git a/playwright/apache-test-0.2/apache-test-0.2.tar.gz.asc 
b/playwright/apache-test-0.2/apache-test-0.2.tar.gz.asc
new file mode 100644
index 0000000..ee315f7
--- /dev/null
+++ b/playwright/apache-test-0.2/apache-test-0.2.tar.gz.asc
@@ -0,0 +1,8 @@
+-----BEGIN PGP SIGNATURE-----
+
+wr0EABYKAG8FgmjtT7AJEIGxoLjZ0gy6RxQAAAAAAB4AIHNhbHRAbm90YXRpb25z
+LnNlcXVvaWEtcGdwLm9yZ4VkofDuJJfmgWXBNbtwfchEvmhkIDXiWM6KftpKJ6HY
+FiEEoy8PzP9IyU+5cfmFgbGguNnSDLoAAGw/AQDAk4F3V56S1Rtrirm/ACTN59wu
+L4OohdalGj+tXF45mwD+OssTPHA5li7U6oDv3Aj3zXtCD6zWqfumaW0ePCxzOQs=
+=QK4u
+-----END PGP SIGNATURE-----
diff --git a/playwright/apache-test-0.2/apache-test-0.2.tar.gz.sha512 
b/playwright/apache-test-0.2/apache-test-0.2.tar.gz.sha512
new file mode 100644
index 0000000..bc07ae9
--- /dev/null
+++ b/playwright/apache-test-0.2/apache-test-0.2.tar.gz.sha512
@@ -0,0 +1 @@
+c6268245288e458795dbbc30b254493df29ce0829d770e0e22002cdb74043ef79e98436e7a1e67371974288cd13964a99538047ef53a4e4659075af283ee7c50
  apache-test-0.2.tar.gz
diff --git 
a/playwright/apache-tooling-test-example-0.2/apache-tooling-test-example-0.2.tar.gz
 
b/playwright/apache-tooling-test-example-0.2/apache-tooling-test-example-0.2.tar.gz
deleted file mode 100644
index 0512b3c..0000000
Binary files 
a/playwright/apache-tooling-test-example-0.2/apache-tooling-test-example-0.2.tar.gz
 and /dev/null differ
diff --git 
a/playwright/apache-tooling-test-example-0.2/apache-tooling-test-example-0.2.tar.gz.asc
 
b/playwright/apache-tooling-test-example-0.2/apache-tooling-test-example-0.2.tar.gz.asc
deleted file mode 100644
index 7d5b50b..0000000
--- 
a/playwright/apache-tooling-test-example-0.2/apache-tooling-test-example-0.2.tar.gz.asc
+++ /dev/null
@@ -1,8 +0,0 @@
------BEGIN PGP SIGNATURE-----
-
-wr0EABYKAG8FgmhayrkJEE9m+vmdYrj0RxQAAAAAAB4AIHNhbHRAbm90YXRpb25z
-LnNlcXVvaWEtcGdwLm9yZ3vpwgIRTsF5adxcqdebGCCmSGKvgOzuVzTFzEdfkCIW
-FiEEb6YmD+8n2tqPMh+RT2b6+Z1iuPQAAPZAAQC2wpFu44Z10oaQHvXAoWdHWH1r
-rh0ypJtdf8m8Z3fU7gD9EbDlFqqr1g3VE+52ftONNAaOOCNFIRUAcajGjphXjws=
-=8tSd
------END PGP SIGNATURE-----
diff --git 
a/playwright/apache-tooling-test-example-0.2/apache-tooling-test-example-0.2.tar.gz.sha512
 
b/playwright/apache-tooling-test-example-0.2/apache-tooling-test-example-0.2.tar.gz.sha512
deleted file mode 100644
index 06bb2c0..0000000
--- 
a/playwright/apache-tooling-test-example-0.2/apache-tooling-test-example-0.2.tar.gz.sha512
+++ /dev/null
@@ -1 +0,0 @@
-82709c7c20145bff4a62728a305d93c1a07f3082e209b147dd174dc31ddd151f0164a37a62dddb68c20eb8e21404199cd1863ea946438aecf509ced57d583a8c
  apache-tooling-test-example-0.2.tar.gz
diff --git a/playwright/mk.sh b/playwright/mk.sh
index 99d7ff4..8fbc956 100755
--- a/playwright/mk.sh
+++ b/playwright/mk.sh
@@ -28,16 +28,17 @@ mv tmp.secret.asc "${_fp}.secret.asc"
 sq key delete --cert-file "${_fp}.secret.asc" --output "${_fp}.asc"
 
 # Enter the directory containing the artifact
-cd apache-tooling-test-example-0.2/
+cd apache-test-0.2/
+rm ./*.asc ./*.sha512
 
 # Generate the SHA-2-512 hash
-sha512sum apache-tooling-test-example-0.2.tar.gz > \
-  apache-tooling-test-example-0.2.tar.gz.sha512
+sha512sum apache-test-0.2.tar.gz > \
+  apache-test-0.2.tar.gz.sha512
 
 # Generate the signature
 sq sign --signer-file "../${_fp}.secret.asc" \
-  --signature-file apache-tooling-test-example-0.2.tar.gz > \
-  apache-tooling-test-example-0.2.tar.gz.asc
+  --signature-file apache-test-0.2.tar.gz.asc \
+  apache-test-0.2.tar.gz
 
 # Remove the secret key
 rm "../${_fp}.secret.asc"
diff --git a/playwright/test.py b/playwright/test.py
index 80e8b54..5c8ff11 100755
--- a/playwright/test.py
+++ b/playwright/test.py
@@ -19,7 +19,6 @@
 
 import argparse
 import dataclasses
-import getpass
 import glob
 import logging
 import os
@@ -35,9 +34,10 @@ import netifaces
 import playwright.sync_api as sync_api
 import rich.logging
 
-_SSH_KEY_COMMENT: Final[str] = "[email protected]"
-_SSH_KEY_PATH: Final[str] = "/root/.ssh/id_ed25519"
-_OPENPGP_TEST_UID: Final[str] = "<[email protected]>"
+OPENPGP_TEST_UID: Final[str] = "<[email protected]>"
+SSH_KEY_COMMENT: Final[str] = "[email protected]"
+SSH_KEY_PATH: Final[str] = "/root/.ssh/id_ed25519"
+TEST_PROJECT: Final[str] = "test"
 
 
 @dataclasses.dataclass
@@ -58,19 +58,23 @@ def esc_id(text: str) -> str:
 
 
 def get_credentials() -> Credentials | None:
-    try:
-        username = input("Enter ASF Username: ")
-        password = getpass.getpass("Enter ASF Password: ")
-    except (EOFError, KeyboardInterrupt):
-        print()
-        logging.error("EOFError: No credentials provided")
-        return None
+    return Credentials(username="test", password="test")
 
-    if (not username) or (not password):
-        logging.error("Username and password cannot be empty")
-        return None
 
-    return Credentials(username=username, password=password)
+# def get_credentials_custom() -> Credentials | None:
+#     try:
+#         username = input("Enter ASF Username: ")
+#         password = getpass.getpass("Enter ASF Password: ")
+#     except (EOFError, KeyboardInterrupt):
+#         print()
+#         logging.error("EOFError: No credentials provided")
+#         return None
+#
+#     if (not username) or (not password):
+#         logging.error("Username and password cannot be empty")
+#         return None
+#
+#     return Credentials(username=username, password=password)
 
 
 def get_default_gateway_ip() -> str | None:
@@ -95,10 +99,12 @@ def go_to_path(page: sync_api.Page, path: str, wait: bool = 
True) -> None:
 
 def lifecycle_01_add_draft(page: sync_api.Page, credentials: Credentials, 
version_name: str) -> None:
     logging.info("Following link to start a new release")
-    go_to_path(page, "/start/tooling-test-example")
+    go_to_path(page, f"/start/{TEST_PROJECT}")
 
     logging.info("Waiting for the start new release page")
     version_name_locator = page.locator("input#version_name")
+    if not version_name_locator.is_visible(timeout=1000):
+        logging.error(f"Version name input not found. Page 
content:\n{page.content()}")
     sync_api.expect(version_name_locator).to_be_visible()
     logging.info("Start new release page loaded")
 
@@ -110,24 +116,24 @@ def lifecycle_01_add_draft(page: sync_api.Page, 
credentials: Credentials, versio
     sync_api.expect(submit_button_locator).to_be_enabled()
     submit_button_locator.click()
 
-    logging.info(f"Waiting for navigation to 
/compose/tooling-test-example/{version_name} after adding draft")
-    wait_for_path(page, f"/compose/tooling-test-example/{version_name}")
+    logging.info(f"Waiting for navigation to 
/compose/{TEST_PROJECT}/{version_name} after adding draft")
+    wait_for_path(page, f"/compose/{TEST_PROJECT}/{version_name}")
     logging.info("Add draft actions completed successfully")
 
 
 def lifecycle_02_check_draft_added(page: sync_api.Page, credentials: 
Credentials, version_name: str) -> None:
-    logging.info(f"Checking for draft 'tooling-test-example {version_name}'")
-    go_to_path(page, f"/compose/tooling-test-example/{version_name}")
-    h1_strong_locator = page.locator("h1 strong:has-text('Tooling Test 
Example')")
+    logging.info(f"Checking for draft '{TEST_PROJECT} {version_name}'")
+    go_to_path(page, f"/compose/{TEST_PROJECT}/{version_name}")
+    h1_strong_locator = page.locator("h1 strong:has-text('Test')")
     sync_api.expect(h1_strong_locator).to_be_visible()
     h1_em_locator = page.locator(f"h1 em:has-text('{esc_id(version_name)}')")
     sync_api.expect(h1_em_locator).to_be_visible()
-    logging.info(f"Draft 'tooling-test-example {version_name}' found 
successfully")
+    logging.info(f"Draft '{TEST_PROJECT} {version_name}' found successfully")
 
 
 def lifecycle_03_add_file(page: sync_api.Page, credentials: Credentials, 
version_name: str) -> None:
-    logging.info(f"Navigating to the upload file page for tooling-test-example 
{version_name}")
-    go_to_path(page, f"/upload/tooling-test-example/{version_name}")
+    logging.info(f"Navigating to the upload file page for {TEST_PROJECT} 
{version_name}")
+    go_to_path(page, f"/upload/{TEST_PROJECT}/{version_name}")
     logging.info("Upload file page loaded")
 
     logging.info("Locating the file input")
@@ -142,21 +148,21 @@ def lifecycle_03_add_file(page: sync_api.Page, 
credentials: Credentials, version
     sync_api.expect(submit_button_locator).to_be_enabled()
     submit_button_locator.click()
 
-    logging.info(f"Waiting for navigation to 
/compose/tooling-test-example/{version_name} after adding file")
-    wait_for_path(page, f"/compose/tooling-test-example/{version_name}")
+    logging.info(f"Waiting for navigation to 
/compose/{TEST_PROJECT}/{version_name} after adding file")
+    wait_for_path(page, f"/compose/{TEST_PROJECT}/{version_name}")
     logging.info("Add file actions completed successfully")
 
-    logging.info("Navigating back to /compose/tooling-test-example")
-    go_to_path(page, f"/compose/tooling-test-example/{version_name}")
-    logging.info("Navigation back to /compose/tooling-test-example completed 
successfully")
+    logging.info(f"Navigating back to /compose/{TEST_PROJECT}/{version_name}")
+    go_to_path(page, f"/compose/{TEST_PROJECT}/{version_name}")
+    logging.info(f"Navigation back to /compose/{TEST_PROJECT}/{version_name} 
completed successfully")
 
 
 def lifecycle_04_start_vote(page: sync_api.Page, credentials: Credentials, 
version_name: str) -> None:
-    logging.info(f"Navigating to the compose/tooling-test-example page for 
tooling-test-example {version_name}")
-    go_to_path(page, f"/compose/tooling-test-example/{version_name}")
-    logging.info("Compose/tooling-test-example page loaded successfully")
+    logging.info(f"Navigating to the compose/{TEST_PROJECT} page for 
{TEST_PROJECT} {version_name}")
+    go_to_path(page, f"/compose/{TEST_PROJECT}/{version_name}")
+    logging.info(f"Compose/{TEST_PROJECT} page loaded successfully")
 
-    logging.info(f"Locating start vote link for tooling-test-example 
{version_name}")
+    logging.info(f"Locating start vote link for {TEST_PROJECT} {version_name}")
     start_vote_link_locator = page.locator('a[title="Start a vote on this 
draft"]')
     sync_api.expect(start_vote_link_locator).to_be_visible()
 
@@ -173,15 +179,15 @@ def lifecycle_04_start_vote(page: sync_api.Page, 
credentials: Credentials, versi
     sync_api.expect(submit_button_locator).to_be_enabled()
     submit_button_locator.click()
 
-    logging.info(f"Waiting for navigation to 
/vote/tooling-test-example/{version_name} after submitting vote email")
-    wait_for_path(page, f"/vote/tooling-test-example/{version_name}")
+    logging.info(f"Waiting for navigation to 
/vote/{TEST_PROJECT}/{version_name} after submitting vote email")
+    wait_for_path(page, f"/vote/{TEST_PROJECT}/{version_name}")
 
     logging.info("Vote initiation actions completed successfully")
 
 
 def lifecycle_05_resolve_vote(page: sync_api.Page, credentials: Credentials, 
version_name: str) -> None:
-    logging.info(f"Navigating to the vote page for tooling-test-example 
{version_name}")
-    go_to_path(page, f"/vote/tooling-test-example/{version_name}")
+    logging.info(f"Navigating to the vote page for {TEST_PROJECT} 
{version_name}")
+    go_to_path(page, f"/vote/{TEST_PROJECT}/{version_name}")
     logging.info("Vote page loaded successfully")
 
     # Wait until the vote initiation background task has completed
@@ -202,7 +208,7 @@ def lifecycle_05_resolve_vote(page: sync_api.Page, 
credentials: Credentials, ver
         logging.warning("Vote initiation banner not detected after 15s, 
proceeding anyway")
 
     logging.info("Locating the 'Resolve vote' button")
-    tabulate_form_locator = 
page.locator(f'form[action="/resolve/tabulated/tooling-test-example/{version_name}"]')
+    tabulate_form_locator = 
page.locator(f'form[action="/resolve/tabulated/{TEST_PROJECT}/{version_name}"]')
     sync_api.expect(tabulate_form_locator).to_be_visible()
 
     tabulate_button_locator = 
tabulate_form_locator.locator('button[type="submit"]:has-text("Resolve vote")')
@@ -211,10 +217,10 @@ def lifecycle_05_resolve_vote(page: sync_api.Page, 
credentials: Credentials, ver
     tabulate_button_locator.click()
 
     logging.info("Waiting for navigation to tabulated votes page")
-    wait_for_path(page, 
f"/resolve/tabulated/tooling-test-example/{version_name}")
+    wait_for_path(page, f"/resolve/tabulated/{TEST_PROJECT}/{version_name}")
 
     logging.info("Locating the resolve vote form on the tabulated votes page")
-    resolve_form_locator = 
page.locator(f'form[action="/resolve/submit/tooling-test-example/{version_name}"]')
+    resolve_form_locator = 
page.locator(f'form[action="/resolve/submit/{TEST_PROJECT}/{version_name}"]')
     sync_api.expect(resolve_form_locator).to_be_visible()
 
     logging.info("Selecting 'Passed' radio button in resolve form")
@@ -227,25 +233,25 @@ def lifecycle_05_resolve_vote(page: sync_api.Page, 
credentials: Credentials, ver
     sync_api.expect(resolve_submit_locator).to_be_enabled()
     resolve_submit_locator.click()
 
-    logging.info(f"Waiting for navigation to 
/finish/tooling-test-example/{version_name} after resolving the vote")
-    wait_for_path(page, f"/finish/tooling-test-example/{version_name}")
+    logging.info(f"Waiting for navigation to 
/finish/{TEST_PROJECT}/{version_name} after resolving the vote")
+    wait_for_path(page, f"/finish/{TEST_PROJECT}/{version_name}")
     logging.info("Vote resolution actions completed successfully")
 
 
 def lifecycle_06_announce_preview(page: sync_api.Page, credentials: 
Credentials, version_name: str) -> None:
-    go_to_path(page, f"/finish/tooling-test-example/{version_name}")
+    go_to_path(page, f"/finish/{TEST_PROJECT}/{version_name}")
     logging.info("Finish page loaded successfully")
 
-    logging.info(f"Locating the announce link for tooling-test-example 
{version_name}")
-    announce_link_locator = 
page.locator(f'a[href="/announce/tooling-test-example/{esc_id(version_name)}"]')
+    logging.info(f"Locating the announce link for {TEST_PROJECT} 
{version_name}")
+    announce_link_locator = 
page.locator(f'a[href="/announce/{TEST_PROJECT}/{esc_id(version_name)}"]')
     sync_api.expect(announce_link_locator).to_be_visible()
     announce_link_locator.click()
 
-    logging.info(f"Waiting for navigation to 
/announce/tooling-test-example/{version_name} after announcing preview")
-    wait_for_path(page, f"/announce/tooling-test-example/{version_name}")
+    logging.info(f"Waiting for navigation to 
/announce/{TEST_PROJECT}/{version_name} after announcing preview")
+    wait_for_path(page, f"/announce/{TEST_PROJECT}/{version_name}")
 
-    logging.info(f"Locating the announcement form for tooling-test-example 
{version_name}")
-    form_locator = 
page.locator(f'form[action="/announce/tooling-test-example/{esc_id(version_name)}"]')
+    logging.info(f"Locating the announcement form for {TEST_PROJECT} 
{version_name}")
+    form_locator = 
page.locator(f'form[action="/announce/{TEST_PROJECT}/{esc_id(version_name)}"]')
     sync_api.expect(form_locator).to_be_visible()
 
     logging.info("Locating the confirmation checkbox within the form")
@@ -261,21 +267,19 @@ def lifecycle_06_announce_preview(page: sync_api.Page, 
credentials: Credentials,
     submit_button_locator.click()
 
     logging.info("Waiting for navigation to /releases after submitting 
announcement")
-    wait_for_path(page, "/releases/finished/tooling-test-example")
+    wait_for_path(page, f"/releases/finished/{TEST_PROJECT}")
     logging.info("Preview announcement actions completed successfully")
 
 
 def lifecycle_07_release_exists(page: sync_api.Page, credentials: Credentials, 
version_name: str) -> None:
-    logging.info(f"Checking for release tooling-test-example {version_name} on 
/releases/finished/tooling-test-example")
-    go_to_path(page, "/releases/finished/tooling-test-example")
+    logging.info(f"Checking for release {TEST_PROJECT} {version_name} on 
/releases/finished/{TEST_PROJECT}")
+    go_to_path(page, f"/releases/finished/{TEST_PROJECT}")
     logging.info("Releases finished page loaded successfully")
 
     release_card_locator = 
page.locator(f'div.card:has(strong.card-title:has-text("{version_name}"))')
     sync_api.expect(release_card_locator).to_be_visible()
-    logging.info(f"Found card for tooling-test-example {version_name} release")
-    logging.info(
-        f"Release tooling-test-example {version_name} confirmed exists on 
/releases/finished/tooling-test-example"
-    )
+    logging.info(f"Found card for {TEST_PROJECT} {version_name} release")
+    logging.info(f"Release {TEST_PROJECT} {version_name} confirmed exists on 
/releases/finished/{TEST_PROJECT}")
 
 
 def main() -> None:
@@ -464,7 +468,7 @@ def slow(func: Callable[..., Any]) -> Callable[..., Any]:
 
 
 def ssh_keys_generate() -> None:
-    ssh_key_path = _SSH_KEY_PATH
+    ssh_key_path = SSH_KEY_PATH
     ssh_dir = os.path.dirname(ssh_key_path)
 
     try:
@@ -477,9 +481,9 @@ def ssh_keys_generate() -> None:
 
         os.makedirs(ssh_dir, mode=0o700, exist_ok=True)
 
-        logging.info(f"Generating new SSH key at {ssh_key_path} with comment 
{_SSH_KEY_COMMENT}")
+        logging.info(f"Generating new SSH key at {ssh_key_path} with comment 
{SSH_KEY_COMMENT}")
         subprocess.run(
-            ["ssh-keygen", "-t", "ed25519", "-f", ssh_key_path, "-N", "", 
"-C", _SSH_KEY_COMMENT],
+            ["ssh-keygen", "-t", "ed25519", "-f", ssh_key_path, "-N", "", 
"-C", SSH_KEY_COMMENT],
             check=True,
             capture_output=True,
             text=True,
@@ -504,7 +508,6 @@ def test_all(page: sync_api.Page, credentials: Credentials, 
skip_slow: bool) ->
     tests["projects"] = [
         test_projects_01_update,
         test_projects_02_check_directory,
-        test_projects_03_add_project,
     ]
     tests["lifecycle"] = [
         test_lifecycle_01_add_draft,
@@ -542,7 +545,7 @@ def test_all(page: sync_api.Page, credentials: Credentials, 
skip_slow: bool) ->
 
 
 def test_checks_01_hashing_sha512(page: sync_api.Page, credentials: 
Credentials) -> None:
-    project_name = "tooling-test-example"
+    project_name = TEST_PROJECT
     version_name = "0.2"
     filename_sha512 = f"apache-{project_name}-{version_name}.tar.gz.sha512"
     compose_path = f"/compose/{project_name}/{version_name}"
@@ -579,7 +582,7 @@ def test_checks_01_hashing_sha512(page: sync_api.Page, 
credentials: Credentials)
 
 
 def test_checks_02_license_files(page: sync_api.Page, credentials: 
Credentials) -> None:
-    project_name = "tooling-test-example"
+    project_name = TEST_PROJECT
     version_name = "0.2"
     filename_targz = f"apache-{project_name}-{version_name}.tar.gz"
     compose_path = f"/compose/{project_name}/{version_name}"
@@ -616,7 +619,7 @@ def test_checks_02_license_files(page: sync_api.Page, 
credentials: Credentials)
 
 
 def test_checks_03_license_headers(page: sync_api.Page, credentials: 
Credentials) -> None:
-    project_name = "tooling-test-example"
+    project_name = TEST_PROJECT
     version_name = "0.2"
     filename_targz = f"apache-{project_name}-{version_name}.tar.gz"
     report_file_path = 
f"/report/{project_name}/{version_name}/{filename_targz}"
@@ -641,7 +644,7 @@ def test_checks_03_license_headers(page: sync_api.Page, 
credentials: Credentials
 
 
 def test_checks_04_paths(page: sync_api.Page, credentials: Credentials) -> 
None:
-    project_name = "tooling-test-example"
+    project_name = TEST_PROJECT
     version_name = "0.2"
     filename_sha512 = f"apache-{project_name}-{version_name}.tar.gz.sha512"
     report_file_path = 
f"/report/{project_name}/{version_name}/{filename_sha512}"
@@ -667,7 +670,7 @@ def test_checks_04_paths(page: sync_api.Page, credentials: 
Credentials) -> None:
 
 
 def test_checks_05_signature(page: sync_api.Page, credentials: Credentials) -> 
None:
-    project_name = "tooling-test-example"
+    project_name = TEST_PROJECT
     version_name = "0.2"
     filename_asc = f"apache-{project_name}-{version_name}.tar.gz.asc"
     report_file_path = f"/report/{project_name}/{version_name}/{filename_asc}"
@@ -691,7 +694,7 @@ def test_checks_05_signature(page: sync_api.Page, 
credentials: Credentials) -> N
 
 
 def test_checks_06_targz(page: sync_api.Page, credentials: Credentials) -> 
None:
-    project_name = "tooling-test-example"
+    project_name = TEST_PROJECT
     version_name = "0.2"
     filename_targz = f"apache-{project_name}-{version_name}.tar.gz"
     report_file_path = 
f"/report/{project_name}/{version_name}/{filename_targz}"
@@ -831,6 +834,12 @@ def test_login(page: sync_api.Page, credentials: 
Credentials) -> None:
     if debugging:
         remove_debugging = test_logging_debug(page, credentials)
 
+    if credentials.username == "test":
+        go_to_path(page, "/test-login", wait=False)
+        wait_for_path(page, "/")
+        logging.info("Test login completed successfully")
+        return
+
     go_to_path(page, "/")
     logging.info(f"Initial page title: {page.title()}")
 
@@ -973,7 +982,7 @@ def test_ssh_01_add_key(page: sync_api.Page, credentials: 
Credentials) -> None:
     wait_for_path(page, "/keys/ssh/add")
     logging.info("Navigated to Add your SSH key page")
 
-    public_key_path = f"{_SSH_KEY_PATH}.pub"
+    public_key_path = f"{SSH_KEY_PATH}.pub"
     try:
         logging.info(f"Reading public key from {public_key_path}")
         with open(public_key_path, encoding="utf-8") as f:
@@ -1022,7 +1031,7 @@ def test_ssh_01_add_key(page: sync_api.Page, credentials: 
Credentials) -> None:
 
 
 def test_ssh_02_rsync_upload(page: sync_api.Page, credentials: Credentials) -> 
None:
-    project_name = "tooling-test-example"
+    project_name = TEST_PROJECT
     version_name = "0.2"
     source_dir_rel = f"apache-{project_name}-{version_name}"
     source_dir_abs = f"/run/tests/{source_dir_rel}"
@@ -1093,16 +1102,29 @@ def test_ssh_02_rsync_upload(page: sync_api.Page, 
credentials: Credentials) -> N
 
 
 def test_tidy_up(page: sync_api.Page) -> None:
-    # Projects cannot be deleted if they have associated releases
-    # Therefore, we need to delete releases first
     test_tidy_up_releases(page)
-    test_tidy_up_project(page)
     test_tidy_up_ssh_keys(page)
     test_tidy_up_openpgp_keys(page)
 
 
 def test_tidy_up_openpgp_keys(page: sync_api.Page) -> None:
     logging.info("Starting OpenPGP key tidy up")
+
+    # First, delete the test key if it exists with wrong apache_uid
+    # (it may exist from real usage due to on_conflict_do_nothing in the 
INSERT)
+    # TODO: Don't hardcode this
+    logging.info("Deleting test key from database via admin route")
+
+    # Navigate to the delete route and submit the form
+    go_to_path(page, "/admin/delete-test-openpgp-keys")
+    delete_button = page.locator('button[type="submit"]')
+    if delete_button.is_visible():
+        delete_button.click()
+        page.wait_for_load_state()
+        logging.info("Test key deletion form submitted")
+    else:
+        logging.info("Test key deletion button not found, key may not exist")
+
     go_to_path(page, "/keys")
     logging.info("Navigated to /keys page for OpenPGP key cleanup")
 
@@ -1129,7 +1151,7 @@ def test_tidy_up_openpgp_keys(page: sync_api.Page) -> 
None:
         sync_api.expect(pre_locator).to_be_visible()
         key_content = pre_locator.inner_text()
 
-        if _OPENPGP_TEST_UID in key_content:
+        if OPENPGP_TEST_UID in key_content:
             logging.info(f"Found test OpenPGP key with fingerprint 
{fingerprint} for deletion")
             fingerprints_to_delete.append(fingerprint)
 
@@ -1179,7 +1201,7 @@ def test_tidy_up_openpgp_keys_continued(page: 
sync_api.Page, fingerprints_to_del
 
 
 def test_tidy_up_project(page: sync_api.Page) -> None:
-    project_name = "Apache Tooling Test Example"
+    project_name = "Apache Test"
     logging.info(f"Checking for project '{project_name}' at /projects")
     go_to_path(page, "/projects")
     logging.info("Project directory page loaded")
@@ -1268,7 +1290,7 @@ def test_tidy_up_ssh_keys(page: sync_api.Page) -> None:
             continue
 
         key_content = details_pre_locator.inner_text()
-        if _SSH_KEY_COMMENT in key_content:
+        if SSH_KEY_COMMENT in key_content:
             fingerprint_td_locator = card.locator('td:has-text("SHA256:")')
             if fingerprint_td_locator.is_visible(timeout=500):
                 fingerprint = fingerprint_td_locator.inner_text().strip()
@@ -1280,7 +1302,7 @@ def test_tidy_up_ssh_keys(page: sync_api.Page) -> None:
             else:
                 logging.warning("Could not locate fingerprint td for a test 
key card")
         else:
-            logging.debug(f"SSH key card: test comment '{_SSH_KEY_COMMENT}' 
not found in key content")
+            logging.debug(f"SSH key card: test comment '{SSH_KEY_COMMENT}' not 
found in key content")
 
     # For the complexity linter only
     test_tidy_up_ssh_keys_continued(page, fingerprints_to_delete)
@@ -1331,11 +1353,11 @@ def test_tidy_up_releases(page: sync_api.Page) -> None:
     logging.info("Admin delete release page loaded")
 
     # TODO: Get these names automatically
-    release_remove(page, "tooling-test-example-0.1+draft")
-    release_remove(page, "tooling-test-example-0.1+candidate")
-    release_remove(page, "tooling-test-example-0.1+preview")
-    release_remove(page, "tooling-test-example-0.1+release")
-    release_remove(page, "tooling-test-example-0.2")
+    release_remove(page, f"{TEST_PROJECT}-0.1+draft")
+    release_remove(page, f"{TEST_PROJECT}-0.1+candidate")
+    release_remove(page, f"{TEST_PROJECT}-0.1+preview")
+    release_remove(page, f"{TEST_PROJECT}-0.1+release")
+    release_remove(page, f"{TEST_PROJECT}-0.2")
 
 
 def wait_for_path(page: sync_api.Page, path: str) -> None:


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to