From b20f07c69aec92c8dd8a65bcb46072d95d8f8dc8 Mon Sep 17 00:00:00 2001 From: Eu Pin Tien Date: Fri, 16 Jan 2026 15:43:00 +0000 Subject: [PATCH 01/13] Fixes and optimisations for the Murfey authentication API * Forwards only essential headers to the auth server to prevent timeouts due to mismatch between header, body, and methods. * Migrate authentication server querying logic out into a helper function to minimise repetition. --- src/murfey/server/api/auth.py | 183 ++++++++++++++++++---------------- 1 file changed, 98 insertions(+), 85 deletions(-) diff --git a/src/murfey/server/api/auth.py b/src/murfey/server/api/auth.py index 6576f209..bcc48026 100644 --- a/src/murfey/server/api/auth.py +++ b/src/murfey/server/api/auth.py @@ -3,7 +3,6 @@ import secrets import time from logging import getLogger -from typing import Dict from uuid import uuid4 import aiohttp @@ -18,7 +17,7 @@ from passlib.context import CryptContext from pydantic import BaseModel from sqlmodel import Session, create_engine, select -from typing_extensions import Annotated +from typing_extensions import Annotated, Any from murfey.server.murfey_db import murfey_db, url from murfey.util.api import url_path_for @@ -50,7 +49,7 @@ instrument_oauth2_scheme = lambda *args, **kwargs: None pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") -instrument_server_tokens: Dict[float, dict] = {} +instrument_server_tokens: dict[float, dict] = {} # Set up database engine try: @@ -66,14 +65,30 @@ def hash_password(password: str) -> str: """ ======================================================================================= -TOKEN VALIDATION FUNCTIONS +VALIDATION FUNCTIONS ======================================================================================= Functions and helpers used to validate incoming requests from both the client and -the frontend. 'validate_token()' and 'validate_instrument_token()' are imported -int the other FastAPI modules and attached as dependencies to the routers. +the frontend. + +'validate_token()' and 'validate_instrument_token()' are imported in the other FastAPI +modules and attached as dependencies to the routers. They validate the tokens passed +around internally by Murfey to ensure that the request is valid. + +'validate_instrument_server_session_access()' and 'validate_frontend_session_access()' +are used to verify the IDs of sessions ot be accessed, and are attached as dependencies +to them. + +'validate_user_instrument_access()' is used to verify the instrument server name being +accessed by the frontend, and is attached as a dependency as well. """ +# Essential headers used for authentication to forward along if present +AUTH_HEADERS = ( + "authorization", + "x-auth-request-access-token", +) + def check_user(username: str) -> bool: try: @@ -84,6 +99,39 @@ def check_user(username: str) -> bool: return username in [u.username for u in users] +async def submit_to_auth_endpoint( + url_subpath: str, + request: Request, + token: str, +) -> dict[str, Any]: + """ + Helper function to forward incoming requests to an authentication server + to verify that they are allowed to inspect the + """ + + # Forward only essentials auth-related headers + headers = { + key: value + for key, value in dict(request.headers).items() + if key.lower() in AUTH_HEADERS + } + if security_config.auth_type == "password": + headers["authorization"] = f"Bearer {token}" + cookies = ( + {security_config.cookie_key: token} + if security_config.auth_type == "cookie" + else {} + ) + async with aiohttp.ClientSession(cookies=cookies) as session: + async with session.get( + f"{auth_url}/{url_subpath}", + headers=headers, + ) as response: + success = response.status == 200 + validation_outcome: dict[str, Any] = await response.json() + return validation_outcome if success and validation_outcome else {"valid": False} + + async def validate_token( token: Annotated[str, Depends(oauth2_scheme)], request: Request, @@ -94,25 +142,9 @@ async def validate_token( try: # Validate using auth URL if provided; will error if invalid if auth_url: - # Extract and forward headers as-is - headers = dict(request.headers) - # Update/add authorization header if authenticating using password - if security_config.auth_type == "password": - headers["authorization"] = f"Bearer {token}" - # Forward the cookie along if authenticating using cookie - cookies = ( - {security_config.cookie_key: token} - if security_config.auth_type == "cookie" - else {} - ) - async with aiohttp.ClientSession(cookies=cookies) as session: - async with session.get( - f"{auth_url}/validate_token", - headers=headers, - ) as response: - success = response.status == 200 - validation_outcome = await response.json() - if not (success and validation_outcome.get("valid")): + if not ( + await submit_to_auth_endpoint("validate_token", request, token) + ).get("valid"): raise JWTError # If authenticating using cookies; an auth URL MUST be provided else: @@ -199,20 +231,6 @@ async def validate_instrument_token( return None -""" -======================================================================================= -SESSION ID VALIDATION -======================================================================================= - -Annotated ints are defined here that trigger validation of the session IDs in incoming -requests, verifying that the session is allowed to access the particular visit. - -The 'MurfeySessionID...' types are imported and used in the type hints of the endpoint -functions in the other FastAPI routers, depending on whether requests from the frontend -or the instrument are expected. -""" - - def get_visit_name(session_id: int) -> str: with Session(engine) as murfey_db: return ( @@ -222,46 +240,6 @@ def get_visit_name(session_id: int) -> str: ) -async def submit_to_auth_endpoint(url_subpath: str, token: str) -> None: - if auth_url: - headers = ( - {} - if security_config.auth_type == "cookie" - else {"Authorization": f"Bearer {token}"} - ) - cookies = ( - {security_config.cookie_key: token} - if security_config.auth_type == "cookie" - else {} - ) - async with aiohttp.ClientSession(cookies=cookies) as session: - async with session.get( - f"{auth_url}/{url_subpath}", - headers=headers, - ) as response: - success = response.status == 200 - validation_outcome: dict = await response.json() - if not (success and validation_outcome.get("valid")): - logger.warning("Unauthorised visit access request from frontend") - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="You do not have access to this visit", - headers={"WWW-Authenticate": "Bearer"}, - ) - - -async def validate_frontend_session_access( - session_id: int, - token: Annotated[str, Depends(oauth2_scheme)], -) -> int: - """ - Validates whether a frontend request can access information about this session - """ - visit_name = get_visit_name(session_id) - await submit_to_auth_endpoint(f"validate_visit_access/{visit_name}", token) - return session_id - - async def validate_instrument_server_session_access( session_id: int, token: Annotated[str, Depends(instrument_oauth2_scheme)], @@ -294,25 +272,60 @@ async def validate_instrument_server_session_access( return session_id +async def validate_frontend_session_access( + session_id: int, + request: Request, + token: Annotated[str, Depends(oauth2_scheme)], +) -> int: + """ + Validates whether a frontend request can access information about this session + """ + visit_name = get_visit_name(session_id) + if auth_url: + if not ( + await submit_to_auth_endpoint( + f"validate_visit_access/{visit_name}", + request, + token, + ) + ).get("valid"): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="You do not have access to this visit", + headers={"WWW-Authenticate": "Bearer"}, + ) + return session_id + + async def validate_user_instrument_access( instrument_name: str, + request: Request, token: Annotated[str, Depends(oauth2_scheme)], ) -> str: """ Validates whether a frontend request can access information about this instrument """ - await submit_to_auth_endpoint( - f"validate_instrument_access/{instrument_name}", token - ) + if auth_url: + if not ( + await submit_to_auth_endpoint( + f"validate_instrument_access/{instrument_name}", + request, + token, + ) + ).get("valid"): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="You do not have access to this instrument", + headers={"WWW-Authenticate": "Bearer"}, + ) return instrument_name -# Set validation conditions for the session ID based on where the request is from -MurfeySessionIDFrontend = Annotated[int, Depends(validate_frontend_session_access)] +# Create annotated session ID and instrument name for endpoints that need to verify them MurfeySessionIDInstrument = Annotated[ int, Depends(validate_instrument_server_session_access) ] - +MurfeySessionIDFrontend = Annotated[int, Depends(validate_frontend_session_access)] MurfeyInstrumentNameFrontend = Annotated[str, Depends(validate_user_instrument_access)] From 2b0ac83f1a45e71667a8c0d268e13e7d89049d70 Mon Sep 17 00:00:00 2001 From: Eu Pin Tien Date: Mon, 19 Jan 2026 10:14:17 +0000 Subject: [PATCH 02/13] Rewrote 'oauth2_scheme' and 'instrument_oauth2_scheme' variables as inline if-else statements instead --- src/murfey/server/api/auth.py | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/src/murfey/server/api/auth.py b/src/murfey/server/api/auth.py index bcc48026..b0d5f292 100644 --- a/src/murfey/server/api/auth.py +++ b/src/murfey/server/api/auth.py @@ -39,14 +39,16 @@ auth_url = security_config.auth_url ALGORITHM = security_config.auth_algorithm or "HS256" SECRET_KEY = security_config.auth_key or secrets.token_hex(32) -if security_config.auth_type == "password": - oauth2_scheme = OAuth2PasswordBearer(tokenUrl="auth/token") -else: - oauth2_scheme = APIKeyCookie(name=security_config.cookie_key) -if security_config.instrument_auth_type == "token": - instrument_oauth2_scheme = OAuth2PasswordBearer(tokenUrl="auth/token") -else: - instrument_oauth2_scheme = lambda *args, **kwargs: None +oauth2_scheme = ( + OAuth2PasswordBearer(tokenUrl="auth/token") + if security_config.auth_type == "password" + else APIKeyCookie(name=security_config.cookie_key) +) +instrument_oauth2_scheme = ( + OAuth2PasswordBearer(tokenUrl="auth/token") + if security_config.instrument_auth_type == "token" + else lambda *args, **kwargs: None +) pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") instrument_server_tokens: dict[float, dict] = {} From 3b26241f60adbe6dc47ff23a13557912fc93eefe Mon Sep 17 00:00:00 2001 From: Eu Pin Tien Date: Mon, 19 Jan 2026 11:03:58 +0000 Subject: [PATCH 03/13] Add placeholder tests for auth functions and API endpoints --- tests/server/api/test_auth_api.py | 54 +++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 tests/server/api/test_auth_api.py diff --git a/tests/server/api/test_auth_api.py b/tests/server/api/test_auth_api.py new file mode 100644 index 00000000..ac6fade4 --- /dev/null +++ b/tests/server/api/test_auth_api.py @@ -0,0 +1,54 @@ +def test_check_user(): + pass + + +async def test_submit_to_auth_endpoint(): + pass + + +async def test_validate_token(): + pass + + +def test_validate_session_against_visit(): + pass + + +async def test_validate_instrument_token(): + pass + + +def test_get_visit_name(): + pass + + +async def test_validate_instrument_server_session_access(): + pass + + +async def test_validate_frontend_session_access(): + pass + + +async def test_validate_user_instrument_access(): + pass + + +def test_verify_password(): + pass + + +def test_validate_user(): + pass + + +def test_create_access_token(): + pass + + +async def test_generate_token(): + pass + + +async def test_mint_session_token(): + pass From e4a0d7834be23e572baaca864c2edadbc76a49e7 Mon Sep 17 00:00:00 2001 From: Eu Pin Tien Date: Mon, 19 Jan 2026 12:56:07 +0000 Subject: [PATCH 04/13] Decorated async test stubs --- tests/server/api/test_auth_api.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/server/api/test_auth_api.py b/tests/server/api/test_auth_api.py index ac6fade4..1638195e 100644 --- a/tests/server/api/test_auth_api.py +++ b/tests/server/api/test_auth_api.py @@ -1,11 +1,16 @@ +import pytest + + def test_check_user(): pass +@pytest.mark.asyncio async def test_submit_to_auth_endpoint(): pass +@pytest.mark.asyncio async def test_validate_token(): pass @@ -14,6 +19,7 @@ def test_validate_session_against_visit(): pass +@pytest.mark.asyncio async def test_validate_instrument_token(): pass @@ -22,14 +28,17 @@ def test_get_visit_name(): pass +@pytest.mark.asyncio async def test_validate_instrument_server_session_access(): pass +@pytest.mark.asyncio async def test_validate_frontend_session_access(): pass +@pytest.mark.asyncio async def test_validate_user_instrument_access(): pass @@ -46,9 +55,11 @@ def test_create_access_token(): pass +@pytest.mark.asyncio async def test_generate_token(): pass +@pytest.mark.asyncio async def test_mint_session_token(): pass From eb0d7bbef5f4c667a70540d01e4c99a0d388bee0 Mon Sep 17 00:00:00 2001 From: Eu Pin Tien Date: Mon, 19 Jan 2026 17:32:55 +0000 Subject: [PATCH 05/13] Added unit test for the 'submit_to_auth_endpiont' helper function --- tests/server/api/test_auth_api.py | 125 +++++++++++++++++++++++++++++- 1 file changed, 123 insertions(+), 2 deletions(-) diff --git a/tests/server/api/test_auth_api.py b/tests/server/api/test_auth_api.py index 1638195e..878b53ab 100644 --- a/tests/server/api/test_auth_api.py +++ b/tests/server/api/test_auth_api.py @@ -1,13 +1,134 @@ +import copy +from unittest.mock import AsyncMock, MagicMock + import pytest +from pytest_mock import MockerFixture + +from murfey.server.api.auth import submit_to_auth_endpoint def test_check_user(): pass +@pytest.mark.parametrize( + "test_params", + ( # URL subpath | Auth type | Status code | Validation result + ( + "validate_token", + "cookie", + 200, + True, + ), + ( + "validate_visit_access/some_visit", + "password", + 200, + True, + ), + ( + "validate_instrument_access/some_instrument", + "cookie", + 200, + False, + ), + ( + "validate_token", + "password", + 200, + False, + ), + ( + "validate_visit_access/some_visit", + "cookie", + 400, + True, + ), + ( + "validate_instrument_access/some_instrument", + "password", + 400, + True, + ), + ), +) @pytest.mark.asyncio -async def test_submit_to_auth_endpoint(): - pass +async def test_submit_to_auth_endpoint( + mocker: MockerFixture, + test_params: tuple[str, str, int, bool], +): + # Unpack test params + url_subpath, auth_type, status_code, validation_outcome = test_params + + # Patch the auth URL to use + auth_url = "some_url" + mocker.patch("murfey.server.api.auth.auth_url", auth_url) + + # Patch the security config + mock_security_config = MagicMock() + mock_security_config.auth_url = auth_url + mock_security_config.auth_type = auth_type + mock_security_config.cookie_key = "_oauth2_proxy" + mocker.patch("murfey.server.api.auth.security_config", mock_security_config) + + # Mock the request being forwarded and its headers and cookies + mock_headers = { + "authorization": "Bearer dummy", + "x-auth-request-access-token": "dummy", + } + mock_token = "123456" + mock_cookies = ( + {mock_security_config.cookie_key: mock_token} if auth_type == "cookie" else {} + ) + + mock_request = MagicMock() + mock_request.headers = mock_headers + + # Mock the async response + mock_response = MagicMock() + mock_response.status = status_code + mock_response.json = AsyncMock( + return_value={ + "valid": validation_outcome, + } + ) + + # Mock the async session and the 'get' + mock_get = AsyncMock() + mock_get.__aenter__.return_value = mock_response + + mock_session = MagicMock() + mock_session.get.return_value = mock_get + + mock_session_context = AsyncMock() + mock_session_context.__aenter__.return_value = mock_session + + mock_client_session = mocker.patch( + "murfey.server.api.auth.aiohttp.ClientSession", + return_value=mock_session_context, + ) + + # Run the function and check that the correct calls were made + result = await submit_to_auth_endpoint( + url_subpath=url_subpath, + request=mock_request, + token=mock_token, + ) + + # Check that aiohttp.ClientSession got called with the correct parameters + mock_client_session.assert_called_once_with(cookies=mock_cookies) + + # Compare the headers passed to 'session.get' against what is expected + updated_headers = copy.deepcopy(mock_headers) + if auth_type == "password": + updated_headers["authorization"] = f"Bearer {mock_token}" + mock_session.get.assert_called_once_with( + f"{mock_security_config.auth_url}/{url_subpath}", + headers=updated_headers, + ) + + # Check that the combination of status code and JSON response are correct + assert result == {"valid": (validation_outcome if status_code == 200 else False)} @pytest.mark.asyncio From c6f814bcce9d9cbe194fb73aab9eab5ff182ffb8 Mon Sep 17 00:00:00 2001 From: Eu Pin Tien Date: Mon, 19 Jan 2026 18:20:33 +0000 Subject: [PATCH 06/13] Added parametrized unit teset for 'validate_token' endpoint function --- tests/server/api/test_auth_api.py | 97 ++++++++++++++++++++++++++++++- 1 file changed, 94 insertions(+), 3 deletions(-) diff --git a/tests/server/api/test_auth_api.py b/tests/server/api/test_auth_api.py index 878b53ab..477d988e 100644 --- a/tests/server/api/test_auth_api.py +++ b/tests/server/api/test_auth_api.py @@ -1,10 +1,15 @@ import copy +import secrets from unittest.mock import AsyncMock, MagicMock import pytest +from fastapi import HTTPException from pytest_mock import MockerFixture -from murfey.server.api.auth import submit_to_auth_endpoint +from murfey.server.api.auth import ( + submit_to_auth_endpoint, + validate_token, +) def test_check_user(): @@ -131,9 +136,95 @@ async def test_submit_to_auth_endpoint( assert result == {"valid": (validation_outcome if status_code == 200 else False)} +@pytest.mark.parametrize( + "test_params", + ( # Exception raised? | Auth URL | Auth type | Validation outcome | User decoded | User exists + (False, "some_url", "cookie", True, True, True), + (False, "", "password", True, True, True), + # Auth endpoint returns False + (True, "some_url", "cookie", False, True, True), + # Authenticating with cookie, but no auth URL + (True, "", "cookie", True, True, True), + # Decoding fails + (True, "", "password", True, False, True), + # User check fails + (True, "", "password", True, True, False), + ), +) @pytest.mark.asyncio -async def test_validate_token(): - pass +async def test_validate_token( + mocker: MockerFixture, + test_params: tuple[bool, str, str, bool, bool, bool], +): + # Unpack test params + ( + raises_exception, + auth_url, + auth_type, + validation_outcome, + user_decoded, + user_exists, + ) = test_params + + # Patch the auth URL to use + mocker.patch("murfey.server.api.auth.auth_url", auth_url) + + # Create a mock token + mock_token = "some_token" + + # Mock the request + mock_request = MagicMock() + + # Mock the secret key and algorithms module-level variables + mock_secret_key = mocker.patch( + "murfey.server.api.auth.SECRET_KEY", secrets.token_hex(32) + ) + mock_algorithms = mocker.patch("murfey.server.api.auth.ALGORITHM", "HS256") + + # Mock the 'jwt.decode' function + mock_decoded_data = {"user": "some_user"} if user_decoded else {} + mock_decode = mocker.patch( + "murfey.server.api.auth.jwt.decode", return_value=mock_decoded_data + ) + + # Mock the 'check_user' function + mock_check_user = mocker.patch( + "murfey.server.api.auth.check_user", return_value=user_exists + ) + + # Patch the security config + mock_security_config = MagicMock() + mock_security_config.auth_type = auth_type + mocker.patch("murfey.server.api.auth.security_config", mock_security_config) + + # Patch the 'submit_to_auth_endpoint' function + mock_submit = mocker.patch( + "murfey.server.api.auth.submit_to_auth_endpoint", new_callable=AsyncMock + ) + mock_submit.return_value = {"valid": validation_outcome} + + # Run the function and check that the values passed and returned are as expected + if not raises_exception: + result = await validate_token( + token=mock_token, + request=mock_request, + ) + if auth_url: + mock_submit.assert_called_once_with( + "validate_token", mock_request, mock_token + ) + if auth_type == "password": + mock_decode.assert_called_once_with( + mock_token, mock_secret_key, algorithms=[mock_algorithms] + ) + mock_check_user.assert_called_once_with(mock_decoded_data["user"]) + assert result is None + else: + with pytest.raises(HTTPException): + await validate_token( + token=mock_token, + request=mock_request, + ) def test_validate_session_against_visit(): From a61759167eab9b663ac0ef4795159ba99ccd9a9c Mon Sep 17 00:00:00 2001 From: Eu Pin Tien Date: Mon, 19 Jan 2026 18:49:26 +0000 Subject: [PATCH 07/13] Added tests forr the 'validate_frontend_session_access' and 'validate_user_instrument_access' validation functions --- tests/server/api/test_auth_api.py | 123 +++++++++++++++++++++++++++++- 1 file changed, 119 insertions(+), 4 deletions(-) diff --git a/tests/server/api/test_auth_api.py b/tests/server/api/test_auth_api.py index 477d988e..3a02384f 100644 --- a/tests/server/api/test_auth_api.py +++ b/tests/server/api/test_auth_api.py @@ -8,7 +8,9 @@ from murfey.server.api.auth import ( submit_to_auth_endpoint, + validate_frontend_session_access, validate_token, + validate_user_instrument_access, ) @@ -139,8 +141,10 @@ async def test_submit_to_auth_endpoint( @pytest.mark.parametrize( "test_params", ( # Exception raised? | Auth URL | Auth type | Validation outcome | User decoded | User exists + # These cases will pass (False, "some_url", "cookie", True, True, True), (False, "", "password", True, True, True), + # These cases will fail # Auth endpoint returns False (True, "some_url", "cookie", False, True, True), # Authenticating with cookie, but no auth URL @@ -245,14 +249,125 @@ async def test_validate_instrument_server_session_access(): pass +@pytest.mark.parametrize( + "test_params", + ( # Raises exception | Auth URL | Validation outcome + # These cases will pass + (False, "some_url", True), + (False, "", True), + # These cases will fail + (True, "some_url", False), + ), +) @pytest.mark.asyncio -async def test_validate_frontend_session_access(): - pass +async def test_validate_frontend_session_access( + mocker: MockerFixture, + test_params: tuple[bool, str, bool], +): + # Unpack the test parameters + raises_exception, auth_url, validation_outcome = test_params + session_id = 1 + + # Mock the request and token + mock_request = MagicMock() + mock_token = "123456" + + # Mock the results of 'get_visit_name' + visit_name = "test_visit" + mock_get_visit_name = mocker.patch( + "murfey.server.api.auth.get_visit_name", return_value=visit_name + ) + + # Patch the auth URL + mocker.patch("murfey.server.api.auth.auth_url", auth_url) + + # Patch the 'submit_to_auth_endpoint' function + mock_submit = mocker.patch( + "murfey.server.api.auth.submit_to_auth_endpoint", new_callable=AsyncMock + ) + mock_submit.return_value = {"valid": validation_outcome} + + # Run the function and check that the results and passed parameters are as expected + if not raises_exception: + result = await validate_frontend_session_access( + session_id=session_id, + request=mock_request, + token=mock_token, + ) + mock_get_visit_name.assert_called_once_with(session_id) + if auth_url: + mock_submit.assert_awaited_once_with( + f"validate_visit_access/{visit_name}", + mock_request, + mock_token, + ) + else: + mock_submit.assert_not_called() + assert result == session_id + else: + with pytest.raises(HTTPException): + await validate_frontend_session_access( + session_id=session_id, + request=mock_request, + token=mock_token, + ) +@pytest.mark.parametrize( + "test_params", + ( # Raises exception | Auth URL | Validation outcome + # These cases will pass + (False, "some_url", True), + (False, "", True), + # These cases will fail + (True, "some_url", False), + ), +) @pytest.mark.asyncio -async def test_validate_user_instrument_access(): - pass +async def test_validate_user_instrument_access( + mocker: MockerFixture, + test_params: tuple[bool, str, bool], +): + # Unpack the test parameters + raises_exception, auth_url, validation_outcome = test_params + instrument_name = "some_instrument" + + # Mock the request and token + mock_request = MagicMock() + mock_token = "123456" + + # Patch the auth URL + mocker.patch("murfey.server.api.auth.auth_url", auth_url) + + # Patch the 'submit_to_auth_endpoint' function + mock_submit = mocker.patch( + "murfey.server.api.auth.submit_to_auth_endpoint", new_callable=AsyncMock + ) + mock_submit.return_value = {"valid": validation_outcome} + + # Run the function and check that the results and passed parameters are as expected + if not raises_exception: + result = await validate_user_instrument_access( + instrument_name=instrument_name, + request=mock_request, + token=mock_token, + ) + if auth_url: + mock_submit.assert_awaited_once_with( + f"validate_instrument_access/{instrument_name}", + mock_request, + mock_token, + ) + else: + mock_submit.assert_not_called() + assert result == instrument_name + else: + with pytest.raises(HTTPException): + await validate_user_instrument_access( + instrument_name=instrument_name, + request=mock_request, + token=mock_token, + ) def test_verify_password(): From 3dd97202f41cb451d438cd7becb87061ff6bb4c4 Mon Sep 17 00:00:00 2001 From: Eu Pin Tien Date: Mon, 19 Jan 2026 19:06:04 +0000 Subject: [PATCH 08/13] Added database test for the 'check_user' helper function --- tests/server/api/test_auth_api.py | 37 +++++++++++++++++++++++++++++-- 1 file changed, 35 insertions(+), 2 deletions(-) diff --git a/tests/server/api/test_auth_api.py b/tests/server/api/test_auth_api.py index 3a02384f..a8ccb36d 100644 --- a/tests/server/api/test_auth_api.py +++ b/tests/server/api/test_auth_api.py @@ -5,17 +5,50 @@ import pytest from fastapi import HTTPException from pytest_mock import MockerFixture +from sqlmodel import Session as SQLModelSession from murfey.server.api.auth import ( + check_user, submit_to_auth_endpoint, validate_frontend_session_access, validate_token, validate_user_instrument_access, ) +from murfey.util.db import MurfeyUser -def test_check_user(): - pass +@pytest.mark.parametrize( + "test_params", + ( # User to check | Expected result + ("murfey_user", True), + ("some_dud", False), + ), +) +def test_check_user( + mocker: MockerFixture, + murfey_db_session: SQLModelSession, + test_params: tuple[str, bool], +): + # Unpack test params + user_to_check, expected_result = test_params + + # Add the test user to the database + murfey_user = MurfeyUser( + username="murfey_user", + hashed_password="asdfghjkl", + ) + murfey_db_session.add(murfey_user) + murfey_db_session.commit() + + # Patch the Session context + mock_session_context = MagicMock() + mock_session_context.__enter__.return_value = murfey_db_session + mock_session_context.__exit__.return_value = None + mocker.patch("murfey.server.api.auth.Session", return_value=mock_session_context) + + # Run the function and check that the result is as expected + result = check_user(user_to_check) + assert result == expected_result @pytest.mark.parametrize( From ee16759ee80d6a130d64041a6f9048f5a00a1067 Mon Sep 17 00:00:00 2001 From: Eu Pin Tien Date: Tue, 20 Jan 2026 09:52:43 +0000 Subject: [PATCH 09/13] Added test for 'validate_session_against_visit' function --- tests/server/api/test_auth_api.py | 49 +++++++++++++++++++++++++++++-- 1 file changed, 46 insertions(+), 3 deletions(-) diff --git a/tests/server/api/test_auth_api.py b/tests/server/api/test_auth_api.py index a8ccb36d..8fa4adcd 100644 --- a/tests/server/api/test_auth_api.py +++ b/tests/server/api/test_auth_api.py @@ -11,10 +11,11 @@ check_user, submit_to_auth_endpoint, validate_frontend_session_access, + validate_session_against_visit, validate_token, validate_user_instrument_access, ) -from murfey.util.db import MurfeyUser +from murfey.util.db import MurfeyUser, Session as MurfeySession @pytest.mark.parametrize( @@ -264,8 +265,50 @@ async def test_validate_token( ) -def test_validate_session_against_visit(): - pass +@pytest.mark.parametrize( + "test_params", + ( # Session ID | Visit | Expected result + (1, "test_visit", True), + (1, "some_visit", False), + (2, "test_visit", False), + ), +) +def test_validate_session_against_visit( + mocker: MockerFixture, + murfey_db_session: SQLModelSession, + test_params: tuple[int, str, bool], +): + # Unpack test params + session_id, visit_name, expected_result = test_params + + # Add a test session to the database + session_entry = MurfeySession( + id=1, + name="test_visit", + visit="test_visit", + started=False, + current_gain_ref="/path/to/gain_ref", + instrument_name="test_instrument", + process=True, + visit_end_time=None, + ) + murfey_db_session.add(session_entry) + murfey_db_session.commit() + + # Patch the Session call with the test database + mock_session_context = MagicMock() + mock_session_context.__enter__.return_value = murfey_db_session + mock_session_context.__exit__.return_value = None + mocker.patch("murfey.server.api.auth.Session", return_value=mock_session_context) + + # Run the function + assert ( + validate_session_against_visit( + session_id=session_id, + visit=visit_name, + ) + == expected_result + ) @pytest.mark.asyncio From b3ef5f43200d01e7cc6b765dc33336d475b53f57 Mon Sep 17 00:00:00 2001 From: Eu-Pin Tien Date: Tue, 20 Jan 2026 10:07:44 +0000 Subject: [PATCH 10/13] Use session ID range that doesn't conflict with the default Murfey Session entry --- tests/server/api/test_auth_api.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/server/api/test_auth_api.py b/tests/server/api/test_auth_api.py index 8fa4adcd..b05338ce 100644 --- a/tests/server/api/test_auth_api.py +++ b/tests/server/api/test_auth_api.py @@ -268,9 +268,9 @@ async def test_validate_token( @pytest.mark.parametrize( "test_params", ( # Session ID | Visit | Expected result - (1, "test_visit", True), - (1, "some_visit", False), - (2, "test_visit", False), + (11, "test_visit", True), + (11, "some_visit", False), + (12, "test_visit", False), ), ) def test_validate_session_against_visit( @@ -283,7 +283,7 @@ def test_validate_session_against_visit( # Add a test session to the database session_entry = MurfeySession( - id=1, + id=11, name="test_visit", visit="test_visit", started=False, From 1ef879692124deeb1f62be6ad7dc94bd6c852ee4 Mon Sep 17 00:00:00 2001 From: Eu-Pin Tien Date: Tue, 20 Jan 2026 10:16:39 +0000 Subject: [PATCH 11/13] Added test for 'get_visit_name' helper function --- tests/server/api/test_auth_api.py | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/tests/server/api/test_auth_api.py b/tests/server/api/test_auth_api.py index b05338ce..71de6dc9 100644 --- a/tests/server/api/test_auth_api.py +++ b/tests/server/api/test_auth_api.py @@ -9,6 +9,7 @@ from murfey.server.api.auth import ( check_user, + get_visit_name, submit_to_auth_endpoint, validate_frontend_session_access, validate_session_against_visit, @@ -16,6 +17,7 @@ validate_user_instrument_access, ) from murfey.util.db import MurfeyUser, Session as MurfeySession +from tests.conftest import ExampleVisit @pytest.mark.parametrize( @@ -316,8 +318,19 @@ async def test_validate_instrument_token(): pass -def test_get_visit_name(): - pass +def test_get_visit_name( + mocker: MockerFixture, + murfey_db_session: SQLModelSession, +): + # Patch the Session call with the test database + mock_session_context = MagicMock() + mock_session_context.__enter__.return_value = murfey_db_session + mock_session_context.__exit__.return_value = None + mocker.patch("murfey.server.api.auth.Session", return_value=mock_session_context) + + # Check that the built-in default visit gets returned + visit_name = f"{ExampleVisit.proposal_code}{ExampleVisit.proposal_number}-{ExampleVisit.visit_number}" + assert get_visit_name(ExampleVisit.murfey_session_id) == visit_name @pytest.mark.asyncio From d220658b92c3e1d214bff0cd35e0c75063c73937 Mon Sep 17 00:00:00 2001 From: Eu-Pin Tien Date: Tue, 20 Jan 2026 10:23:08 +0000 Subject: [PATCH 12/13] Built-in visit was incomplete and not useful for test; insert new visit for test --- tests/server/api/test_auth_api.py | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/tests/server/api/test_auth_api.py b/tests/server/api/test_auth_api.py index 71de6dc9..a26119d1 100644 --- a/tests/server/api/test_auth_api.py +++ b/tests/server/api/test_auth_api.py @@ -17,7 +17,6 @@ validate_user_instrument_access, ) from murfey.util.db import MurfeyUser, Session as MurfeySession -from tests.conftest import ExampleVisit @pytest.mark.parametrize( @@ -322,6 +321,20 @@ def test_get_visit_name( mocker: MockerFixture, murfey_db_session: SQLModelSession, ): + # Add a test visit to the database + session_entry = MurfeySession( + id=11, + name="test_visit", + visit="test_visit", + started=True, + current_gain_ref="/path/to/gain_ref", + instrument_name="test_instrument", + process=True, + visit_end_time=None, + ) + murfey_db_session.add(session_entry) + murfey_db_session.commit() + # Patch the Session call with the test database mock_session_context = MagicMock() mock_session_context.__enter__.return_value = murfey_db_session @@ -329,8 +342,7 @@ def test_get_visit_name( mocker.patch("murfey.server.api.auth.Session", return_value=mock_session_context) # Check that the built-in default visit gets returned - visit_name = f"{ExampleVisit.proposal_code}{ExampleVisit.proposal_number}-{ExampleVisit.visit_number}" - assert get_visit_name(ExampleVisit.murfey_session_id) == visit_name + assert get_visit_name(session_id=11) == "test_visit" @pytest.mark.asyncio From c888c75dee9c3d1923a793c3dc2d22d90d71fb3d Mon Sep 17 00:00:00 2001 From: Eu-Pin Tien Date: Tue, 20 Jan 2026 10:43:41 +0000 Subject: [PATCH 13/13] Added test for 'validate_user' helper function --- tests/server/api/test_auth_api.py | 37 +++++++++++++++++++++++++++++-- 1 file changed, 35 insertions(+), 2 deletions(-) diff --git a/tests/server/api/test_auth_api.py b/tests/server/api/test_auth_api.py index a26119d1..7fc8071b 100644 --- a/tests/server/api/test_auth_api.py +++ b/tests/server/api/test_auth_api.py @@ -14,6 +14,7 @@ validate_frontend_session_access, validate_session_against_visit, validate_token, + validate_user, validate_user_instrument_access, ) from murfey.util.db import MurfeyUser, Session as MurfeySession @@ -475,8 +476,40 @@ def test_verify_password(): pass -def test_validate_user(): - pass +@pytest.mark.parametrize( + "test_params", + ( # User to query | Expected outcome + ("test_user", True), + ("some_user", False), + ), +) +def test_validate_user( + mocker: MockerFixture, + murfey_db_session: SQLModelSession, + test_params: tuple[str, bool], +): + # Unpack test params + user_to_query, expected_result = test_params + + # Add a user to the test database + user_entry = MurfeyUser( + username="test_user", + hashed_password="asdfghjkl", + ) + murfey_db_session.add(user_entry) + murfey_db_session.commit() + + # Mock the 'verify_password' function + mocker.patch("murfey.server.api.auth.verify_password", return_value=True) + + # Patch the Session call with the test database + mock_session_context = MagicMock() + mock_session_context.__enter__.return_value = murfey_db_session + mock_session_context.__exit__.return_value = None + mocker.patch("murfey.server.api.auth.Session", return_value=mock_session_context) + + # Run the function and check that the outocome is as expected + assert validate_user(user_to_query, "dummypassword") == expected_result def test_create_access_token():