From 5d68fcf50dc9917ccedc9d1692d00031674cba4e Mon Sep 17 00:00:00 2001 From: marius-mather Date: Tue, 3 Jun 2025 15:23:30 +1000 Subject: [PATCH 01/15] Add config for galaxy --- galaxy/__init__.py | 0 galaxy/config.py | 21 +++++++++++++++++++++ 2 files changed, 21 insertions(+) create mode 100644 galaxy/__init__.py create mode 100644 galaxy/config.py diff --git a/galaxy/__init__.py b/galaxy/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/galaxy/config.py b/galaxy/config.py new file mode 100644 index 00000000..54ce2c41 --- /dev/null +++ b/galaxy/config.py @@ -0,0 +1,21 @@ +from functools import lru_cache + +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class GalaxySettings(BaseSettings): + """ + Settings for the Galaxy API. + + Note: these are currently read from the same .env file + as other settings (which pydantic-settings supports). + """ + galaxy_url: str + galaxy_api_key: str + + model_config = SettingsConfigDict(env_file=".env") + + +@lru_cache +def get_galaxy_settings(): + return GalaxySettings() From d3ce911f7b4bfce412befc46106e8e6ff2e2e1db Mon Sep 17 00:00:00 2001 From: marius-mather Date: Tue, 3 Jun 2025 15:36:07 +1000 Subject: [PATCH 02/15] Add galaxy vars to .env.example --- .env.example | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.env.example b/.env.example index 8bf1758a..ebc70610 100644 --- a/.env.example +++ b/.env.example @@ -8,6 +8,9 @@ AUTH0_AUDIENCE=https://audience.com/api JWT_SECRET_KEY=secret-key # Note the list syntax pydantic-settings uses ADMIN_ROLES='["Admin", "GalaxyAdmin"]' +# URL of Galaxy instance, for making calls to Galaxy API +GALAXY_URL=https://galaxy.example.com +GALAXY_API_KEY=api-key # Comma-separated list of allowed origins. Note we # don't process this with pydantic-settings as it needs # to be used before the FastAPI app loads From fd2e51727737a443337d6ea75a6f92af785295b3 Mon Sep 17 00:00:00 2001 From: marius-mather Date: Fri, 6 Jun 2025 16:02:58 +1000 Subject: [PATCH 03/15] Add galaxy client for API calls to Galaxy --- galaxy/client.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 galaxy/client.py diff --git a/galaxy/client.py b/galaxy/client.py new file mode 100644 index 00000000..aa1686fc --- /dev/null +++ b/galaxy/client.py @@ -0,0 +1,25 @@ +from httpx import Client + +from galaxy.schemas import GalaxyUserModel + + +class GalaxyClient: + + def __init__(self, galaxy_url: str, api_key: str): + self.url = galaxy_url + self.api_key = api_key + self.client = Client(base_url=self.url, headers={'x-api-key': self.api_key}) + + def username_exists(self, username: str) -> bool: + """ + Check if a username already exists in Galaxy. + Note the user search in the current Galaxy API will return + partial matches, so we need to check the returned users + for an exact match. + """ + resp = self.client.get("/api/users", params={"f_name": username}) + returned_users = [GalaxyUserModel(**u) for u in resp.json()] + for user in returned_users: + if user.username == username: + return True + return False From 3723d923c1b2734997a72d88f5f1cad0faf360b3 Mon Sep 17 00:00:00 2001 From: marius-mather Date: Fri, 6 Jun 2025 16:03:14 +1000 Subject: [PATCH 04/15] Schemas for data returned from galaxy --- galaxy/schemas.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 galaxy/schemas.py diff --git a/galaxy/schemas.py b/galaxy/schemas.py new file mode 100644 index 00000000..4bf0663e --- /dev/null +++ b/galaxy/schemas.py @@ -0,0 +1,16 @@ +from typing import Optional + +from pydantic import BaseModel, NaiveDatetime + + +class GalaxyUserModel(BaseModel): + """ + User data returned by Galaxy's users API + """ + model_class: str + id: str + username: str + email: str + deleted: bool + active: bool + last_password_change: Optional[NaiveDatetime] From b861a7971b2b145d14d929afbaecb93155b2d9dc Mon Sep 17 00:00:00 2001 From: marius-mather Date: Fri, 6 Jun 2025 16:07:53 +1000 Subject: [PATCH 05/15] Test username_exists() --- tests/galaxy/test_client.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 tests/galaxy/test_client.py diff --git a/tests/galaxy/test_client.py b/tests/galaxy/test_client.py new file mode 100644 index 00000000..9439c4d9 --- /dev/null +++ b/tests/galaxy/test_client.py @@ -0,0 +1,25 @@ +import httpx +import pytest + +from galaxy.client import GalaxyClient +from tests.galaxy.data_generation import GalaxyUserFactory + + +@pytest.fixture +def galaxy_client(): + return GalaxyClient(galaxy_url="https://galaxy.example.com", + api_key="dummy-key") + + +def test_username_exists(galaxy_client, respx_mock): + user1 = GalaxyUserFactory.build(username="user1") + user2 = GalaxyUserFactory.build(username="user2") + respx_mock.get("https://galaxy.example.com/api/users").mock( + return_value=httpx.Response( + 200, + json=[user1.model_dump(mode="json"), + user2.model_dump(mode="json")] + ) + ) + assert galaxy_client.username_exists("user1") + assert not galaxy_client.username_exists("other_user") From 3f7393f531f15aa6e221cef0dcff7d99d5767be7 Mon Sep 17 00:00:00 2001 From: marius-mather Date: Tue, 10 Jun 2025 09:52:34 +1000 Subject: [PATCH 06/15] Mock admin token via dependency override --- tests/test_admin.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/test_admin.py b/tests/test_admin.py index aaf32e6d..f2cbc13a 100644 --- a/tests/test_admin.py +++ b/tests/test_admin.py @@ -5,6 +5,7 @@ from fastapi import HTTPException from freezegun import freeze_time +from auth.management import get_management_token from auth.validator import get_current_user, user_is_admin from main import app from routers.admin import PaginationParams @@ -48,8 +49,11 @@ def get_nonadmin_user(): payload = AccessTokenPayloadFactory.build(biocommons_roles=["User"]) return SessionUserFactory.build(access_token=payload) + def mock_admin_token(): + return "mock_token" + app.dependency_overrides[get_current_user] = get_nonadmin_user - mocker.patch("routers.admin.get_management_token", return_value="mock_token") + app.dependency_overrides[get_management_token] = mock_admin_token resp = test_client.get("/admin/users") assert resp.status_code == 403 assert resp.json() == {"detail": "You must be an admin to access this endpoint."} From 480f498476e392e91ccf63cdff80744ed559fe8f Mon Sep 17 00:00:00 2001 From: marius-mather Date: Tue, 10 Jun 2025 13:53:23 +1000 Subject: [PATCH 07/15] Docstrings for galaxy client --- galaxy/client.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/galaxy/client.py b/galaxy/client.py index aa1686fc..7ef2cc5e 100644 --- a/galaxy/client.py +++ b/galaxy/client.py @@ -4,8 +4,15 @@ class GalaxyClient: + """ + Client for making requests to the Galaxy API. + """ def __init__(self, galaxy_url: str, api_key: str): + """ + :param galaxy_url: Base URL of the Galaxy instance. + :param api_key: API key for a galaxy user. + """ self.url = galaxy_url self.api_key = api_key self.client = Client(base_url=self.url, headers={'x-api-key': self.api_key}) From 93a7658cc0db247f906ab10ffc8cee34ca6d458d Mon Sep 17 00:00:00 2001 From: marius-mather Date: Thu, 12 Jun 2025 09:43:34 +1000 Subject: [PATCH 08/15] Add data generation for galaxy tests --- tests/galaxy/__init__.py | 0 tests/galaxy/data_generation.py | 6 ++++++ 2 files changed, 6 insertions(+) create mode 100644 tests/galaxy/__init__.py create mode 100644 tests/galaxy/data_generation.py diff --git a/tests/galaxy/__init__.py b/tests/galaxy/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/galaxy/data_generation.py b/tests/galaxy/data_generation.py new file mode 100644 index 00000000..9c3f6527 --- /dev/null +++ b/tests/galaxy/data_generation.py @@ -0,0 +1,6 @@ +from polyfactory.factories.pydantic_factory import ModelFactory + +from galaxy.schemas import GalaxyUserModel + + +class GalaxyUserFactory(ModelFactory[GalaxyUserModel]): ... From f181803907f3403f21bf43796ff24831f0abcece Mon Sep 17 00:00:00 2001 From: marius-mather Date: Thu, 12 Jun 2025 11:19:25 +1000 Subject: [PATCH 09/15] Dependency to get galaxy client --- galaxy/client.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/galaxy/client.py b/galaxy/client.py index 7ef2cc5e..e4f2bba1 100644 --- a/galaxy/client.py +++ b/galaxy/client.py @@ -1,5 +1,9 @@ +from typing import Annotated + +from fastapi import Depends from httpx import Client +from galaxy.config import GalaxySettings, get_galaxy_settings from galaxy.schemas import GalaxyUserModel @@ -30,3 +34,7 @@ def username_exists(self, username: str) -> bool: if user.username == username: return True return False + + +def get_galaxy_client(settings: Annotated[GalaxySettings, Depends(get_galaxy_settings)]): + return GalaxyClient(galaxy_url=settings.galaxy_url, api_key=settings.galaxy_api_key) From d0c5c68754500e79555efd6e069ebdde15ff7f21 Mon Sep 17 00:00:00 2001 From: marius-mather Date: Thu, 12 Jun 2025 11:19:52 +1000 Subject: [PATCH 10/15] Need to ignore other vars when reading galaxy config from shared env file --- galaxy/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/galaxy/config.py b/galaxy/config.py index 54ce2c41..3896e642 100644 --- a/galaxy/config.py +++ b/galaxy/config.py @@ -13,7 +13,7 @@ class GalaxySettings(BaseSettings): galaxy_url: str galaxy_api_key: str - model_config = SettingsConfigDict(env_file=".env") + model_config = SettingsConfigDict(env_file=".env", extra="ignore") @lru_cache From 23c6ba663bf88773906f6cfd4aa25f01f6c35b79 Mon Sep 17 00:00:00 2001 From: marius-mather Date: Thu, 12 Jun 2025 11:21:25 +1000 Subject: [PATCH 11/15] Check for existing username when registering for Galaxy --- routers/galaxy_register.py | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/routers/galaxy_register.py b/routers/galaxy_register.py index fdea9ddd..926f9481 100644 --- a/routers/galaxy_register.py +++ b/routers/galaxy_register.py @@ -1,5 +1,5 @@ import logging -from typing import Optional +from typing import Annotated, Optional import httpx from fastapi import APIRouter, Header, HTTPException @@ -7,6 +7,7 @@ from auth.config import Settings, get_settings from auth.management import get_management_token +from galaxy.client import GalaxyClient, get_galaxy_client from register.tokens import create_registration_token, verify_registration_token from schemas.biocommons import BiocommonsRegisterData from schemas.galaxy import GalaxyRegistrationData @@ -26,8 +27,9 @@ async def get_registration_token(settings: Settings = Depends(get_settings)): @router.post("/register") def register( registration_data: GalaxyRegistrationData, + settings: Annotated[Settings, Depends(get_settings)], + galaxy_client: Annotated[GalaxyClient, Depends(get_galaxy_client)], registration_token: Optional[str] = Header(None), - settings: Settings = Depends(get_settings), ): if not registration_token: raise HTTPException(status_code=400, detail="Missing registration token") @@ -35,11 +37,20 @@ def register( verify_registration_token(registration_token, settings=settings) logger.debug("Registration token verified.") + user_data = BiocommonsRegisterData.from_galaxy_registration(registration_data) + logger.debug("Checking if username exists in Galaxy") + galaxy_username = user_data.user_metadata.galaxy_username + try: + existing = galaxy_client.username_exists(galaxy_username) + if existing: + raise HTTPException(status_code=400, detail="Username already exists") + except httpx.HTTPError as e: + logger.warning(f"Failed to check username in Galaxy: {e}") + url = f"https://{settings.auth0_domain}/api/v2/users" logger.debug("Getting management token.") management_token = get_management_token(settings=settings) headers = {"Authorization": f"Bearer {management_token}"} - user_data = BiocommonsRegisterData.from_galaxy_registration(registration_data) logger.debug("Registering with Auth0 management API") resp = httpx.post( url, From c467faa88a0860ed48d6ef57f68f26255d6ead5b Mon Sep 17 00:00:00 2001 From: marius-mather Date: Thu, 12 Jun 2025 11:47:38 +1000 Subject: [PATCH 12/15] Need to ignore other vars in main settings as well --- auth/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/auth/config.py b/auth/config.py index 26f6e50b..2d0212a9 100644 --- a/auth/config.py +++ b/auth/config.py @@ -36,7 +36,7 @@ class Settings(BaseSettings): "bpa-wheat-pathogens-transcript": "Wheat Pathogens Transcript", } - model_config = SettingsConfigDict(env_file=".env") + model_config = SettingsConfigDict(env_file=".env", extra="ignore") @lru_cache() From b08ec3d89c93b1d21bf1c709fba1ebb3096e8bf4 Mon Sep 17 00:00:00 2001 From: marius-mather Date: Thu, 12 Jun 2025 11:47:52 +1000 Subject: [PATCH 13/15] Add mock galaxy client for testing --- tests/conftest.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/conftest.py b/tests/conftest.py index 077fe7d9..3dc8da41 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,9 +1,12 @@ +from unittest.mock import MagicMock + import pytest from fastapi.testclient import TestClient from auth.config import Settings, get_settings from auth.management import get_management_token from auth.validator import get_current_user +from galaxy.client import GalaxyClient, get_galaxy_client from main import app from tests.datagen import AccessTokenPayloadFactory, SessionUserFactory @@ -67,3 +70,11 @@ def override_user(): app.dependency_overrides[get_management_token] = lambda: "mock_token" yield app.dependency_overrides.clear() + + +@pytest.fixture +def mock_galaxy_client(): + client = MagicMock(GalaxyClient) + app.dependency_overrides[get_galaxy_client] = lambda: client + yield client + app.dependency_overrides.clear() From 99b4d990898f1c39c323f7b5984dec316a8016a5 Mon Sep 17 00:00:00 2001 From: marius-mather Date: Thu, 12 Jun 2025 11:48:07 +1000 Subject: [PATCH 14/15] Update test to mock call to galaxy --- tests/test_galaxy.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/test_galaxy.py b/tests/test_galaxy.py index a04012ef..24c0435f 100644 --- a/tests/test_galaxy.py +++ b/tests/test_galaxy.py @@ -138,7 +138,7 @@ def test_register(mocker, mock_auth_token, mock_settings, test_client): @pytest.mark.respx(base_url="https://mock-domain") -def test_register_json_types(respx_mock, mock_auth_token, mock_settings, test_client): +def test_register_json_types(respx_mock, mock_auth_token, mock_settings, test_client, mock_galaxy_client): """ Test how we handle datetimes in the response data: if we don't use model_dump(mode="json") when providing json data, we can get errors @@ -154,6 +154,7 @@ def test_register_json_types(respx_mock, mock_auth_token, mock_settings, test_cl user_data = GalaxyRegistrationDataFactory.build() token_resp = test_client.get("/galaxy/get-registration-token") headers = {"registration-token": token_resp.json()["token"]} + mock_galaxy_client.username_exists.return_value = False resp = test_client.post("/galaxy/register", json=user_data.model_dump(), headers=headers) assert resp.status_code == 200 From 24f79da2a4f4c22bfc4853d08e13198ca42160c5 Mon Sep 17 00:00:00 2001 From: marius-mather Date: Thu, 12 Jun 2025 11:56:06 +1000 Subject: [PATCH 15/15] Make sure we ignore env file in testing, mock galaxy settings where needed --- tests/conftest.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/tests/conftest.py b/tests/conftest.py index 3dc8da41..5fc716ca 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -7,6 +7,7 @@ from auth.management import get_management_token from auth.validator import get_current_user from galaxy.client import GalaxyClient, get_galaxy_client +from galaxy.config import GalaxySettings, get_galaxy_settings from main import app from tests.datagen import AccessTokenPayloadFactory, SessionUserFactory @@ -19,7 +20,10 @@ def ignore_env_file(): """ def get_settings_no_env_file(): return Settings(_env_file=None) + def get_galaxy_settings_no_env_file(): + return GalaxySettings(_env_file=None) app.dependency_overrides[get_settings] = get_settings_no_env_file + app.dependency_overrides[get_galaxy_settings] = get_galaxy_settings_no_env_file @pytest.fixture @@ -36,8 +40,15 @@ def mock_settings(): auth0_algorithms=["HS256"] ) + +@pytest.fixture +def mock_galaxy_settings(): + """Dummy settings values for GalaxySettings""" + return GalaxySettings(galaxy_url="http://mock-url", galaxy_api_key="mock-key") + + @pytest.fixture -def test_client(mock_settings): +def test_client(mock_settings, mock_galaxy_settings): """ Override the get_settings dependency to return a mocked Settings object. """ @@ -47,6 +58,7 @@ def override_settings(): # Apply override app.dependency_overrides[get_settings] = override_settings + app.dependency_overrides[get_galaxy_settings] = lambda: mock_galaxy_settings # Create client client = TestClient(app)