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

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

commit 6cfd6132c506507f8fd84e209ded2292624e4847
Author: Alastair McFarlane <[email protected]>
AuthorDate: Thu Mar 19 14:37:11 2026 +0000

    Move most of the admin routes over to "typed" decorators for param 
validation. Add test to make sure future decorator changes don't break routing. 
Simplify param checking.
---
 atr/admin/__init__.py         | 409 +++++++++++++++++++++++++++++-------------
 atr/blueprints/admin.py       | 128 ++++++++++++-
 atr/blueprints/api.py         |  67 ++-----
 atr/blueprints/common.py      | 133 ++++++++++++--
 atr/get/test.py               |   7 +-
 tests/unit/test_blueprints.py |  62 +++++++
 6 files changed, 602 insertions(+), 204 deletions(-)

diff --git a/atr/admin/__init__.py b/atr/admin/__init__.py
index 3ceb3bf1..1ce05888 100644
--- a/atr/admin/__init__.py
+++ b/atr/admin/__init__.py
@@ -53,6 +53,7 @@ import atr.mapping as mapping
 import atr.models.safe as safe
 import atr.models.session
 import atr.models.sql as sql
+import atr.models.unsafe as unsafe
 import atr.paths as paths
 import atr.principal as principal
 import atr.storage as storage
@@ -126,17 +127,25 @@ class SessionDataCommon(NamedTuple):
     projects: list[str]
 
 
[email protected]("/all-releases")
-async def all_releases(session: web.Committer) -> str:
-    """Display a list of all releases across all phases."""
[email protected]
+async def all_releases(_session: web.Committer, _all_releases: 
Literal["all/releases"]) -> str:
+    """
+    URL: GET /all/releases
+
+    Display a list of all releases across all phases.
+    """
     async with db.session() as data:
         releases = await data.release(_project=True, 
_committee=True).order_by(sql.Release.key).all()
     return await template.render("all-releases.html", releases=releases, 
release_as_url=mapping.release_as_url)
 
 
[email protected]("/browse-as")
-async def browse_as_get(session: web.Committer) -> str | web.WerkzeugResponse:
-    """Allows an admin to browse as another user."""
[email protected]
+async def browse_as_get(_session: web.Committer, _browse_as: 
Literal["browse-as"]) -> str | web.WerkzeugResponse:
+    """
+    URL: GET /browse-as
+
+    Allows an admin to browse as another user.
+    """
     rendered_form = form.render(
         model_cls=BrowseAsUserForm,
         submit_label="Browse as this user",
@@ -144,10 +153,15 @@ async def browse_as_get(session: web.Committer) -> str | 
web.WerkzeugResponse:
     return await template.render("browse-as.html", form=rendered_form)
 
 
[email protected]("/browse-as")
[email protected](BrowseAsUserForm)
-async def browse_as_post(session: web.Committer, browse_form: 
BrowseAsUserForm) -> str | web.WerkzeugResponse:
-    """Allows an admin to browse as another user."""
[email protected]
+async def browse_as_post(
+    session: web.Committer, _browse_as: Literal["browse-as"], browse_form: 
BrowseAsUserForm
+) -> str | web.WerkzeugResponse:
+    """
+    URL: POST /browse-as
+
+    Allows an admin to browse as another user.
+    """
     # We specifically need to use this on the production server
     import atr.get.root as root
 
@@ -194,9 +208,13 @@ async def browse_as_post(session: web.Committer, 
browse_form: BrowseAsUserForm)
     return await session.redirect(root.index)
 
 
[email protected]("/configuration")
-async def configuration(session: web.Committer) -> web.QuartResponse:
-    """Display the current application configuration values."""
[email protected]
+async def configuration(_session: web.Committer, _configuration: 
Literal["configuration"]) -> web.QuartResponse:
+    """
+    URL: GET /configuration
+
+    Display the current application configuration values.
+    """
 
     sensitive_config_patterns = ("_PASSWORD", "_KEY", "_TOKEN", "_SECRET")
 
@@ -219,9 +237,13 @@ async def configuration(session: web.Committer) -> 
web.QuartResponse:
     return web.TextResponse("\n".join(values))
 
 
[email protected]("/consistency")
-async def consistency(session: web.Committer) -> web.TextResponse:
-    """Check for consistency between the database and the filesystem."""
[email protected]
+async def consistency(_session: web.Committer, _consistency: 
Literal["consistency"]) -> web.TextResponse:
+    """
+    URL: GET /consistency
+
+    Check for consistency between the database and the filesystem.
+    """
     # Get all releases from the database
     async with db.session() as data:
         releases = await data.release().all()
@@ -264,19 +286,34 @@ async def consistency(session: web.Committer) -> 
web.TextResponse:
     )
 
 
[email protected]("/data")
-async def data(session: web.Committer) -> str:
-    return await _data(session, "Committee")
[email protected]
+async def data(session: web.Committer, _data: Literal["data"]) -> str:
+    """
+    URL: GET /data
+    """
+    return await _data_browse(session, "Committee")
 
 
[email protected]("/data/<model>")
-async def data_model(session: web.Committer, model: str = "Committee") -> str:
-    return await _data(session, model)
[email protected]
+async def data_model(
+    session: web.Committer, _data: Literal["data"], model: unsafe.UnsafeStr = 
unsafe.UnsafeStr("Committee")
+) -> str:
+    """
+    URL: GET /data/<model>
+    """
 
+    return await _data_browse(session, str(model))
 
[email protected]("/delete-committee-keys")
-async def delete_committee_keys_get(session: web.Committer) -> str | 
web.WerkzeugResponse:
-    """Display the form to delete committee keys."""
+
[email protected]
+async def delete_committee_keys_get(
+    _session: web.Committer, _delete_committee_keys: 
Literal["delete-committee-keys"]
+) -> str | web.WerkzeugResponse:
+    """
+    URL: GET /delete-committee-keys
+
+    Display the form to delete committee keys.
+    """
     async with db.session() as data:
         all_committees = await 
data.committee(_public_signing_keys=True).order_by(sql.Committee.key).all()
         committees_with_keys = [c for c in all_committees if 
c.public_signing_keys]
@@ -291,12 +328,17 @@ async def delete_committee_keys_get(session: 
web.Committer) -> str | web.Werkzeu
     return await template.render("delete-committee-keys.html", 
form=rendered_form)
 
 
[email protected]("/delete-committee-keys")
[email protected](DeleteCommitteeKeysForm)
[email protected]
 async def delete_committee_keys_post(
-    session: web.Committer, delete_form: DeleteCommitteeKeysForm
+    session: web.Committer,
+    _delete_committee_keys: Literal["delete-committee-keys"],
+    delete_form: DeleteCommitteeKeysForm,
 ) -> str | web.WerkzeugResponse:
-    """Delete all keys for selected committee."""
+    """
+    URL: POST /delete-committee-keys
+
+    Delete all keys for selected committee.
+    """
     committee_key = delete_form.committee_key
 
     async with db.session() as data:
@@ -336,9 +378,15 @@ async def delete_committee_keys_post(
     return await session.redirect(delete_committee_keys_get)
 
 
[email protected]("/delete-release")
-async def delete_release_get(session: web.Committer) -> str | 
web.WerkzeugResponse:
-    """Display the form to delete releases."""
[email protected]
+async def delete_release_get(
+    _session: web.Committer, _delete_release_get: Literal["delete-release"]
+) -> str | web.WerkzeugResponse:
+    """
+    URL: GET /delete-release
+
+    Display the form to delete releases.
+    """
     async with db.session() as data:
         releases = await 
data.release(_project=True).order_by(sql.Release.key).all()
 
@@ -378,20 +426,31 @@ async def delete_release_get(session: web.Committer) -> 
str | web.WerkzeugRespon
     return await template.render("delete-release.html", form=rendered_form)
 
 
[email protected]("/delete-release")
[email protected](DeleteReleaseForm)
-async def delete_release_post(session: web.Committer, delete_form: 
DeleteReleaseForm) -> str | web.WerkzeugResponse:
-    """Delete selected releases and their associated data and files."""
[email protected]
+async def delete_release_post(
+    session: web.Committer, _delete_release_get: Literal["delete-release"], 
delete_form: DeleteReleaseForm
+) -> str | web.WerkzeugResponse:
+    """
+    URL: POST /delete-release
+
+    Delete selected releases and their associated data and files.
+    """
     await _delete_releases(session, delete_form.releases_to_delete)
 
     return await session.redirect(delete_release_get)
 
 
[email protected]("/delete-test-openpgp-keys")
-async def delete_test_openpgp_keys_get(session: web.Committer) -> web.Response:
-    """Display the form to delete test user OpenPGP keys."""
[email protected]
+async def delete_test_openpgp_keys_get(
+    _session: web.Committer, _delete_test_openpgp_keys: 
Literal["delete-test-openpgp-keys"]
+) -> web.Response:
+    """
+    URL: GET /delete-test-openpgp-keys
+
+    Display the form to delete test user OpenPGP keys.
+    """
     if not config.get().ALLOW_TESTS:
-        raise base.ASFQuartException("Test operations are disabled in this 
environment", errorcode=403)
+        return quart.abort(404)
 
     rendered_form = form.render(
         model_cls=form.Empty,
@@ -401,12 +460,17 @@ async def delete_test_openpgp_keys_get(session: 
web.Committer) -> web.Response:
     return web.ElementResponse(rendered_form)
 
 
[email protected]("/delete-test-openpgp-keys")
[email protected]()
-async def delete_test_openpgp_keys_post(session: web.Committer) -> 
web.Response:
-    """Delete all test user OpenPGP keys and their links."""
[email protected]
+async def delete_test_openpgp_keys_post(
+    session: web.Committer, _delete_test_openpgp_keys: 
Literal["delete-test-openpgp-keys"], _form: form.Empty
+) -> web.Response:
+    """
+    URL: POST /delete-test-openpgp-keys
+
+    Delete all test user OpenPGP keys and their links.
+    """
     if not config.get().ALLOW_TESTS:
-        raise base.ASFQuartException("Test operations are disabled in this 
environment", errorcode=403)
+        return quart.abort(404)
 
     test_uid = "test"
     try:
@@ -427,18 +491,26 @@ async def delete_test_openpgp_keys_post(session: 
web.Committer) -> web.Response:
     return await session.redirect(get.keys.keys)
 
 
[email protected]("/env")
-async def env(session: web.Committer) -> web.QuartResponse:
-    """Display the environment variables."""
[email protected]
+async def env(_session: web.Committer, _env: Literal["env"]) -> 
web.QuartResponse:
+    """
+    URL: GET /env
+
+    Display the environment variables.
+    """
     env_vars = []
     for key, value in os.environ.items():
         env_vars.append(f"{key}={value}")
     return web.TextResponse("\n".join(env_vars))
 
 
[email protected]("/keys/check")
-async def keys_check_get(session: web.Committer) -> web.QuartResponse:
-    """Check public signing key details."""
[email protected]
+async def keys_check_get(_session: web.Committer, _keys_check: 
Literal["keys/check"]) -> web.QuartResponse:
+    """
+    URL: GET /keys/check
+
+    Check public signing key details.
+    """
     rendered_form = form.render(
         model_cls=form.Empty,
         submit_label="Check public signing key details",
@@ -447,6 +519,7 @@ async def keys_check_get(session: web.Committer) -> 
web.QuartResponse:
     return web.ElementResponse(rendered_form)
 
 
+# TODO: AM Why has this no empty form?
 @admin.post("/keys/check")
 async def keys_check_post(session: web.Committer) -> web.QuartResponse:
     """Check public signing key details."""
@@ -458,9 +531,15 @@ async def keys_check_post(session: web.Committer) -> 
web.QuartResponse:
         return web.TextResponse(f"Exception during key check: {e!s}")
 
 
[email protected]("/keys/regenerate-all")
-async def keys_regenerate_all_get(session: web.Committer) -> web.QuartResponse:
-    """Display the form to regenerate KEYS files."""
[email protected]
+async def keys_regenerate_all_get(
+    _session: web.Committer, _keys_regenerate_all: 
Literal["keys/regenerate-all"]
+) -> web.QuartResponse:
+    """
+    URL: GET /keys/regenerate-all
+
+    Display the form to regenerate KEYS files.
+    """
     rendered_form = form.render(
         model_cls=form.Empty,
         submit_label="Regenerate all KEYS files",
@@ -469,6 +548,7 @@ async def keys_regenerate_all_get(session: web.Committer) 
-> web.QuartResponse:
     return web.ElementResponse(rendered_form)
 
 
+# TODO: AM Why has this no empty form?
 @admin.post("/keys/regenerate-all")
 async def keys_regenerate_all_post(session: web.Committer) -> 
web.QuartResponse:
     """Regenerate the KEYS file for all committees."""
@@ -493,9 +573,15 @@ async def keys_regenerate_all_post(session: web.Committer) 
-> web.QuartResponse:
     return web.TextResponse("\n".join(response_lines))
 
 
[email protected]("/keys/update")
-async def keys_update_get(session: web.Committer) -> str | 
web.WerkzeugResponse | tuple[Mapping[str, Any], int]:
-    """Update keys from remote data."""
[email protected]
+async def keys_update_get(
+    _session: web.Committer, _keys_update: Literal["keys/update"]
+) -> str | web.WerkzeugResponse | tuple[Mapping[str, Any], int]:
+    """
+    URL: GET /keys/update
+
+    Update keys from remote data.
+    """
     rendered_form = form.render(
         model_cls=form.Empty,
         submit_label="Update keys",
@@ -512,6 +598,7 @@ async def keys_update_get(session: web.Committer) -> str | 
web.WerkzeugResponse
     return await template.render("update-keys.html", empty_form=rendered_form, 
previous_output=previous_output)
 
 
+# TODO: AM Why has this no empty form?
 @admin.post("/keys/update")
 async def keys_update_post(session: web.Committer) -> str | 
web.WerkzeugResponse | tuple[Mapping[str, Any], int]:
     """Update keys from remote data."""
@@ -531,8 +618,11 @@ async def keys_update_post(session: web.Committer) -> str 
| web.WerkzeugResponse
         }, 200
 
 
[email protected]("/ldap")
-async def ldap_get(session: web.Committer) -> str:
[email protected]
+async def ldap_get(session: web.Committer, _ldap: Literal["ldap"]) -> str:
+    """
+    URL: GET /ldap
+    """
     rendered_form = form.render(
         model_cls=LdapLookupForm,
         submit_label="Lookup",
@@ -547,9 +637,11 @@ async def ldap_get(session: web.Committer) -> str:
     )
 
 
[email protected]("/ldap")
[email protected](LdapLookupForm)
-async def ldap_post(session: web.Committer, lookup_form: LdapLookupForm) -> 
str:
[email protected]
+async def ldap_post(session: web.Committer, _ldap: Literal["ldap"], 
lookup_form: LdapLookupForm) -> str:
+    """
+    URL POST /ldap
+    """
     # TODO: This is one case where we should perhaps allow str | None on the 
form
     uid_query = lookup_form.uid if lookup_form.uid else None
     email_query = lookup_form.email if lookup_form.email else None
@@ -586,8 +678,11 @@ async def ldap_post(session: web.Committer, lookup_form: 
LdapLookupForm) -> str:
     )
 
 
[email protected]("/logs")
-async def logs(session: web.Committer) -> web.QuartResponse:
[email protected]
+async def logs(_session: web.Committer, _logs: Literal["logs"]) -> 
web.QuartResponse:
+    """
+    URL: GET /logs
+    """
     _require_debug_and_allow_tests()
     recent_logs = log.get_recent_logs()
     if recent_logs is None:
@@ -595,29 +690,35 @@ async def logs(session: web.Committer) -> 
web.QuartResponse:
     return web.TextResponse("\n".join(recent_logs))
 
 
[email protected]("/ongoing-tasks/<project_key>/<version_key>/<revision>")
[email protected]
 async def ongoing_tasks_get(
-    session: web.Committer, project_key: str, version_key: str, revision: str
+    session: web.Committer,
+    _ongoing_tasks: Literal["ongoing-tasks"],
+    project_key: safe.ProjectKey,
+    version_key: safe.VersionKey,
+    revision: safe.RevisionNumber,
 ) -> web.QuartResponse:
-    project = safe.ProjectKey(project_key)
-    version = safe.VersionKey(version_key)
-    revision_number = safe.RevisionNumber(revision)
-    return await _ongoing_tasks(session, project, version, revision_number)
+    """
+    URL: GET /ongoing-tasks
+    """
+    return await _fetch_ongoing_tasks(session, project_key, version_key, 
revision)
 
 
+# TODO: AM Why has this no empty form?
 @admin.post("/ongoing-tasks/<project_key>/<version_key>/<revision>")
 async def ongoing_tasks_post(
-    session: web.Committer, project_key: str, version_key: str, revision: str
+    session: web.Committer, project_key: safe.ProjectKey, version_key: 
safe.VersionKey, revision: safe.RevisionNumber
 ) -> web.QuartResponse:
-    project = safe.ProjectKey(project_key)
-    version = safe.VersionKey(version_key)
-    revision_number = safe.RevisionNumber(revision)
-    return await _ongoing_tasks(session, project, version, revision_number)
+    return await _fetch_ongoing_tasks(session, project_key, version_key, 
revision)
+
 
[email protected]
+async def performance(_session: web.Committer, _performance: 
Literal["performance"]) -> str:
+    """
+    URL: GET /performance
 
[email protected]("/performance")
-async def performance(session: web.Committer) -> str:
-    """Display performance statistics for all routes."""
+    Display performance statistics for all routes.
+    """
     app = asfquart.APP
 
     if app is ...:
@@ -696,9 +797,15 @@ async def performance(session: web.Committer) -> str:
     return await template.render("performance.html", stats=sorted_summary)
 
 
[email protected]("/projects/update")
-async def projects_update_get(session: web.Committer) -> str | 
web.WerkzeugResponse | tuple[Mapping[str, Any], int]:
-    """Update projects from remote data."""
[email protected]
+async def projects_update_get(
+    _session: web.Committer, _projects_update: Literal["projects/update"]
+) -> str | web.WerkzeugResponse | tuple[Mapping[str, Any], int]:
+    """
+    URL: GET /projects/update
+
+    Update projects from remote data.
+    """
     rendered_form = form.render(
         model_cls=form.Empty,
         submit_label="Update projects",
@@ -708,6 +815,7 @@ async def projects_update_get(session: web.Committer) -> 
str | web.WerkzeugRespo
     return await template.render("update-projects.html", 
empty_form=rendered_form)
 
 
+# TODO: AM Why has this no empty form?
 @admin.post("/projects/update")
 async def projects_update_post(session: web.Committer) -> str | 
web.WerkzeugResponse | tuple[Mapping[str, Any], int]:
     """Update projects from remote data."""
@@ -725,14 +833,21 @@ async def projects_update_post(session: web.Committer) -> 
str | web.WerkzeugResp
         }, 200
 
 
[email protected]("/raise-error")
-async def raise_error(session: web.Committer) -> web.QuartResponse:
[email protected]
+async def raise_error(_session: web.Committer, _raise_error: 
Literal["raise-error"]) -> web.QuartResponse:
+    """
+    URL: GET /raise-error
+    """
     raise RuntimeError("Admin test route deliberately raised an unhandled 
error")
 
 
[email protected]("/revoke-user-tokens")
-async def revoke_user_tokens_get(session: web.Committer) -> str:
-    """Revoke all Personal Access Tokens for a specified user."""
[email protected]
+async def revoke_user_tokens_get(_session: web.Committer, _revoke_user_tokens: 
Literal["revoke-user-tokens"]) -> str:
+    """
+    URL: GET /revoke-user-tokens
+
+    Revoke all Personal Access Tokens for a specified user.
+    """
     token_counts: list[tuple[str, int]] = []
     async with db.session() as data:
         stmt = (
@@ -757,12 +872,15 @@ async def revoke_user_tokens_get(session: web.Committer) 
-> str:
     )
 
 
[email protected]("/revoke-user-tokens")
[email protected](RevokeUserTokensForm)
[email protected]
 async def revoke_user_tokens_post(
-    session: web.Committer, revoke_form: RevokeUserTokensForm
+    session: web.Committer, _revoke_user_tokens: 
Literal["revoke-user-tokens"], revoke_form: RevokeUserTokensForm
 ) -> str | web.WerkzeugResponse:
-    """Revoke all Personal Access Tokens for a specified user."""
+    """
+    URL: POST /revoke-user-tokens
+
+    Revoke all Personal Access Tokens for a specified user.
+    """
     target_uid = revoke_form.asf_uid
 
     async with storage.write(session) as write:
@@ -777,8 +895,11 @@ async def revoke_user_tokens_post(
     return await session.redirect(revoke_user_tokens_get)
 
 
[email protected]("/rotate-jwt-key")
-async def rotate_jwt_key_get(session: web.Committer) -> str:
[email protected]
+async def rotate_jwt_key_get(_session: web.Committer, _rotate_jwt_key: 
Literal["rotate-jwt-key"]) -> str:
+    """
+    URL: GET /rotate-jwt-key
+    """
     rendered_form = form.render(
         model_cls=RotateJwtKeyForm,
         submit_label="Rotate JWT key",
@@ -786,9 +907,13 @@ async def rotate_jwt_key_get(session: web.Committer) -> 
str:
     return await _rotate_jwt_key_page(rendered_form)
 
 
[email protected]("/rotate-jwt-key")
[email protected](RotateJwtKeyForm)
-async def rotate_jwt_key_post(session: web.Committer, _rotate_form: 
RotateJwtKeyForm) -> str | web.WerkzeugResponse:
[email protected]
+async def rotate_jwt_key_post(
+    session: web.Committer, _rotate_jwt_key: Literal["rotate-jwt-key"], 
_rotate_form: RotateJwtKeyForm
+) -> str | web.WerkzeugResponse:
+    """
+    URL: POST /rotate-jwt-key
+    """
     async with storage.write(session) as write:
         wafa = write.as_foundation_admin()
         await wafa.tokens.rotate_jwt_signing_key()
@@ -796,16 +921,23 @@ async def rotate_jwt_key_post(session: web.Committer, 
_rotate_form: RotateJwtKey
     return await session.redirect(rotate_jwt_key_get)
 
 
[email protected]("/task-times/<project_key>/<version_key>/<revision_number>")
[email protected]
 async def task_times(
-    session: web.Committer, project_key: str, version_key: str, 
revision_number: str
+    _session: web.Committer,
+    _task_times: Literal["task-times"],
+    project_key: safe.ProjectKey,
+    version_key: safe.VersionKey,
+    revision_number: safe.RevisionNumber,
 ) -> web.QuartResponse:
+    """
+    URL: GET /task-times/<project_key>/<version_key>/<revision_number>
+    """
     values = []
     async with db.session() as data:
         tasks = await data.task(
-            project_key=project_key,
-            version_key=version_key,
-            revision_number=revision_number,
+            project_key=str(project_key),
+            version_key=str(version_key),
+            revision_number=str(revision_number),
         ).all()
         for task in tasks:
             if (task.started is None) or (task.completed is None):
@@ -816,9 +948,13 @@ async def task_times(
     return web.TextResponse("\n".join(values))
 
 
[email protected]("/tasks/recent/<int:minutes>")
-async def tasks_recent(session: web.Committer, minutes: int) -> str:
-    """Display tasks from the last N minutes."""
[email protected]
+async def tasks_recent(_session: web.Committer, _tasks_recent: 
Literal["tasks/recent"], minutes: int) -> str:
+    """
+    URL: GET /tasks/recent/<int:minutes>
+
+    Display tasks from the last N minutes.
+    """
     if minutes < 1:
         minutes = 1
     if minutes > 1440:
@@ -929,9 +1065,13 @@ async def tasks_recent(session: web.Committer, minutes: 
int) -> str:
     return await template.blank(f"Recent Tasks ({minutes}m)", 
content=page.collect())
 
 
[email protected]("/toggle-view")
-async def toggle_view_get(session: web.Committer) -> str:
-    """Display the page with a button to toggle between admin and user 
views."""
[email protected]
+async def toggle_view_get(_session: web.Committer, _toggle_view: 
Literal["toggle-view"]) -> str:
+    """
+    URL: GET /toggle-view
+
+    Display the page with a button to toggle between admin and user views.
+    """
     rendered_form = form.render(
         model_cls=form.Empty,
         submit_label="Toggle view",
@@ -941,10 +1081,15 @@ async def toggle_view_get(session: web.Committer) -> str:
     return await template.render("toggle-admin-view.html", 
empty_form=rendered_form)
 
 
[email protected]("/toggle-view")
[email protected]()
-async def toggle_view_post(session: web.Committer) -> web.WerkzeugResponse:
-    """Toggle between admin and user views."""
[email protected]
+async def toggle_view_post(
+    _session: web.Committer, _toggle_view: Literal["toggle-view"], _form: 
form.Empty
+) -> web.WerkzeugResponse:
+    """
+    URL: POST /toggle-view
+
+    Toggle between admin and user views.
+    """
     app = asfquart.APP
     if (not hasattr(app, "app_id")) or (not isinstance(app.app_id, str)):
         raise TypeError("Internal error: APP has no valid app_id")
@@ -962,9 +1107,13 @@ async def toggle_view_post(session: web.Committer) -> 
web.WerkzeugResponse:
     return quart.redirect("https://"; + quart.request.host + "/")
 
 
[email protected]("/validate")
-async def validate_(session: web.Committer) -> str:
-    """Run validators and display any divergences."""
[email protected]
+async def validate_(_session: web.Committer, _validate: Literal["validate"]) 
-> str:
+    """
+    URL: GET /validate
+
+    Run validators and display any divergences.
+    """
 
     async with db.session() as data:
         divergences = [d async for d in validate.everything(data)]
@@ -975,9 +1124,15 @@ async def validate_(session: web.Committer) -> str:
     )
 
 
[email protected]("/validate-jwt")
-async def validate_jwt_get(session: web.Committer) -> str:
-    _require_debug_and_allow_tests()
[email protected]
+async def validate_jwt_get(_session: web.Committer, _validate_jwt: 
Literal["validate-jwt"]) -> str:
+    """
+    URL: GET /validate-jwt
+    """
+    try:
+        _require_debug_and_allow_tests()
+    except base.ASFQuartException:
+        return quart.abort(404)
     rendered_form = form.render(
         model_cls=ValidateJwtForm,
         submit_label="Validate JWT",
@@ -986,10 +1141,18 @@ async def validate_jwt_get(session: web.Committer) -> 
str:
     return await _validate_jwt_page(rendered_form, result=None)
 
 
[email protected]("/validate-jwt")
[email protected](ValidateJwtForm)
-async def validate_jwt_post(session: web.Committer, validate_form: 
ValidateJwtForm) -> str:
-    _require_debug_and_allow_tests()
[email protected]
+async def validate_jwt_post(
+    _session: web.Committer, _validate_jwt: Literal["validate-jwt"], 
validate_form: ValidateJwtForm
+) -> str:
+    """
+    URL: POST /validate-jwt
+    """
+    try:
+        _require_debug_and_allow_tests()
+    except base.ASFQuartException:
+        return quart.abort(404)
+
     token = validate_form.token
     result: dict[str, Any] = {"token_length": len(token), "valid": False}
 
@@ -1044,7 +1207,7 @@ async def _check_keys(fix: bool = False) -> str:
     return message
 
 
-async def _data(session: web.Committer, model: str = "Committee") -> str:
+async def _data_browse(_session: web.Committer, model: str = "Committee") -> 
str:
     """Browse all records in the database."""
     async with db.session() as data:
         # Map of model names to their classes
@@ -1175,8 +1338,8 @@ async def 
_get_filesystem_dirs_unfinished(filesystem_dirs: list[str]) -> None:
                         filesystem_dirs.append(version_dir_path)
 
 
-async def _ongoing_tasks(
-    session: web.Committer,
+async def _fetch_ongoing_tasks(
+    _session: web.Committer,
     project_key: safe.ProjectKey,
     version_key: safe.VersionKey,
     revision: safe.RevisionNumber,
diff --git a/atr/blueprints/admin.py b/atr/blueprints/admin.py
index a6f93ae9..5ef56003 100644
--- a/atr/blueprints/admin.py
+++ b/atr/blueprints/admin.py
@@ -14,23 +14,27 @@
 # KIND, either express or implied.  See the License for the
 # specific language governing permissions and limitations
 # under the License.
-
 import json
+import time
 from collections.abc import Awaitable, Callable
 from types import ModuleType
-from typing import Any
+from typing import Any, Concatenate, overload
 
 import asfquart.base as base
 import asfquart.session
 import pydantic
 import quart
+import quart_schema
 
+import atr.blueprints.common as common
 import atr.form
+import atr.log as log
 import atr.user as user
 import atr.web as web
 
 _BLUEPRINT_NAME = "admin_blueprint"
 _BLUEPRINT = quart.Blueprint(_BLUEPRINT_NAME, __name__, url_prefix="/admin", 
template_folder="../admin/templates")
+_routes: list[str] = []
 
 
 def empty() -> Callable[[Callable[..., Awaitable[Any]]], Callable[..., 
Awaitable[Any]]]:
@@ -131,7 +135,125 @@ def register(app: base.QuartApp) -> tuple[ModuleType, 
list[str]]:
     import atr.admin as admin
 
     app.register_blueprint(_BLUEPRINT)
-    return admin, []
+    return admin, _routes
+
+
+@overload
+def typed[**P, R](func: Callable[Concatenate[web.Committer, P], Awaitable[R]]) 
-> web.RouteFunction[R]: ...
+
+
+@overload
+def typed[**P, R](func: Callable[Concatenate[web.Public, P], Awaitable[R]]) -> 
web.RouteFunction[R]: ...  # pyright: ignore[reportOverlappingOverload]
+
+
+def typed(func: Callable[..., Any]) -> Callable[..., Any]:
+    """Decorator that derives the URL path from the function's type 
annotations.
+
+    - Literal["..."] parameters become literal path segments
+    - safe.ProjectName / safe.VersionName parameters are validated via cache/DB
+    - pydantic.BaseModel subclass parameters are parsed from the JSON request 
body
+    - dataclass parameters are parsed from the query string
+    - str | None parameters create optional URL segments (two routes 
registered)
+    - int, float, str use Quart's built-in type converters
+    - HTTP method is POST if a body param is present, GET otherwise
+    """
+    path, validated_params, literal_params, body_param, form_param, 
query_param, optional_params = (
+        common.build_api_path(func)
+    )
+    method = "POST" if (body_param is not None or form_param is not None) else 
"GET"
+    body_safe_params = common.safe_params_for_type(body_param[1]) if 
body_param is not None else []
+    form_safe_params = common.safe_params_for_type(form_param[1]) if 
form_param is not None else []
+    query_safe_params = common.safe_params_for_type(query_param[1]) if 
query_param is not None else []
+
+    async def wrapper(*_args: Any, **kwargs: Any) -> Any:
+        enhanced_session = await common.authenticate()
+        await common.validate_params(kwargs, validated_params)
+        kwargs.update(literal_params)
+
+        if body_param is not None:
+            await common.parse_body(body_param, body_safe_params, kwargs)
+
+        if form_param is not None:
+            form_param_name, form_cls = form_param
+            context: dict[str, Any] = {"kwargs": kwargs, "session": 
enhanced_session}
+            try:
+                kwargs[form_param_name] = await 
enhanced_session.form_validate(form_cls, context)
+            except pydantic.ValidationError as e:
+                errors = e.errors()
+                if len(errors) == 0:
+                    raise RuntimeError("Validation failed, but no errors were 
reported")
+                form_data_raw = await atr.form.quart_request()
+                flash_data = atr.form.flash_error_data(form_cls, errors, 
form_data_raw)
+                summary = atr.form.flash_error_summary(errors, flash_data)
+                await quart.flash(summary, category="error")
+                await quart.flash(json.dumps(flash_data), 
category="form-error-data")
+                return quart.redirect(quart.request.path)
+            if form_safe_params:
+                await common.validate_safe_fields(kwargs[form_param_name], 
form_safe_params, kwargs)
+
+        if query_param is not None:
+            await common.parse_query(query_param, query_safe_params, kwargs)
+
+        start_time_ns = time.perf_counter_ns()
+        response = await func(enhanced_session, **kwargs)
+        end_time_ns = time.perf_counter_ns()
+        total_ms = (end_time_ns - start_time_ns) // 1_000_000
+        log.performance(f"API {method} {path} {func.__name__} = 0 0 
{total_ms}")
+
+        return response
+
+    endpoint = func.__module__.replace(".", "_") + "_" + func.__name__
+    wrapper.__name__ = func.__name__
+    wrapper.__doc__ = func.__doc__
+    wrapper.__annotations__["endpoint"] = _BLUEPRINT_NAME + "." + endpoint
+
+    # Replace the original quart request decorators
+    if query_param is not None:
+        wrapper = quart_schema.validate_querystring(query_param[1])(wrapper)
+    if body_param is not None:
+        wrapper = quart_schema.validate_request(body_param[1])(wrapper)
+
+    # Examine `func` for quart attributes and re-attach to the wrapped function
+    # This makes sure the OpenAPI documentation is preserved
+    # Note: we don't update querystring or request as they're processed above 
using our detected types
+    _copy_quart_attributes(func, wrapper)
+
+    # If there are optional params, we need two routes, one with the optional 
params omitted
+    # and one with them all present.
+    # AM 26/03/03: This actually only handles the case where there's some 
required and a single optional, but
+    # that's the only case that existed in the original code. Theoretically we 
could count the optional params and
+    # generate the correct number of routes, but that's lot of effort for 
little gain right now
+    _add_url_rules(wrapper, path, endpoint, method, optional_params)
+
+    common.register_route(func, "api", _routes)
+    return wrapper
+
+
+def _add_url_rules(
+    wrapper: Callable[..., Any],
+    path: str,
+    endpoint: str,
+    method: str,
+    optional_params: list[str],
+) -> None:
+    """Register URL rules for the wrapper, handling optional path params with 
a default short route."""
+    if optional_params:
+        required_segments = [
+            seg for seg in path.strip("/").split("/") if not any(seg == 
f"<{name}>" for name in optional_params)
+        ]
+        short_path = "/" + "/".join(required_segments)
+        defaults = {name: None for name in optional_params}
+        _BLUEPRINT.add_url_rule(short_path, endpoint=endpoint, 
view_func=wrapper, methods=[method], defaults=defaults)
+        _BLUEPRINT.add_url_rule(path, endpoint=endpoint + "_full", 
view_func=wrapper, methods=[method])
+    else:
+        _BLUEPRINT.add_url_rule(path, endpoint=endpoint, view_func=wrapper, 
methods=[method])
+
+
+def _copy_quart_attributes(src: Callable[..., Any], dst: Callable[..., Any]) 
-> None:
+    """Copy quart schema attributes from src to dst to preserve OpenAPI 
documentation."""
+    for attr in common.QUART_ATTRIBUTES:
+        if hasattr(src, attr):
+            setattr(dst, attr, getattr(src, attr))
 
 
 @_BLUEPRINT.before_request
diff --git a/atr/blueprints/api.py b/atr/blueprints/api.py
index f04428cb..e335072b 100644
--- a/atr/blueprints/api.py
+++ b/atr/blueprints/api.py
@@ -14,12 +14,11 @@
 # KIND, either express or implied.  See the License for the
 # specific language governing permissions and limitations
 # under the License.
-import dataclasses
 import datetime
 import inspect
 import sys
 import time
-from collections.abc import Callable
+from collections.abc import Awaitable, Callable
 from types import ModuleType
 from typing import Any
 
@@ -33,19 +32,11 @@ import werkzeug.exceptions as exceptions
 
 import atr.blueprints.common as common
 import atr.log as log
+import atr.web as web
 
 _BLUEPRINT_NAME = "api_blueprint"
 _BLUEPRINT = quart.Blueprint(_BLUEPRINT_NAME, __name__, url_prefix="/api")
 _routes: list[str] = []
-_QUART_ATTRIBUTES = [
-    quart_schema.validation.QUART_SCHEMA_HEADERS_ATTRIBUTE,
-    quart_schema.validation.QUART_SCHEMA_RESPONSE_ATTRIBUTE,
-    quart_schema.openapi.QUART_SCHEMA_SECURITY_ATTRIBUTE,
-    quart_schema.openapi.QUART_SCHEMA_TAG_ATTRIBUTE,
-    quart_schema.openapi.QUART_SCHEMA_HIDDEN_ATTRIBUTE,
-    quart_schema.openapi.QUART_SCHEMA_DEPRECATED_ATTRIBUTE,
-    quart_schema.openapi.QUART_SCHEMA_OPERATION_ID_ATTRIBUTE,
-]
 
 
 def register(app: base.QuartApp) -> tuple[ModuleType, list[str]]:
@@ -55,9 +46,10 @@ def register(app: base.QuartApp) -> tuple[ModuleType, 
list[str]]:
     return api, _routes
 
 
-def typed(func: Callable[..., Any]) -> Callable[..., Any]:
+def typed(func: Callable[..., Awaitable[Any]]) -> web.RouteFunction[Any]:
     """Decorator that derives the URL path from the function's type 
annotations.
 
+    - Arguments after session are joined with / to make the web path
     - Literal["..."] parameters become literal path segments
     - safe.ProjectName / safe.VersionName parameters are validated via cache/DB
     - pydantic.BaseModel subclass parameters are parsed from the JSON request 
body
@@ -67,7 +59,9 @@ def typed(func: Callable[..., Any]) -> Callable[..., Any]:
     - HTTP method is POST if a body param is present, GET otherwise
     """
     original = inspect.unwrap(func)
-    path, validated_params, literal_params, body_param, query_param, 
optional_params = common.build_api_path(original)
+    path, validated_params, literal_params, body_param, _, query_param, 
optional_params = common.build_api_path(
+        original
+    )
     method = "POST" if body_param is not None else "GET"
     body_safe_params = common.safe_params_for_type(body_param[1]) if 
body_param is not None else []
     query_safe_params = common.safe_params_for_type(query_param[1]) if 
query_param is not None else []
@@ -77,22 +71,10 @@ def typed(func: Callable[..., Any]) -> Callable[..., Any]:
         kwargs.update(literal_params)
 
         if body_param is not None:
-            body_name, body_cls = body_param
-            json_data = await quart.request.get_json()
-            try:
-                body_instance = body_cls.model_validate(json_data)
-            except pydantic.ValidationError as e:
-                raise quart_schema.RequestSchemaValidationError(e) from e
-            if body_safe_params:
-                await common.validate_safe_fields(body_instance, 
body_safe_params, kwargs)
-            kwargs[body_name] = body_instance
+            await common.parse_body(body_param, body_safe_params, kwargs)
 
         if query_param is not None:
-            query_name, query_cls = query_param
-            query_instance = _parse_query_args(query_cls, quart.request.args)
-            if query_safe_params:
-                await common.validate_safe_fields(query_instance, 
query_safe_params, kwargs)
-            kwargs[query_name] = query_instance
+            await common.parse_query(query_param, query_safe_params, kwargs)
 
         start_time_ns = time.perf_counter_ns()
         response = await func(**kwargs)
@@ -157,7 +139,7 @@ def _add_url_rules(
 
 def _copy_quart_attributes(src: Callable[..., Any], dst: Callable[..., Any]) 
-> None:
     """Copy quart schema attributes from src to dst to preserve OpenAPI 
documentation."""
-    for attr in _QUART_ATTRIBUTES:
+    for attr in common.QUART_ATTRIBUTES:
         if hasattr(src, attr):
             setattr(dst, attr, getattr(src, attr))
 
@@ -197,35 +179,6 @@ async def _handle_request_validation(err: 
quart_schema.RequestSchemaValidationEr
     return _json_error("Input validation failed", 400, {"validation_details": 
verr.errors()})
 
 
-def _coerce_query_field(raw: str, field_type: Any, field_name: str) -> Any:
-    """Coerce a raw query string value to the expected field type."""
-    if field_type is str or field_type == "str":
-        return raw
-    if field_type is int or field_type == "int":
-        try:
-            return int(raw)
-        except ValueError:
-            raise exceptions.BadRequest(f"Query parameter {field_name!r} must 
be an integer")
-    if field_type is bool or field_type == "bool":
-        return raw.lower() in ("true", "1", "yes")
-    return raw
-
-
-def _parse_query_args(query_cls: type, args: Any) -> Any:
-    """Parse query string parameters into a dataclass instance."""
-    field_values: dict[str, Any] = {}
-    for field in dataclasses.fields(query_cls):
-        raw = args.get(field.name)
-        if raw is None:
-            if field.default is not dataclasses.MISSING:
-                field_values[field.name] = field.default
-            elif field.default_factory is not dataclasses.MISSING:
-                field_values[field.name] = field.default_factory()
-            continue
-        field_values[field.name] = _coerce_query_field(raw, field.type, 
field.name)
-    return query_cls(**field_values)
-
-
 def _json_error(
     message: str, status_code: int | None, extra: dict[str, Any] | None = None
 ) -> tuple[quart.Response, int]:
diff --git a/atr/blueprints/common.py b/atr/blueprints/common.py
index ee69be5a..8457a9ab 100644
--- a/atr/blueprints/common.py
+++ b/atr/blueprints/common.py
@@ -25,6 +25,9 @@ from typing import Annotated, Any, Literal, TypeAliasType, 
get_args, get_origin,
 import asfquart.base as base
 import asfquart.session
 import pydantic
+import quart
+import quart_schema
+import werkzeug.exceptions as exceptions
 
 import atr.form as form
 import atr.ldap as ldap
@@ -89,7 +92,7 @@ def build_path(
     segments: list[str] = []
     validated_params: list[tuple[str, type]] = []
     literal_params: dict[str, str] = {}
-    form_param: tuple[str, type] | None = None
+    unique = _UniqueParams()
 
     for ix, param_name in enumerate(params):
         hint = hints.get(param_name)
@@ -102,16 +105,13 @@ def build_path(
             public = hint is web.Public
             continue
 
-        if _is_form_type(hint):
-            if form_param is not None:
-                raise TypeError(f"Parameter {param_name!r} in {func.__name__}: 
only one Form is allowed")
-            form_param = (param_name, hint)
+        if unique.check(hint, param_name, func.__name__):
             continue
 
         _classify_url_param(param_name, hint, func.__name__, segments, 
validated_params, literal_params)
 
     path = "/" + "/".join(segments)
-    return path, validated_params, literal_params, form_param, public
+    return path, validated_params, literal_params, unique.form, public
 
 
 def build_api_path(
@@ -122,6 +122,7 @@ def build_api_path(
     dict[str, str],
     tuple[str, type[pydantic.BaseModel]] | None,
     tuple[str, type] | None,
+    tuple[str, type] | None,
     list[str],
 ]:
     """Inspect a function's type hints to build a URL path for an API route.
@@ -144,25 +145,20 @@ def build_api_path(
     segments: list[str] = []
     validated_params: list[tuple[str, type]] = []
     literal_params: dict[str, str] = {}
-    body_param: tuple[str, type[pydantic.BaseModel]] | None = None
-    query_param: tuple[str, type] | None = None
+    unique = _UniqueParams()
     optional_params: list[str] = []
 
-    for param_name in params:
+    for ix, param_name in enumerate(params):
         hint = hints.get(param_name)
         if hint is None:
             raise TypeError(f"Parameter {param_name!r} in {func.__name__} has 
no type annotation")
 
-        if _is_body_type(hint):
-            if body_param is not None:
-                raise TypeError(f"Parameter {param_name!r} in {func.__name__}: 
only one body type is allowed")
-            body_param = (param_name, hint)
+        if (hint is web.Public) or (hint is web.Committer):
+            if ix != 0:
+                raise TypeError(f"Parameter {param_name!r} in {func.__name__} 
must be first")
             continue
 
-        if _is_query_type(hint):
-            if query_param is not None:
-                raise TypeError(f"Parameter {param_name!r} in {func.__name__}: 
only one query type is allowed")
-            query_param = (param_name, hint)
+        if unique.check(hint, param_name, func.__name__):
             continue
 
         inner, is_optional = _unwrap_optional(hint)
@@ -175,7 +171,7 @@ def build_api_path(
         _classify_url_param(param_name, hint, func.__name__, segments, 
validated_params, literal_params)
 
     path = "/" + "/".join(segments)
-    return path, validated_params, literal_params, body_param, query_param, 
optional_params
+    return path, validated_params, literal_params, unique.body, unique.form, 
unique.query, optional_params
 
 
 def register_route(func: Callable[..., Any], prefix: str, routes: list[str]) 
-> None:
@@ -236,6 +232,96 @@ async def validate_safe_fields(
             setattr(instance, name, temp[name])
 
 
+async def parse_body(
+    body_param: tuple[str, type[pydantic.BaseModel]],
+    safe_params: list[tuple[str, type]],
+    kwargs: dict[str, Any],
+) -> None:
+    """Parse and validate a JSON body parameter, adding it to kwargs."""
+    body_name, body_cls = body_param
+    json_data = await quart.request.get_json()
+    try:
+        body_instance = body_cls.model_validate(json_data)
+    except pydantic.ValidationError as e:
+        raise quart_schema.RequestSchemaValidationError(e) from e
+    if safe_params:
+        await validate_safe_fields(body_instance, safe_params, kwargs)
+    kwargs[body_name] = body_instance
+
+
+async def parse_query(
+    query_param: tuple[str, type],
+    safe_params: list[tuple[str, type]],
+    kwargs: dict[str, Any],
+) -> None:
+    """Parse and validate query string parameters, adding them to kwargs."""
+    query_name, query_cls = query_param
+    query_instance = _parse_query_args(query_cls, quart.request.args)
+    if safe_params:
+        await validate_safe_fields(query_instance, safe_params, kwargs)
+    kwargs[query_name] = query_instance
+
+
+def _coerce_query_field(raw: str, field_type: Any, field_name: str) -> Any:
+    """Coerce a raw query string value to the expected field type."""
+    if field_type is str or field_type == "str":
+        return raw
+    if field_type is int or field_type == "int":
+        try:
+            return int(raw)
+        except ValueError:
+            raise exceptions.BadRequest(f"Query parameter {field_name!r} must 
be an integer")
+    if field_type is bool or field_type == "bool":
+        return raw.lower() in ("true", "1", "yes")
+    return raw
+
+
+def _parse_query_args(query_cls: type, args: Any) -> Any:
+    """Parse query string parameters into a dataclass instance."""
+    field_values: dict[str, Any] = {}
+    for field in dataclasses.fields(query_cls):
+        raw = args.get(field.name)
+        if raw is None:
+            if field.default is not dataclasses.MISSING:
+                field_values[field.name] = field.default
+            elif field.default_factory is not dataclasses.MISSING:
+                field_values[field.name] = field.default_factory()
+            continue
+        field_values[field.name] = _coerce_query_field(raw, field.type, 
field.name)
+    return query_cls(**field_values)
+
+
[email protected]
+class _UniqueParams:
+    """Tracks the at-most-one body, form, and query parameters during path 
building."""
+
+    body: tuple[str, type[pydantic.BaseModel]] | None = None
+    form: tuple[str, type] | None = None
+    query: tuple[str, type] | None = None
+
+    def check(self, hint: Any, param_name: str, func_name: str) -> bool:
+        """If hint is a body/form/query type, store it (ensuring uniqueness). 
Return True if matched."""
+        if _is_body_type(hint):
+            if self.body is not None:
+                raise TypeError(f"Parameter {param_name!r} in {func_name}: 
only one body type is allowed")
+            self.body = (param_name, hint)
+            return True
+
+        if _is_form_type(hint):
+            if self.form is not None:
+                raise TypeError(f"Parameter {param_name!r} in {func_name}: 
only one Form is allowed")
+            self.form = (param_name, hint)
+            return True
+
+        if _is_query_type(hint):
+            if self.query is not None:
+                raise TypeError(f"Parameter {param_name!r} in {func_name}: 
only one query type is allowed")
+            self.query = (param_name, hint)
+            return True
+
+        return False
+
+
 def _classify_url_param(
     param_name: str,
     hint: Any,
@@ -309,3 +395,14 @@ def _unwrap_optional(hint: Any) -> tuple[Any, bool]:
     if len(non_none) == 1 and type(None) in args:
         return non_none[0], True
     return hint, False
+
+
+QUART_ATTRIBUTES = [
+    quart_schema.validation.QUART_SCHEMA_HEADERS_ATTRIBUTE,
+    quart_schema.validation.QUART_SCHEMA_RESPONSE_ATTRIBUTE,
+    quart_schema.openapi.QUART_SCHEMA_SECURITY_ATTRIBUTE,
+    quart_schema.openapi.QUART_SCHEMA_TAG_ATTRIBUTE,
+    quart_schema.openapi.QUART_SCHEMA_HIDDEN_ATTRIBUTE,
+    quart_schema.openapi.QUART_SCHEMA_DEPRECATED_ATTRIBUTE,
+    quart_schema.openapi.QUART_SCHEMA_OPERATION_ID_ATTRIBUTE,
+]
diff --git a/atr/get/test.py b/atr/get/test.py
index d5d98521..f1ccba41 100644
--- a/atr/get/test.py
+++ b/atr/get/test.py
@@ -21,6 +21,7 @@ from typing import Literal
 
 import aiofiles
 import asfquart.base as base
+import quart
 import werkzeug.wrappers.response as response
 
 import atr.blueprints.get as get
@@ -68,7 +69,7 @@ async def test_login(_session: web.Public, _test_login: 
Literal["test/login"]) -
     URL: /test/login
     """
     if not config.get().ALLOW_TESTS:
-        return await web.redirect(root.notfound)
+        return quart.abort(404)
 
     session_data = atr.models.session.CookieData(
         uid="test",
@@ -96,7 +97,7 @@ async def test_merge(
     URL: /test/merge/<project_key>/<version_key>
     """
     if not config.get().ALLOW_TESTS:
-        raise base.ASFQuartException("Test routes not enabled", errorcode=404)
+        return quart.abort(404)
 
     async with storage.write(session) as write_n:
         wacp_n = await write_n.as_project_committee_participant(project_key)
@@ -213,7 +214,7 @@ async def test_vote(
     URL: /test/vote/<category>/<project_key>/<version_key>
     """
     if not config.get().ALLOW_TESTS:
-        raise base.ASFQuartException("Test routes not enabled", errorcode=404)
+        return quart.abort(404)
 
     category_map = {
         "unauthenticated": vote.UserCategory.UNAUTHENTICATED,
diff --git a/tests/unit/test_blueprints.py b/tests/unit/test_blueprints.py
new file mode 100644
index 00000000..af268bfe
--- /dev/null
+++ b/tests/unit/test_blueprints.py
@@ -0,0 +1,62 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+import asfquart
+import pytest
+
+import atr.blueprints as blueprints
+import atr.util as util
+
+_TESTED_BLUEPRINTS = frozenset({"get_blueprint", "post_blueprint", 
"admin_blueprint"})
+
+
[email protected]
+async def test_all_routes_support_url_construction(monkeypatch):
+    # Prevent writing routes.json to a real directory
+    monkeypatch.setattr("atr.blueprints._export_routes", lambda _: None)
+    # We don't need a functional .APP for this test but we do need it reset 
afterwards
+    monkeypatch.setattr("asfquart.APP", None)
+
+    app = asfquart.construct("test")
+    blueprints.register(app)
+
+    failures: list[str] = []
+
+    async with app.test_request_context("/"):
+        for rule in app.url_map.iter_rules():
+            blueprint_name = rule.endpoint.rsplit(".", 1)[0] if "." in 
rule.endpoint else None
+            if blueprint_name not in _TESTED_BLUEPRINTS:
+                continue
+
+            view_func = app.view_functions[rule.endpoint]
+
+            # Build dummy kwargs so url_for can construct the URL
+            kwargs = {}
+            for arg in rule.arguments:
+                converter = rule._converters.get(arg)
+                if converter is not None and type(converter).__name__ == 
"IntegerConverter":
+                    kwargs[arg] = 1
+                else:
+                    kwargs[arg] = "test"
+
+            try:
+                util.as_url(view_func, **kwargs)
+            except Exception as e:
+                failures.append(f"{rule.endpoint} ({rule.rule}): {e}")
+
+    if failures:
+        raise AssertionError("Routes incompatible with as_url:\n" + 
"\n".join(failures))


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

Reply via email to