From 778dd5de52bf112d84b05ce82d83ce2204dde018 Mon Sep 17 00:00:00 2001 From: mvdbeek Date: Mon, 27 Apr 2026 09:52:46 +0200 Subject: [PATCH] Fix admin resend-activation email missing hostname In a FastAPI request, UrlBuilder's NoMatchFound fallback called web.url_for without the popped qualified flag, and routes.url_for has no host available in an ASGI context. The admin resend-activation endpoint hits this path (since /user/activate is a WSGI-only @web.expose route) and produced links like http:///user/activate?... . Latent in the FastAPI endpoint since 72d5cceda8f ("Add mail activation operation to api"); surfaced for users after c9b849d934a bumped fastapi 0.135.3 -> 0.136.0. Qualify the fallback URL with request.base_url when qualified=True. Integration test posts to /api/users/{user_id}/send_activation_email as admin with mock SMTP capture, then asserts the captured email body contains a fully-qualified activation link with a non-empty host. Would have caught the regression that produced http:///user/activate?... . --- lib/galaxy/webapps/galaxy/api/__init__.py | 9 +++++-- test/integration/test_users.py | 31 +++++++++++++++++++++++ 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/lib/galaxy/webapps/galaxy/api/__init__.py b/lib/galaxy/webapps/galaxy/api/__init__.py index d95e8939f87..bb8ee853fc0 100644 --- a/lib/galaxy/webapps/galaxy/api/__init__.py +++ b/lib/galaxy/webapps/galaxy/api/__init__.py @@ -228,10 +228,15 @@ class UrlBuilder: url = f"{url}?{urlencode(query_params)}" return url except NoMatchFound: - # Fallback to legacy url_for + # Fallback to legacy WSGI url_for for routes not registered with FastAPI if query_params: path_params.update(query_params) - return web.url_for(name, **path_params) + url = web.url_for(name, **path_params) + if qualified and not url.startswith(("http://", "https://")): + # routes.url_for has no thread-local request_config in an ASGI + # request, so qualify the URL using the FastAPI request base_url. + url = str(self.request.base_url).rstrip("/") + url + return url def _url_path_for(self, name: str, **path_params) -> str: """O(1) route lookup using the app's pre-built name index. diff --git a/test/integration/test_users.py b/test/integration/test_users.py index 5c40d980e44..dc1cc2edc0d 100644 --- a/test/integration/test_users.py +++ b/test/integration/test_users.py @@ -1,3 +1,6 @@ +import json +import os +import re from typing import ( ClassVar, ) @@ -91,3 +94,31 @@ class TestUnexposedUsersIntegration(UsersIntegrationCase): # And the current user has all fields, so no limited fields. expected_limited_user_keys = set() expected_regular_user_list_count = 1 + + +class TestAdminResendActivationEmail(integration_util.IntegrationTestCase): + email_directory: ClassVar[str] + + @classmethod + def handle_galaxy_config_kwds(cls, config): + super().handle_galaxy_config_kwds(config) + cls.email_directory = cls._test_driver.mkdtemp() + config["user_activation_on"] = True + config["activation_grace_period"] = 3 + config["email_from"] = "galaxy-noreply@example.com" + config["smtp_server"] = f"mock_emails_to_path://{cls.email_directory}/email.json" + + def test_resend_activation_includes_qualified_link(self): + user = self._setup_user("resend-activation@test.gx") + response = self._post(f"users/{user['id']}/send_activation_email", admin=True) + self._assert_status_code_is_ok(response) + + with open(os.path.join(self.email_directory, "email.json")) as f: + email = json.loads(f.read()) + assert email["to"] == "resend-activation@test.gx" + assert email["subject"] == "Galaxy Account Activation" + match = re.search(r"(https?://[^/\s]+/user/activate\?[^\s]+)", email["body"]) + assert match, f"No qualified activation link found in email body:\n{email['body']}" + link = match.group(1) + assert "activation_token=" in link + assert "email=" in link