From 8ef11c1e6ae269e5e25e8ae1747c80ffa1e5c75a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Frnka?= Date: Fri, 10 Jul 2026 00:25:06 +0200 Subject: [PATCH 1/3] Fix ruff issues --- service/config.template.yaml | 11 +++- .../ai/common/config.py | 66 +++++++++++++++---- .../ai_document_plugin_service/api/auth.py | 27 +++++--- service/tests/api/test_auth.py | 38 ++++++++--- service/tests/common/test_config.py | 36 ++++++++-- 5 files changed, 139 insertions(+), 39 deletions(-) diff --git a/service/config.template.yaml b/service/config.template.yaml index 5b4036a..7705409 100644 --- a/service/config.template.yaml +++ b/service/config.template.yaml @@ -1,9 +1,14 @@ -# Specify urls which can be used for authenticating the user. The user will send the url in a header 'X-Dsw-Api-Url' +# Specify urls and tenant ids which can be used for authenticating the user. The user will send the url in a header 'X-Dsw-Api-Url' # Use the base url for dsw api, not the frontend. # For example: https://researchers.dsw.elixir-europe.org/wizard-api +# Get the tenant UUID from the JWT token +# For debugging purposes, you can set url or tenant_uuid to * to allow all urls or tenant uuids. auth: - allowed_project_urls: - - "https://your-dsw-instance.example.com/wizard-api" + allowed_apis: + - url: "https://your-dsw-instance.example.com/wizard-api" + tenant_uuid: "123e4567-e89b-12d3-a456-426614174000" + - url: "*" + tenant_uuid: "*" logging: level: "INFO" diff --git a/service/src/ai_document_plugin_service/ai/common/config.py b/service/src/ai_document_plugin_service/ai/common/config.py index 5292eb1..4d2c6d7 100644 --- a/service/src/ai_document_plugin_service/ai/common/config.py +++ b/service/src/ai_document_plugin_service/ai/common/config.py @@ -1,12 +1,17 @@ +import logging import os import pathlib from dataclasses import dataclass from typing import Any +from uuid import UUID import yaml +logger = logging.getLogger(__name__) + DEFAULT_CONFIG_PATH = 'config.yaml' CONFIG_PATH_ENV_VAR = 'AI_DOCUMENT_PLUGIN_CONFIG_PATH' +WILDCARD = '*' @dataclass(frozen=True) @@ -29,6 +34,12 @@ class FilePaths: prompts_path: str +@dataclass(frozen=True) +class AllowedApi: + url: str + tenant_uuid: str + + @dataclass(frozen=True) class DatabaseConfig: host: str @@ -41,7 +52,7 @@ class DatabaseConfig: @dataclass(frozen=True) class Config: - allowed_project_urls: tuple[str, ...] + allowed_apis: tuple[AllowedApi, ...] log_level: str database: DatabaseConfig files: FilePaths @@ -114,20 +125,49 @@ def normalize_project_url(url: str) -> str: return url.strip().rstrip('/') -def _get_allowed_project_urls(config: dict[str, Any]) -> tuple[str, ...]: - raw_urls = _get(config, 'auth', 'allowed_project_urls') - if not isinstance(raw_urls, list) or not raw_urls: - msg = "Invalid config value: 'auth.allowed_project_urls' must be a non-empty list" - raise ValueError(msg) +def _get_allowed_apis(config: dict[str, Any]) -> tuple[AllowedApi, ...]: + raw_apis = _get(config, 'auth', 'allowed_apis') + if not isinstance(raw_apis, list) or not raw_apis: + msg = "Invalid config value: 'auth.allowed_apis' must be a non-empty list" + raise TypeError(msg) - normalized: list[str] = [] - for index, entry in enumerate(raw_urls): - if not isinstance(entry, str) or not entry.strip(): - msg = f"Invalid config value: 'auth.allowed_project_urls[{index}]' must be a non-empty string" + allowed_apis: list[AllowedApi] = [] + for index, entry in enumerate(raw_apis): + if not isinstance(entry, dict): + msg = f"Invalid config value: 'auth.allowed_apis[{index}]' must be a mapping" + raise TypeError(msg) + + raw_url = entry.get('url') + raw_tenant_uuid = entry.get('tenant_uuid') + if not isinstance(raw_url, str) or not raw_url.strip(): + msg = f"Invalid config value: 'auth.allowed_apis[{index}].url' must be a non-empty string" + raise ValueError(msg) + if not isinstance(raw_tenant_uuid, str) or not raw_tenant_uuid.strip(): + msg = f"Invalid config value: 'auth.allowed_apis[{index}].tenant_uuid' must be a non-empty string" raise ValueError(msg) - normalized.append(normalize_project_url(entry)) - return tuple(normalized) + url = raw_url.strip() + tenant_uuid = raw_tenant_uuid.strip() + normalized_url = url if url == WILDCARD else normalize_project_url(url) + if tenant_uuid != WILDCARD: + try: + tenant_uuid = str(UUID(tenant_uuid)) + except ValueError as error: + msg = f"Invalid config value: 'auth.allowed_apis[{index}].tenant_uuid' must be a UUID or '*'" + raise ValueError(msg) from error + + if WILDCARD in {normalized_url, tenant_uuid}: + logger.warning( + "Do not use in production! Config 'auth.allowed_apis[%d]' uses a wildcard (url=%s, tenant_uuid=%s) " + 'which allows anyone to use this API.', + index, + normalized_url, + tenant_uuid, + ) + + allowed_apis.append(AllowedApi(url=normalized_url, tenant_uuid=tenant_uuid)) + + return tuple(allowed_apis) def _resolve_existing_path(path: str, *, base_dir: pathlib.Path | None = None) -> str: @@ -182,7 +222,7 @@ def load_config(config_path: str | None = None) -> Config: raise TypeError(msg) return Config( - allowed_project_urls=_get_allowed_project_urls(config), + allowed_apis=_get_allowed_apis(config), log_level=_get_log_level(config), database=DatabaseConfig( host=_expand_env_vars(_get(config, 'database', 'host')), diff --git a/service/src/ai_document_plugin_service/api/auth.py b/service/src/ai_document_plugin_service/api/auth.py index 3211001..4d2b855 100644 --- a/service/src/ai_document_plugin_service/api/auth.py +++ b/service/src/ai_document_plugin_service/api/auth.py @@ -5,7 +5,7 @@ import fastapi import httpx -from ai_document_plugin_service.ai.common.config import Config, normalize_project_url +from ai_document_plugin_service.ai.common.config import WILDCARD, AllowedApi, Config, normalize_project_url from ai_document_plugin_service.api.jwt import extract_identity_from_token DSW_API_URL_HEADER = 'X-Dsw-Api-Url' @@ -21,8 +21,17 @@ class AuthenticatedUser: tenant_uuid: UUID -def is_allowed_project_url(api_url: str, allowed_project_urls: tuple[str, ...]) -> bool: - return normalize_project_url(api_url) in allowed_project_urls +def is_allowed_request(api_url: str, tenant_uuid: UUID, allowed_apis: tuple[AllowedApi, ...]) -> bool: + normalized_api_url = normalize_project_url(api_url) + tenant_uuid_str = str(tenant_uuid) + + for entry in allowed_apis: + url_matches = entry.url in {WILDCARD, normalized_api_url} + tenant_matches = entry.tenant_uuid in {WILDCARD, tenant_uuid_str} + if not (url_matches and tenant_matches): + continue + return True + return False def _parse_bearer_token(authorization: str | None) -> str | None: @@ -65,15 +74,15 @@ def verify_authenticated( config: Config = request.app.state.config normalized_api_url = normalize_project_url(dsw_api_url) - if not is_allowed_project_url(normalized_api_url, config.allowed_project_urls): - raise fastapi.HTTPException(status_code=401, detail='Unauthorized') - - if not _validate_dsw_user(normalized_api_url, token): - raise fastapi.HTTPException(status_code=401, detail='Unauthorized') - try: user_uuid, tenant_uuid = extract_identity_from_token(token) except ValueError as error: raise fastapi.HTTPException(status_code=400, detail=str(error)) from error + if not is_allowed_request(normalized_api_url, tenant_uuid, config.allowed_apis): + raise fastapi.HTTPException(status_code=401, detail='Unauthorized') + + if not _validate_dsw_user(normalized_api_url, token): + raise fastapi.HTTPException(status_code=401, detail='Unauthorized') + return AuthenticatedUser(token=token, api_url=normalized_api_url, user_uuid=user_uuid, tenant_uuid=tenant_uuid) diff --git a/service/tests/api/test_auth.py b/service/tests/api/test_auth.py index 126ab8e..17f00d8 100644 --- a/service/tests/api/test_auth.py +++ b/service/tests/api/test_auth.py @@ -1,15 +1,23 @@ +import base64 +import json from pathlib import Path from textwrap import dedent from unittest.mock import AsyncMock, MagicMock, patch +from uuid import UUID import httpx from fastapi.testclient import TestClient -from ai_document_plugin_service.ai.common.config import CONFIG_PATH_ENV_VAR, normalize_project_url -from ai_document_plugin_service.api.auth import DSW_API_URL_HEADER, is_allowed_project_url +from ai_document_plugin_service.ai.common.config import CONFIG_PATH_ENV_VAR, AllowedApi, normalize_project_url +from ai_document_plugin_service.api.auth import DSW_API_URL_HEADER, is_allowed_request from ai_document_plugin_service.app import create_app +def _make_token(*, user_uuid: str, tenant_uuid: str) -> str: + payload = base64.urlsafe_b64encode(json.dumps({'user_uuid': user_uuid, 'tenant_uuid': tenant_uuid}).encode()).decode() + return f'header.{payload}' + + def _write_config_files(base_dir: Path) -> Path: prompts_path = base_dir / 'prompts.yaml' prompts_path.write_text( @@ -52,8 +60,9 @@ def _write_config_files(base_dir: Path) -> Path: dsw: api_url: "{ALLOWED_URL}" auth: - allowed_project_urls: - - "{ALLOWED_URL}" + allowed_apis: + - url: "{ALLOWED_URL}" + tenant_uuid: "{ALLOWED_TENANT_UUID}" logging: level: "INFO" database: @@ -73,7 +82,9 @@ def _write_config_files(base_dir: Path) -> Path: return config_path ALLOWED_URL = 'https://dsw.example.com' -TEST_TOKEN = 'test-token' +ALLOWED_TENANT_UUID = '123e4567-e89b-12d3-a456-426614174000' +OTHER_TENANT_UUID = '00000000-0000-0000-0000-000000000000' +TEST_TOKEN = _make_token(user_uuid=ALLOWED_TENANT_UUID, tenant_uuid=ALLOWED_TENANT_UUID) def _auth_headers(*, api_url: str = ALLOWED_URL, token: str = TEST_TOKEN) -> dict[str, str]: @@ -87,10 +98,19 @@ def test_normalize_project_url_strips_trailing_slash() -> None: assert normalize_project_url('https://dsw.example.com/') == ALLOWED_URL -def test_is_allowed_project_url_matches_normalized_entries() -> None: - allowed = (ALLOWED_URL,) - assert is_allowed_project_url(f'{ALLOWED_URL}/', allowed) - assert not is_allowed_project_url('https://other.example.com', allowed) +def test_is_allowed_request_matches_normalized_url_and_tenant() -> None: + allowed = (AllowedApi(url=ALLOWED_URL, tenant_uuid=ALLOWED_TENANT_UUID),) + assert is_allowed_request(f'{ALLOWED_URL}/', UUID(ALLOWED_TENANT_UUID), allowed) + assert not is_allowed_request('https://other.example.com', UUID(ALLOWED_TENANT_UUID), allowed) + assert not is_allowed_request(ALLOWED_URL, UUID(OTHER_TENANT_UUID), allowed) + + +def test_is_allowed_request_allows_wildcard_url_or_tenant() -> None: + wildcard_url = (AllowedApi(url='*', tenant_uuid=ALLOWED_TENANT_UUID),) + wildcard_tenant = (AllowedApi(url=ALLOWED_URL, tenant_uuid='*'),) + + assert is_allowed_request('https://anything.example.com', UUID(ALLOWED_TENANT_UUID), wildcard_url) + assert is_allowed_request(ALLOWED_URL, UUID(OTHER_TENANT_UUID), wildcard_tenant) def test_health_check_does_not_require_auth(tmp_path: Path, monkeypatch) -> None: diff --git a/service/tests/common/test_config.py b/service/tests/common/test_config.py index ea16fac..67cf296 100644 --- a/service/tests/common/test_config.py +++ b/service/tests/common/test_config.py @@ -6,6 +6,7 @@ from ai_document_plugin_service.ai.common.config import ( CONFIG_PATH_ENV_VAR, + AllowedApi, load_config, ) from ai_document_plugin_service.app import create_app @@ -17,6 +18,10 @@ def _write_config_files( config_name: str = 'config.yaml', prompts_name: str = 'prompts.yaml', model: str = 'test-model', + allowed_apis_yaml: str = ( + ' - url: "https://dsw.example.com"\n' + ' tenant_uuid: "123e4567-e89b-12d3-a456-426614174000"' + ), ) -> Path: prompts_path = base_dir / prompts_name prompts_path.parent.mkdir(parents=True, exist_ok=True) @@ -61,8 +66,8 @@ def _write_config_files( dsw: api_url: "https://dsw.example.com" auth: - allowed_project_urls: - - "https://dsw.example.com" + allowed_apis: +{allowed_apis_yaml} logging: level: "INFO" database: @@ -98,10 +103,30 @@ def test_load_config_uses_env_config_path_and_resolves_prompts_relative_to_it( config = load_config() - assert config.allowed_project_urls == ('https://dsw.example.com',) + assert config.allowed_apis == (AllowedApi(url='https://dsw.example.com', tenant_uuid='123e4567-e89b-12d3-a456-426614174000'),) assert config.files.prompts_path == str(config_path.parent / 'nested/prompts.custom.yaml') +def test_load_config_warns_on_wildcard_allowed_apis_entry( + tmp_path: Path, + monkeypatch, + caplog, +) -> None: + config_path = _write_config_files( + tmp_path, + allowed_apis_yaml=' - url: "*"\n tenant_uuid: "*"', + ) + monkeypatch.setenv(CONFIG_PATH_ENV_VAR, str(config_path)) + monkeypatch.setenv('TEST_API_KEY', 'secret-from-env') + monkeypatch.chdir(tmp_path) + + with caplog.at_level('WARNING'): + config = load_config() + + assert config.allowed_apis == (AllowedApi(url='*', tenant_uuid='*'),) + assert any('wildcard' in record.message for record in caplog.records) + + def test_load_config_falls_back_to_default_path_when_env_is_missing( tmp_path: Path, monkeypatch, @@ -160,8 +185,9 @@ def test_load_config_rejects_absolute_prompts_path_in_config( dsw: api_url: "https://dsw.example.com" auth: - allowed_project_urls: - - "https://dsw.example.com" + allowed_apis: + - url: "https://dsw.example.com" + tenant_uuid: "123e4567-e89b-12d3-a456-426614174000" logging: level: "INFO" database: From 822a1bccaa8d6a415eac945a4c35a0824831a60f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Frnka?= Date: Fri, 10 Jul 2026 22:57:49 +0200 Subject: [PATCH 2/3] Fix tests --- service/config.test.yaml | 20 ++ service/pyproject.toml | 5 + service/tests/api/test_auth.py | 118 ++--------- service/tests/common/test_config.py | 190 +++--------------- .../tests/generation/test_dmp_generator.py | 8 +- service/uv.lock | 14 ++ 6 files changed, 97 insertions(+), 258 deletions(-) create mode 100644 service/config.test.yaml diff --git a/service/config.test.yaml b/service/config.test.yaml new file mode 100644 index 0000000..8b6b93d --- /dev/null +++ b/service/config.test.yaml @@ -0,0 +1,20 @@ +auth: + allowed_apis: + - url: "https://your-dsw-instance.example.com/wizard-api" + tenant_uuid: "123e4567-e89b-12d3-a456-426614174000" + +logging: + level: "INFO" + +database: + host: "localhost" + port: "5432" + name: "ai_document_plugin" + user: "ai_document_plugin" + password: "ai_document_plugin" + schema: "public" + +files: + prompts_path: "prompts.yaml" + +max_parallel_executions: 2 diff --git a/service/pyproject.toml b/service/pyproject.toml index 2bd0eb4..c987f6c 100644 --- a/service/pyproject.toml +++ b/service/pyproject.toml @@ -44,8 +44,13 @@ dev = [ "ruff", "ty", "pytest", + "pytest-asyncio" ] +[tool.pytest.ini_options] +asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "function" + [build-system] requires = ["uv_build>=0.11.23,<0.12.0"] build-backend = "uv_build" diff --git a/service/tests/api/test_auth.py b/service/tests/api/test_auth.py index 17f00d8..1172d28 100644 --- a/service/tests/api/test_auth.py +++ b/service/tests/api/test_auth.py @@ -1,7 +1,6 @@ import base64 import json from pathlib import Path -from textwrap import dedent from unittest.mock import AsyncMock, MagicMock, patch from uuid import UUID @@ -12,79 +11,23 @@ from ai_document_plugin_service.api.auth import DSW_API_URL_HEADER, is_allowed_request from ai_document_plugin_service.app import create_app +TEST_CONFIG_PATH = Path(__file__).resolve().parents[2] / 'config.test.yaml' + +ALLOWED_URL = 'https://your-dsw-instance.example.com/wizard-api' +ALLOWED_TENANT_UUID = '123e4567-e89b-12d3-a456-426614174000' +OTHER_TENANT_UUID = '00000000-0000-0000-0000-000000000000' + def _make_token(*, user_uuid: str, tenant_uuid: str) -> str: payload = base64.urlsafe_b64encode(json.dumps({'user_uuid': user_uuid, 'tenant_uuid': tenant_uuid}).encode()).decode() return f'header.{payload}' -def _write_config_files(base_dir: Path) -> Path: - prompts_path = base_dir / 'prompts.yaml' - prompts_path.write_text( - dedent( - ''' - assignment: - temperature: 0.1 - max_tokens: 100 - system_message: "assignment system" - user_message: "assignment user" - section_id: - temperature: 0.2 - max_tokens: 110 - system_message: "section system" - user_message: "section user" - dmp_generation: - temperature: 0.3 - max_tokens: 120 - system_message: "generation system" - dmp_polishing: - temperature: 0.4 - max_tokens: 130 - system_message: "polishing system" - user_message: "polishing user" - ''' - ).strip() - + '\n', - encoding='utf-8', - ) +TEST_TOKEN = _make_token(user_uuid=ALLOWED_TENANT_UUID, tenant_uuid=ALLOWED_TENANT_UUID) - config_path = base_dir / 'config.yaml' - config_path.write_text( - dedent( - f''' - llm_response_generation: - api_key: "$TEST_API_KEY" - api_url: "https://example.com/v1" - model: "test-model" - workers: 2 - dsw: - api_url: "{ALLOWED_URL}" - auth: - allowed_apis: - - url: "{ALLOWED_URL}" - tenant_uuid: "{ALLOWED_TENANT_UUID}" - logging: - level: "INFO" - database: - host: "localhost" - port: 5432 - name: "ai_document_plugin" - user: "plugin_user" - password: "plugin_password" - schema: "public" - files: - prompts_path: "prompts.yaml" - ''' - ).strip() - + '\n', - encoding='utf-8', - ) - return config_path -ALLOWED_URL = 'https://dsw.example.com' -ALLOWED_TENANT_UUID = '123e4567-e89b-12d3-a456-426614174000' -OTHER_TENANT_UUID = '00000000-0000-0000-0000-000000000000' -TEST_TOKEN = _make_token(user_uuid=ALLOWED_TENANT_UUID, tenant_uuid=ALLOWED_TENANT_UUID) +def _use_test_config(monkeypatch) -> None: + monkeypatch.setenv(CONFIG_PATH_ENV_VAR, str(TEST_CONFIG_PATH)) def _auth_headers(*, api_url: str = ALLOWED_URL, token: str = TEST_TOKEN) -> dict[str, str]: @@ -95,14 +38,14 @@ def _auth_headers(*, api_url: str = ALLOWED_URL, token: str = TEST_TOKEN) -> dic def test_normalize_project_url_strips_trailing_slash() -> None: - assert normalize_project_url('https://dsw.example.com/') == ALLOWED_URL + assert normalize_project_url(f'{ALLOWED_URL}/') == ALLOWED_URL def test_is_allowed_request_matches_normalized_url_and_tenant() -> None: - allowed = (AllowedApi(url=ALLOWED_URL, tenant_uuid=ALLOWED_TENANT_UUID),) - assert is_allowed_request(f'{ALLOWED_URL}/', UUID(ALLOWED_TENANT_UUID), allowed) - assert not is_allowed_request('https://other.example.com', UUID(ALLOWED_TENANT_UUID), allowed) - assert not is_allowed_request(ALLOWED_URL, UUID(OTHER_TENANT_UUID), allowed) + allowed_api = (AllowedApi(url=ALLOWED_URL, tenant_uuid=ALLOWED_TENANT_UUID),AllowedApi(url="https://some-other-url.com", tenant_uuid=OTHER_TENANT_UUID)) + assert is_allowed_request(f'{ALLOWED_URL}/', UUID(ALLOWED_TENANT_UUID), allowed_api) + assert not is_allowed_request('https://other.example.com', UUID(ALLOWED_TENANT_UUID), allowed_api) + assert not is_allowed_request(ALLOWED_URL, UUID(OTHER_TENANT_UUID), allowed_api) def test_is_allowed_request_allows_wildcard_url_or_tenant() -> None: @@ -113,11 +56,8 @@ def test_is_allowed_request_allows_wildcard_url_or_tenant() -> None: assert is_allowed_request(ALLOWED_URL, UUID(OTHER_TENANT_UUID), wildcard_tenant) -def test_health_check_does_not_require_auth(tmp_path: Path, monkeypatch) -> None: - config_path = _write_config_files(tmp_path) - monkeypatch.setenv(CONFIG_PATH_ENV_VAR, str(config_path)) - monkeypatch.setenv('TEST_API_KEY', 'test-secret') - monkeypatch.chdir(tmp_path) +def test_health_check_does_not_require_auth(monkeypatch) -> None: + _use_test_config(monkeypatch) client = TestClient(create_app(run_migrations=False)) response = client.get('/health') @@ -126,11 +66,8 @@ def test_health_check_does_not_require_auth(tmp_path: Path, monkeypatch) -> None assert response.json() == {'status': 'healthy'} -def test_protected_route_returns_401_without_headers(tmp_path: Path, monkeypatch) -> None: - config_path = _write_config_files(tmp_path) - monkeypatch.setenv(CONFIG_PATH_ENV_VAR, str(config_path)) - monkeypatch.setenv('TEST_API_KEY', 'test-secret') - monkeypatch.chdir(tmp_path) +def test_protected_route_returns_401_without_headers(monkeypatch) -> None: + _use_test_config(monkeypatch) client = TestClient(create_app(run_migrations=False)) response = client.get('/templates') @@ -138,11 +75,8 @@ def test_protected_route_returns_401_without_headers(tmp_path: Path, monkeypatch assert response.status_code == 401 -def test_protected_route_returns_401_for_disallowed_url(tmp_path: Path, monkeypatch) -> None: - config_path = _write_config_files(tmp_path) - monkeypatch.setenv(CONFIG_PATH_ENV_VAR, str(config_path)) - monkeypatch.setenv('TEST_API_KEY', 'test-secret') - monkeypatch.chdir(tmp_path) +def test_protected_route_returns_401_for_disallowed_url(monkeypatch) -> None: + _use_test_config(monkeypatch) client = TestClient(create_app(run_migrations=False)) response = client.get( @@ -156,15 +90,11 @@ def test_protected_route_returns_401_for_disallowed_url(tmp_path: Path, monkeypa @patch('ai_document_plugin_service.api.auth.httpx.get') def test_protected_route_returns_401_when_dsw_rejects_token( mock_httpx_get: MagicMock, - tmp_path: Path, monkeypatch, ) -> None: mock_httpx_get.return_value = httpx.Response(403, request=httpx.Request('GET', ALLOWED_URL)) - config_path = _write_config_files(tmp_path) - monkeypatch.setenv(CONFIG_PATH_ENV_VAR, str(config_path)) - monkeypatch.setenv('TEST_API_KEY', 'test-secret') - monkeypatch.chdir(tmp_path) + _use_test_config(monkeypatch) client = TestClient(create_app(run_migrations=False)) response = client.get('/templates', headers=_auth_headers()) @@ -177,7 +107,6 @@ def test_protected_route_returns_401_when_dsw_rejects_token( def test_protected_route_succeeds_when_dsw_validates_user( mock_postgres_db: MagicMock, mock_httpx_get: MagicMock, - tmp_path: Path, monkeypatch, ) -> None: mock_httpx_get.return_value = httpx.Response(200, request=httpx.Request('GET', ALLOWED_URL)) @@ -186,10 +115,7 @@ def test_protected_route_succeeds_when_dsw_validates_user( ) mock_postgres_db.return_value.dispose = AsyncMock() - config_path = _write_config_files(tmp_path) - monkeypatch.setenv(CONFIG_PATH_ENV_VAR, str(config_path)) - monkeypatch.setenv('TEST_API_KEY', 'test-secret') - monkeypatch.chdir(tmp_path) + _use_test_config(monkeypatch) client = TestClient(create_app(run_migrations=False)) response = client.get('/templates', headers=_auth_headers()) diff --git a/service/tests/common/test_config.py b/service/tests/common/test_config.py index 67cf296..9f421d3 100644 --- a/service/tests/common/test_config.py +++ b/service/tests/common/test_config.py @@ -1,7 +1,8 @@ +import shutil from pathlib import Path -from textwrap import dedent from unittest.mock import patch +import yaml from fastapi.testclient import TestClient from ai_document_plugin_service.ai.common.config import ( @@ -11,79 +12,29 @@ ) from ai_document_plugin_service.app import create_app +SERVICE_DIR = Path(__file__).resolve().parents[2] +TEST_CONFIG_PATH = SERVICE_DIR / 'config.test.yaml' +TEST_PROMPTS_PATH = SERVICE_DIR / 'prompts.yaml' -def _write_config_files( + +def _copy_test_config( base_dir: Path, *, config_name: str = 'config.yaml', - prompts_name: str = 'prompts.yaml', - model: str = 'test-model', - allowed_apis_yaml: str = ( - ' - url: "https://dsw.example.com"\n' - ' tenant_uuid: "123e4567-e89b-12d3-a456-426614174000"' - ), + allowed_apis: list[dict[str, str]] | None = None, + prompts_path: str | None = None, ) -> Path: - prompts_path = base_dir / prompts_name - prompts_path.parent.mkdir(parents=True, exist_ok=True) - prompts_path.write_text( - dedent( - ''' - assignment: - temperature: 0.1 - max_tokens: 100 - system_message: "assignment system" - user_message: "assignment user" - section_id: - temperature: 0.2 - max_tokens: 110 - system_message: "section system" - user_message: "section user" - dmp_generation: - temperature: 0.3 - max_tokens: 120 - system_message: "generation system" - dmp_polishing: - temperature: 0.4 - max_tokens: 130 - system_message: "polishing system" - user_message: "polishing user" - ''' - ).strip() - + '\n', - encoding='utf-8', - ) + base_dir.mkdir(parents=True, exist_ok=True) + shutil.copyfile(TEST_PROMPTS_PATH, base_dir / 'prompts.yaml') + + config = yaml.safe_load(TEST_CONFIG_PATH.read_text(encoding='utf-8')) + if allowed_apis is not None: + config['auth']['allowed_apis'] = allowed_apis + if prompts_path is not None: + config['files']['prompts_path'] = prompts_path config_path = base_dir / config_name - config_path.parent.mkdir(parents=True, exist_ok=True) - config_path.write_text( - dedent( - f''' - llm_response_generation: - api_key: "$TEST_API_KEY" - api_url: "https://example.com/v1" - model: "{model}" - workers: 2 - dsw: - api_url: "https://dsw.example.com" - auth: - allowed_apis: -{allowed_apis_yaml} - logging: - level: "INFO" - database: - host: "localhost" - port: 5432 - name: "ai_document_plugin" - user: "plugin_user" - password: "plugin_password" - schema: "public" - files: - prompts_path: "{prompts_name}" - ''' - ).strip() - + '\n', - encoding='utf-8', - ) + config_path.write_text(yaml.safe_dump(config), encoding='utf-8') return config_path @@ -91,20 +42,15 @@ def test_load_config_uses_env_config_path_and_resolves_prompts_relative_to_it( tmp_path: Path, monkeypatch, ) -> None: - config_path = _write_config_files( - tmp_path / 'custom-config', - config_name='service.custom.yaml', - prompts_name='nested/prompts.custom.yaml', - model='env-model', - ) - monkeypatch.setenv(CONFIG_PATH_ENV_VAR, str(config_path)) - monkeypatch.setenv('TEST_API_KEY', 'secret-from-env') + monkeypatch.setenv(CONFIG_PATH_ENV_VAR, str(TEST_CONFIG_PATH)) monkeypatch.chdir(tmp_path) config = load_config() - assert config.allowed_apis == (AllowedApi(url='https://dsw.example.com', tenant_uuid='123e4567-e89b-12d3-a456-426614174000'),) - assert config.files.prompts_path == str(config_path.parent / 'nested/prompts.custom.yaml') + assert config.allowed_apis == ( + AllowedApi(url='https://your-dsw-instance.example.com/wizard-api', tenant_uuid='123e4567-e89b-12d3-a456-426614174000'), + ) + assert config.files.prompts_path == str(TEST_PROMPTS_PATH) def test_load_config_warns_on_wildcard_allowed_apis_entry( @@ -112,12 +58,11 @@ def test_load_config_warns_on_wildcard_allowed_apis_entry( monkeypatch, caplog, ) -> None: - config_path = _write_config_files( + config_path = _copy_test_config( tmp_path, - allowed_apis_yaml=' - url: "*"\n tenant_uuid: "*"', + allowed_apis=[{'url': '*', 'tenant_uuid': '*'}], ) monkeypatch.setenv(CONFIG_PATH_ENV_VAR, str(config_path)) - monkeypatch.setenv('TEST_API_KEY', 'secret-from-env') monkeypatch.chdir(tmp_path) with caplog.at_level('WARNING'): @@ -131,9 +76,8 @@ def test_load_config_falls_back_to_default_path_when_env_is_missing( tmp_path: Path, monkeypatch, ) -> None: - config_path = _write_config_files(tmp_path, model='default-model') + config_path = _copy_test_config(tmp_path) monkeypatch.delenv(CONFIG_PATH_ENV_VAR, raising=False) - monkeypatch.setenv('TEST_API_KEY', 'default-secret') monkeypatch.chdir(tmp_path) config = load_config() @@ -145,66 +89,8 @@ def test_load_config_rejects_absolute_prompts_path_in_config( tmp_path: Path, monkeypatch, ) -> None: - absolute_prompts_path = tmp_path / 'external-prompts.yaml' - absolute_prompts_path.write_text( - dedent( - ''' - assignment: - temperature: 0.1 - max_tokens: 100 - system_message: "assignment system" - user_message: "assignment user" - section_id: - temperature: 0.2 - max_tokens: 110 - system_message: "section system" - user_message: "section user" - dmp_generation: - temperature: 0.3 - max_tokens: 120 - system_message: "generation system" - dmp_polishing: - temperature: 0.4 - max_tokens: 130 - system_message: "polishing system" - user_message: "polishing user" - ''' - ).strip() - + '\n', - encoding='utf-8', - ) - config_path = tmp_path / 'config.yaml' - config_path.write_text( - dedent( - f''' - llm_response_generation: - api_key: "$TEST_API_KEY" - api_url: "https://example.com/v1" - model: "invalid-prompts-model" - workers: 2 - dsw: - api_url: "https://dsw.example.com" - auth: - allowed_apis: - - url: "https://dsw.example.com" - tenant_uuid: "123e4567-e89b-12d3-a456-426614174000" - logging: - level: "INFO" - database: - host: "localhost" - port: 5432 - name: "ai_document_plugin" - user: "plugin_user" - password: "plugin_password" - schema: "public" - files: - prompts_path: "{absolute_prompts_path.as_posix()}" - ''' - ).strip() - + '\n', - encoding='utf-8', - ) - monkeypatch.setenv('TEST_API_KEY', 'default-secret') + absolute_prompts_path = tmp_path / 'prompts.yaml' + _copy_test_config(tmp_path, prompts_path=absolute_prompts_path.as_posix()) monkeypatch.chdir(tmp_path) try: @@ -219,18 +105,12 @@ def test_create_app_stores_the_resolved_config( tmp_path: Path, monkeypatch, ) -> None: - config_path = _write_config_files( - tmp_path / 'runtime', - config_name='runtime-config.yaml', - prompts_name='runtime-prompts.yaml', - ) - monkeypatch.setenv(CONFIG_PATH_ENV_VAR, str(config_path)) - monkeypatch.setenv('TEST_API_KEY', 'create-app-secret') + monkeypatch.setenv(CONFIG_PATH_ENV_VAR, str(TEST_CONFIG_PATH)) monkeypatch.chdir(tmp_path) app = create_app(run_migrations=False) - assert app.state.config.files.prompts_path == str(config_path.parent / 'runtime-prompts.yaml') + assert app.state.config.files.prompts_path == str(TEST_PROMPTS_PATH) @patch('ai_document_plugin_service.app.run_startup_migrations') @@ -239,13 +119,7 @@ def test_app_startup_runs_migrations_with_loaded_config( tmp_path: Path, monkeypatch, ) -> None: - config_path = _write_config_files( - tmp_path / 'runtime', - config_name='runtime-config.yaml', - prompts_name='runtime-prompts.yaml', - ) - monkeypatch.setenv(CONFIG_PATH_ENV_VAR, str(config_path)) - monkeypatch.setenv('TEST_API_KEY', 'create-app-secret') + monkeypatch.setenv(CONFIG_PATH_ENV_VAR, str(TEST_CONFIG_PATH)) monkeypatch.chdir(tmp_path) app = create_app() @@ -254,4 +128,4 @@ def test_app_startup_runs_migrations_with_loaded_config( response = client.get('/health') assert response.status_code == 200 - mock_run_startup_migrations.assert_called_once_with(app.state.config, str(config_path)) + mock_run_startup_migrations.assert_called_once_with(app.state.config, str(TEST_CONFIG_PATH)) diff --git a/service/tests/generation/test_dmp_generator.py b/service/tests/generation/test_dmp_generator.py index 7674abe..2705321 100644 --- a/service/tests/generation/test_dmp_generator.py +++ b/service/tests/generation/test_dmp_generator.py @@ -508,7 +508,7 @@ def test_parser_component_item_select_reply_uses_integration_raw_url() -> None: ) -def test_run_renders_parent_and_leaf_sections() -> None: +async def test_run_renders_parent_and_leaf_sections() -> None: stub = StubGenerationLLM() component = _component(stub) km = _km_fixture() @@ -536,7 +536,7 @@ def test_run_renders_parent_and_leaf_sections() -> None: 'ch.listQ.itemQ': {'value': {'type': 'AnswerReply', 'value': 'yes'}}, } - result = component.run( + result = await component.run_async( replies=replies, km=km, new_assignments=_serialize_assignments(assignments), @@ -554,7 +554,7 @@ def test_run_renders_parent_and_leaf_sections() -> None: assert stats is not None -def test_run_handles_empty_section() -> None: +async def test_run_handles_empty_section() -> None: stub = StubGenerationLLM() component = _component(stub) km = _km_fixture() @@ -562,7 +562,7 @@ def test_run_handles_empty_section() -> None: SectionAssignment(id='s0', title='Empty'), ] - result = component.run( + result = await component.run_async( replies={}, km=km, new_assignments=_serialize_assignments(assignments), diff --git a/service/uv.lock b/service/uv.lock index 6ca3f9a..ad74db4 100644 --- a/service/uv.lock +++ b/service/uv.lock @@ -34,6 +34,7 @@ dependencies = [ [package.dev-dependencies] dev = [ { name = "pytest" }, + { name = "pytest-asyncio" }, { name = "ruff" }, { name = "ty" }, { name = "uvicorn" }, @@ -60,6 +61,7 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ { name = "pytest" }, + { name = "pytest-asyncio" }, { name = "ruff" }, { name = "ty" }, { name = "uvicorn" }, @@ -901,6 +903,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, ] +[[package]] +name = "pytest-asyncio" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" }, +] + [[package]] name = "python-dateutil" version = "2.9.0.post0" From 35bc98b0e9a22e01c993e9118f2ca3d458256f77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Frnka?= Date: Fri, 10 Jul 2026 22:59:57 +0200 Subject: [PATCH 3/3] Added tests --- .github/workflows/code-quality.yml | 25 +++++++++++++++++++++++++ service/Makefile | 4 ++++ 2 files changed, 29 insertions(+) diff --git a/.github/workflows/code-quality.yml b/.github/workflows/code-quality.yml index f9a7826..e87cee8 100644 --- a/.github/workflows/code-quality.yml +++ b/.github/workflows/code-quality.yml @@ -120,3 +120,28 @@ jobs: - name: '[check] Run Ruff' working-directory: ./service run: make typecheck + + service-test: + name: 'Service: Test' + runs-on: ubuntu-latest + + steps: + - name: '[setup] Check out repository' + uses: actions/checkout@v7 + + - name: '[setup] Install uv' + uses: astral-sh/setup-uv@v7 + with: + python-version: ${{ env.PYTHON_VERSION }} + + - name: '[setup] Set up Python' + working-directory: ./service + run: uv python install + + - name: '[app] Install' + working-directory: ./service + run: make install + + - name: '[check] Run pytest' + working-directory: ./service + run: make test diff --git a/service/Makefile b/service/Makefile index 4a22be9..e8aeb2e 100644 --- a/service/Makefile +++ b/service/Makefile @@ -10,6 +10,10 @@ lint: typecheck: uv run ty check +.PHONY: test +test: + uv run pytest + .PHONY: format format: uv run ruff format