From 9e3ed3f4a7209d0b9a25cad250b4b9ef2831c9cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Basler?= Date: Thu, 16 Apr 2026 08:49:10 +0200 Subject: [PATCH 1/5] [client-python] feat(pyoaev): add multi-tenancy (#205) --- pyoaev/client.py | 12 ++++++++++-- pyoaev/configuration/settings_loader.py | 6 ++++++ pyoaev/daemons/base_daemon.py | 6 ++++-- pyoaev/helpers.py | 1 + pyoaev/utils.py | 11 +++++++++-- 5 files changed, 30 insertions(+), 6 deletions(-) diff --git a/pyoaev/client.py b/pyoaev/client.py index a86a76b..450e99d 100644 --- a/pyoaev/client.py +++ b/pyoaev/client.py @@ -1,5 +1,6 @@ from typing import TYPE_CHECKING, Any, BinaryIO, Dict, List, Optional, Union from urllib import parse +from uuid import UUID import requests @@ -23,6 +24,7 @@ def __init__( pagination: Optional[str] = None, order_by: Optional[str] = None, ssl_verify: Union[bool, str] = True, + tenant_id: Optional[UUID] = None, **kwargs: Any, ) -> None: @@ -32,6 +34,7 @@ def __init__( raise ValueError("A TOKEN must be set") self.url = url + self.tenant_id = tenant_id self.timeout = timeout #: Headers that will be used in request to OpenAEV self.headers = { @@ -109,9 +112,14 @@ def _build_url(self, path: str) -> str: Returns: The full URL """ - if path.startswith("http://") or path.startswith("https://"): + if parse.urlparse(path).scheme in ("http", "https"): return path - return f"{self.url}/api{path}" + base_url = self.url.rstrip("/") + normalized_path = path.lstrip("/") + if self.tenant_id: + return f"{base_url}/api/tenants/{self.tenant_id}/{normalized_path}" + else: + return f"{base_url}/api/{normalized_path}" def _get_session_opts(self) -> Dict[str, Any]: return { diff --git a/pyoaev/configuration/settings_loader.py b/pyoaev/configuration/settings_loader.py index 8d41e4f..8e4dcdc 100644 --- a/pyoaev/configuration/settings_loader.py +++ b/pyoaev/configuration/settings_loader.py @@ -3,6 +3,7 @@ from datetime import timedelta from pathlib import Path from typing import Annotated, Literal +from uuid import UUID from pydantic import BaseModel, ConfigDict, Field, HttpUrl, PlainSerializer from pydantic_settings import ( @@ -99,6 +100,11 @@ class ConfigLoaderOAEV(BaseConfigModel): token: str = Field( description="The token for the OpenAEV platform.", ) + tenant_id: UUID | None = Field( + default=None, + description="Identifier of the tenant within the OpenAEV platform. Used in multi-tenant environments to scope " + "API requests and ensure data isolation between different tenants.", + ) class ConfigLoaderCollector(BaseConfigModel): diff --git a/pyoaev/daemons/base_daemon.py b/pyoaev/daemons/base_daemon.py index 7dcc4e4..9728a87 100644 --- a/pyoaev/daemons/base_daemon.py +++ b/pyoaev/daemons/base_daemon.py @@ -2,6 +2,7 @@ from abc import ABC, abstractmethod from inspect import signature from types import FunctionType +from uuid import UUID from pyoaev.client import OpenAEV from pyoaev.configuration import Configuration @@ -37,6 +38,7 @@ def __init__( self.api = api_client or BaseDaemon.__get_default_api_client( url=self._configuration.get("openaev_url"), token=self._configuration.get("openaev_token"), + tenant_id=self._configuration.get("openaev_tenant_id"), ) # logging @@ -131,8 +133,8 @@ def get_id(self): ) @classmethod - def __get_default_api_client(cls, url, token): - return OpenAEV(url=url, token=token) + def __get_default_api_client(cls, url, token, tenant_id: UUID | None): + return OpenAEV(url=url, token=token, tenant_id=tenant_id) @classmethod def __get_default_logger(cls, log_level, name): diff --git a/pyoaev/helpers.py b/pyoaev/helpers.py index a422151..a85971e 100644 --- a/pyoaev/helpers.py +++ b/pyoaev/helpers.py @@ -322,6 +322,7 @@ def __init__(self, config: OpenAEVConfigHelper, icon) -> None: self.api = OpenAEV( url=config.get_conf("openaev_url"), token=config.get_conf("openaev_token"), + tenant_id=config.get_conf("openaev_tenant_id"), ) # Get the mq configuration from api self.config = { diff --git a/pyoaev/utils.py b/pyoaev/utils.py index c620152..5d922f2 100644 --- a/pyoaev/utils.py +++ b/pyoaev/utils.py @@ -186,6 +186,7 @@ def __init__(self, api, config, logger, ping_type) -> None: threading.Thread.__init__(self) self.ping_type = ping_type self.api = api + self.tenant_id = getattr(self.api, "tenant_id", None) self.config = config self.logger = logger self.in_error = False @@ -203,9 +204,15 @@ def ping(self) -> None: self.exit_event.wait(40) def run(self) -> None: - self.logger.info("Starting PingAlive thread") + self.logger.info( + "Starting PingAlive thread", + {"tenant_id": str(self.tenant_id) if self.tenant_id else None}, + ) self.ping() def stop(self) -> None: - self.logger.info("Preparing PingAlive for clean shutdown") + self.logger.info( + "Preparing PingAlive for clean shutdown", + {"tenant_id": str(self.tenant_id) if self.tenant_id else None}, + ) self.exit_event.set() From 81073c292d95c2bfeb2dc5cb16b263919a79bfd8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Basler?= Date: Mon, 20 Apr 2026 15:25:13 +0200 Subject: [PATCH 2/5] [client-python] Add tests multi-tenant --- ...ulti_tenant_api_routing_constraint.feature | 12 +++ ...est_multi_tenant_api_routing_constraint.py | 38 ++++++++++ .../features/multi_tenant_api_routing.feature | 17 +++++ ...lti_tenant_base_daemon_propagation.feature | 12 +++ ...lti_tenant_endpoint_search_targets.feature | 12 +++ .../features/test_multi_tenant_api_routing.py | 46 ++++++++++++ ...st_multi_tenant_base_daemon_propagation.py | 72 ++++++++++++++++++ ...st_multi_tenant_endpoint_search_targets.py | 73 +++++++++++++++++++ 8 files changed, 282 insertions(+) create mode 100644 test/bdd/constraints/multi_tenant_api_routing_constraint.feature create mode 100644 test/bdd/constraints/test_multi_tenant_api_routing_constraint.py create mode 100644 test/bdd/features/multi_tenant_api_routing.feature create mode 100644 test/bdd/features/multi_tenant_base_daemon_propagation.feature create mode 100644 test/bdd/features/multi_tenant_endpoint_search_targets.feature create mode 100644 test/bdd/features/test_multi_tenant_api_routing.py create mode 100644 test/bdd/features/test_multi_tenant_base_daemon_propagation.py create mode 100644 test/bdd/features/test_multi_tenant_endpoint_search_targets.py diff --git a/test/bdd/constraints/multi_tenant_api_routing_constraint.feature b/test/bdd/constraints/multi_tenant_api_routing_constraint.feature new file mode 100644 index 0000000..ccfbf54 --- /dev/null +++ b/test/bdd/constraints/multi_tenant_api_routing_constraint.feature @@ -0,0 +1,12 @@ +Feature: URL normalization in OpenAEV client + + Scenario Outline: URL normalization combines base_url and path correctly + Given an OpenAEV client with base_url "" + When I build the URL for "" + Then the resulting URL should be "" + + Examples: + | base_url | path | expected | + | base_url | path | base_url/api/path | + | base_url/ | /path | base_url/api/path | + | base_url// | //path | base_url/api/path | \ No newline at end of file diff --git a/test/bdd/constraints/test_multi_tenant_api_routing_constraint.py b/test/bdd/constraints/test_multi_tenant_api_routing_constraint.py new file mode 100644 index 0000000..f93e49b --- /dev/null +++ b/test/bdd/constraints/test_multi_tenant_api_routing_constraint.py @@ -0,0 +1,38 @@ +import pytest + +from pyoaev import OpenAEV + + +@pytest.mark.parametrize( + "base_url, input_path, expected", + [ + ( + "base_url", + "path", + "base_url/api/path", + ), + ( + "base_url/", + "/path", + "base_url/api/path", + ), + ( + "base_url//", + "//path", + "base_url/api/path", + ), + ], + ids=[ + "clean-base-url-and-relative-path", + "base-url-trailing-slash", + "base-url-double-slash-and-path-double-slash", + ], +) +def test_url_normalization(base_url, input_path, expected): + client = OpenAEV( + url=base_url, + token="token", + tenant_id=None, + ) + result = client._build_url(input_path) + assert result == expected diff --git a/test/bdd/features/multi_tenant_api_routing.feature b/test/bdd/features/multi_tenant_api_routing.feature new file mode 100644 index 0000000..6cbd189 --- /dev/null +++ b/test/bdd/features/multi_tenant_api_routing.feature @@ -0,0 +1,17 @@ +Feature: Multi-tenant API routing in OpenAEV client + + Scenario: Full URL bypasses tenant routing + Given an OpenAEV client with tenant_id "2cffad3a-0001-4078-b0e2-ef74274022c3" + When I build the URL for "https://external.service/api/path" + Then the resulting URL should be "https://external.service/api/path" + + Scenario Outline: Relative path routing behavior + Given an OpenAEV client with tenant_id "" + When I build the URL for "/path" + Then the resulting URL should be "" + + Examples: + | tenant_id | output | + | None | base_url/api/path | + | 2cffad3a-0001-4078-b0e2-ef74274022c3 | base_url/api/tenants/2cffad3a-0001-4078-b0e2-ef74274022c3/path | + diff --git a/test/bdd/features/multi_tenant_base_daemon_propagation.feature b/test/bdd/features/multi_tenant_base_daemon_propagation.feature new file mode 100644 index 0000000..4698deb --- /dev/null +++ b/test/bdd/features/multi_tenant_base_daemon_propagation.feature @@ -0,0 +1,12 @@ +Feature: Tenant propagation in BaseDaemon API client initialization + + Scenario Outline: BaseDaemon propagates tenant_id correctly from configuration + Given a daemon configuration with + When the BaseDaemon is initialized + Then the API client should be created with tenant_id "" + + Examples: + | tenant_id | expected_tenant_id | + | Missing tenant key | None | + | None | None | + | 2cffad3a-0001-4078-b0e2-ef74274022c3 | 2cffad3a-0001-4078-b0e2-ef74274022c3 | \ No newline at end of file diff --git a/test/bdd/features/multi_tenant_endpoint_search_targets.feature b/test/bdd/features/multi_tenant_endpoint_search_targets.feature new file mode 100644 index 0000000..9b2ae5a --- /dev/null +++ b/test/bdd/features/multi_tenant_endpoint_search_targets.feature @@ -0,0 +1,12 @@ +Feature: searchTargets API routing with and without tenant_id + + Scenario Outline: searchTargets routing behavior + Given an OpenAEV client with tenant_id "" + And a valid SearchPaginationInput + When I call searchTargets on endpoint + Then the request URL should be "" + + Examples: + | tenant_id | expected_url | + | None | url/api/endpoints/targets | + | 2cffad3a-0001-4078-b0e2-ef74274022c3 | url/api/tenants/2cffad3a-0001-4078-b0e2-ef74274022c3/endpoints/targets | \ No newline at end of file diff --git a/test/bdd/features/test_multi_tenant_api_routing.py b/test/bdd/features/test_multi_tenant_api_routing.py new file mode 100644 index 0000000..e82ba2d --- /dev/null +++ b/test/bdd/features/test_multi_tenant_api_routing.py @@ -0,0 +1,46 @@ +from uuid import UUID + +import pytest + +from pyoaev import OpenAEV + + +@pytest.mark.parametrize( + "tenant_id, path, expected", + [ + ( + None, + "/path", + "base_url/api/path", + ), + ( + UUID("2cffad3a-0001-4078-b0e2-ef74274022c3"), + "/path", + "base_url/api/tenants/2cffad3a-0001-4078-b0e2-ef74274022c3/path", + ), + ( + None, + "https://external.service/api/path", + "https://external.service/api/path", + ), + ( + UUID("2cffad3a-0001-4078-b0e2-ef74274022c3"), + "https://external.service/api/path", + "https://external.service/api/path", + ), + ], + ids=[ + "legacy-relative-path", + "tenant-relative-path", + "legacy-full-url-bypass", + "tenant-full-url-bypass", + ], +) +def test_build_url_behavior(tenant_id, path, expected): + client = OpenAEV( + "base_url", + "token", + tenant_id=tenant_id, + ) + result = client._build_url(path) + assert result == expected diff --git a/test/bdd/features/test_multi_tenant_base_daemon_propagation.py b/test/bdd/features/test_multi_tenant_base_daemon_propagation.py new file mode 100644 index 0000000..5fe15ca --- /dev/null +++ b/test/bdd/features/test_multi_tenant_base_daemon_propagation.py @@ -0,0 +1,72 @@ +from unittest.mock import MagicMock +from uuid import UUID + +import pytest + +from pyoaev.daemons.base_daemon import BaseDaemon + + +class DummyDaemon(BaseDaemon): + def _setup(self): + pass + + def _start_loop(self): + pass + + +@pytest.mark.parametrize( + "config_map, expected_tenant", + [ + ( + { + "openaev_url": "url", + "openaev_token": "token", + }, + None, + ), + ( + { + "openaev_url": "url", + "openaev_token": "token", + "openaev_tenant_id": None, + }, + None, + ), + ( + { + "openaev_url": "url", + "openaev_token": "token", + "openaev_tenant_id": UUID("2cffad3a-0001-4078-b0e2-ef74274022c3"), + }, + UUID("2cffad3a-0001-4078-b0e2-ef74274022c3"), + ), + ], + ids=[ + "missing_tenant_key", + "explicit_none_tenant", + "valid_uuid_tenant", + ], +) +def test_default_api_client_propagates_tenant_id( + monkeypatch, config_map, expected_tenant +): + captured = {} + + def fake_client(url, token, tenant_id=None): + captured["url"] = url + captured["token"] = token + captured["tenant_id"] = tenant_id + return MagicMock() + + monkeypatch.setattr("pyoaev.daemons.base_daemon.OpenAEV", fake_client) + + config = MagicMock() + config.get.side_effect = lambda key: config_map.get(key) + + daemon = DummyDaemon(configuration=config) + assert daemon.api is not None + + assert "openaev_tenant_id" in config_map or expected_tenant is None + assert captured["url"] == "url" + assert captured["token"] == "token" + assert captured["tenant_id"] == expected_tenant diff --git a/test/bdd/features/test_multi_tenant_endpoint_search_targets.py b/test/bdd/features/test_multi_tenant_endpoint_search_targets.py new file mode 100644 index 0000000..10c3162 --- /dev/null +++ b/test/bdd/features/test_multi_tenant_endpoint_search_targets.py @@ -0,0 +1,73 @@ +from unittest.mock import MagicMock +from uuid import UUID + +import pytest + +from pyoaev import OpenAEV +from pyoaev.apis.inputs.search import Filter, FilterGroup, SearchPaginationInput + + +class MockResponse: + def __init__(self, json_data=None, status_code=200): + self._json_data = json_data + self.status_code = status_code + self.history = None + self.content = None + self.headers = {"Content-Type": "application/json"} + + def json(self): + return self._json_data or {} + + +def build_search_input(): + return SearchPaginationInput( + 0, + 20, + FilterGroup( + "or", + [ + Filter( + "targets", + "and", + "eq", + ["target_1", "target_2", "target_3"], + ) + ], + ), + None, + None, + ) + + +@pytest.mark.parametrize( + "tenant_id, expected_url", + [ + ( + None, + "url/api/endpoints/targets", + ), + ( + UUID("2cffad3a-0001-4078-b0e2-ef74274022c3"), + "url/api/tenants/2cffad3a-0001-4078-b0e2-ef74274022c3/endpoints/targets", + ), + ], + ids=[ + "legacy_routing_no_tenant", + "tenant_routing_enabled", + ], +) +def test_search_input_correctly_serialised(monkeypatch, tenant_id, expected_url): + mock_request = MagicMock(return_value=MockResponse()) + monkeypatch.setattr("requests.Session.request", mock_request) + + api_client = OpenAEV("url", "token", tenant_id=tenant_id) + search_input = build_search_input() + expected_json = search_input.to_dict() + api_client.endpoint.searchTargets(search_input) + + assert mock_request.call_count == 1 + _, kwargs = mock_request.call_args + + assert kwargs["method"] == "post" + assert kwargs["json"] == expected_json + assert kwargs["url"] == expected_url From 3ec2d2224953fcb39db312ae1b4d49ddb6cea976 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Basler?= Date: Mon, 27 Apr 2026 14:54:10 +0200 Subject: [PATCH 3/5] [client-python] Adding and Updating multi-tenant tests --- ..._tenant_validation_uuid_constraint.feature | 30 ++++ ...est_multi_tenant_api_routing_constraint.py | 81 ++++++---- ...multi_tenant_validation_uuid_constraint.py | 133 +++++++++++++++++ .../features/multi_tenant_api_routing.feature | 12 +- ...lti_tenant_base_daemon_propagation.feature | 10 +- ...lti_tenant_endpoint_search_targets.feature | 4 +- .../features/test_multi_tenant_api_routing.py | 107 +++++++++----- ...st_multi_tenant_base_daemon_propagation.py | 139 ++++++++++++------ ...st_multi_tenant_endpoint_search_targets.py | 110 ++++++++++---- 9 files changed, 474 insertions(+), 152 deletions(-) create mode 100644 test/bdd/constraints/multi_tenant_validation_uuid_constraint.feature create mode 100644 test/bdd/constraints/test_multi_tenant_validation_uuid_constraint.py diff --git a/test/bdd/constraints/multi_tenant_validation_uuid_constraint.feature b/test/bdd/constraints/multi_tenant_validation_uuid_constraint.feature new file mode 100644 index 0000000..027f07f --- /dev/null +++ b/test/bdd/constraints/multi_tenant_validation_uuid_constraint.feature @@ -0,0 +1,30 @@ +Feature: Tenant ID handling in OpenAEV configuration + + Scenario: tenant_id is not provided + Given a configuration without tenant_id + When the configuration is loaded + Then tenant_id should be None + + + Scenario: tenant_id is explicitly set to None + Given a configuration with tenant_id set to None + When the configuration is loaded + Then tenant_id should be None + + + Scenario Outline: tenant_id is invalid and should raise a validation error + Given a configuration with tenant_id "" invalid + When the configuration is loaded + Then a validation error should be raised + + Examples: + | tenant_id | + | ChangeMe | + | "" | + | 550-e29-41d-a71-446 | + + + Scenario: tenant_id is a valid UUID + Given a configuration with tenant_id "2cffad3a-0001-4078-b0e2-ef74274022c3" + When the configuration is loaded + Then tenant_id should be a valid UUID \ No newline at end of file diff --git a/test/bdd/constraints/test_multi_tenant_api_routing_constraint.py b/test/bdd/constraints/test_multi_tenant_api_routing_constraint.py index f93e49b..3956afb 100644 --- a/test/bdd/constraints/test_multi_tenant_api_routing_constraint.py +++ b/test/bdd/constraints/test_multi_tenant_api_routing_constraint.py @@ -1,38 +1,63 @@ +"""URL normalization in OpenAEV client feature tests.""" + import pytest +from pytest_bdd import given, parsers, scenario, then, when from pyoaev import OpenAEV +# -------------------------------------------------- +# SCENARIOS +# -------------------------------------------------- + -@pytest.mark.parametrize( - "base_url, input_path, expected", - [ - ( - "base_url", - "path", - "base_url/api/path", - ), - ( - "base_url/", - "/path", - "base_url/api/path", - ), - ( - "base_url//", - "//path", - "base_url/api/path", - ), - ], - ids=[ - "clean-base-url-and-relative-path", - "base-url-trailing-slash", - "base-url-double-slash-and-path-double-slash", - ], +@scenario( + "multi_tenant_api_routing_constraint.feature", + "URL normalization combines base_url and path correctly", ) -def test_url_normalization(base_url, input_path, expected): - client = OpenAEV( +def test_url_normalization(): + pass + + +# -------------------------------------------------- +# FIXTURE CONTEXT +# -------------------------------------------------- + + +@pytest.fixture +def context(): + return {} + + +# -------------------------------------------------- +# GIVEN +# -------------------------------------------------- + + +@given(parsers.parse('an OpenAEV client with base_url "{base_url}"')) +def client(context, base_url): + context["client"] = OpenAEV( url=base_url, token="token", tenant_id=None, ) - result = client._build_url(input_path) - assert result == expected + + +# -------------------------------------------------- +# WHEN +# -------------------------------------------------- + + +@when(parsers.parse('I build the URL for "{path}"')) +def build_url(context, path): + client = context["client"] + context["result"] = client._build_url(path) + + +# -------------------------------------------------- +# THEN +# -------------------------------------------------- + + +@then(parsers.parse('the resulting URL should be "{expected}"')) +def check_url(context, expected): + assert context["result"] == expected diff --git a/test/bdd/constraints/test_multi_tenant_validation_uuid_constraint.py b/test/bdd/constraints/test_multi_tenant_validation_uuid_constraint.py new file mode 100644 index 0000000..5cfcf89 --- /dev/null +++ b/test/bdd/constraints/test_multi_tenant_validation_uuid_constraint.py @@ -0,0 +1,133 @@ +"""Tenant ID handling in OpenAEV configuration feature tests.""" + +from uuid import UUID + +import pytest +from pydantic import ValidationError +from pytest_bdd import given, parsers, scenario, then, when + +from pyoaev.configuration.settings_loader import ConfigLoaderOAEV + +# -------------------------------------------------- +# SCENARIOS +# -------------------------------------------------- + + +@scenario( + "multi_tenant_validation_uuid_constraint.feature", + "tenant_id is a valid UUID", +) +def test_tenant_id_is_a_valid_uuid(): + pass + + +@scenario( + "multi_tenant_validation_uuid_constraint.feature", + "tenant_id is explicitly set to None", +) +def test_tenant_id_is_explicitly_set_to_none(): + pass + + +@scenario( + "multi_tenant_validation_uuid_constraint.feature", + "tenant_id is invalid and should raise a validation error", +) +def test_tenant_id_is_invalid_and_should_raise_a_validation_error(): + pass + + +@scenario( + "multi_tenant_validation_uuid_constraint.feature", + "tenant_id is not provided", +) +def test_tenant_id_is_not_provided(): + pass + + +# -------------------------------------------------- +# GIVEN +# -------------------------------------------------- + + +@given( + "a configuration without tenant_id", + target_fixture="config", +) +def config_without(): + return { + "url": "https://example.com", + "token": "token", + } + + +@given( + "a configuration with tenant_id set to None", + target_fixture="config", +) +def config_none(): + return { + "url": "https://example.com", + "token": "token", + "tenant_id": None, + } + + +@given( + parsers.parse('a configuration with tenant_id "{tenant_id}" invalid'), + target_fixture="config", +) +def config_with_tenant_invalid(tenant_id): + return { + "url": "https://example.com", + "token": "token", + "tenant_id": tenant_id, + } + + +@given( + 'a configuration with tenant_id "2cffad3a-0001-4078-b0e2-ef74274022c3"', + target_fixture="config", +) +def config_with_tenant_valid(): + return { + "url": "https://example.com", + "token": "token", + "tenant_id": "2cffad3a-0001-4078-b0e2-ef74274022c3", + } + + +# -------------------------------------------------- +# WHEN +# -------------------------------------------------- + + +@when( + "the configuration is loaded", + target_fixture="result", +) +def load_config(config): + try: + return ConfigLoaderOAEV(**config) + except ValidationError as err: + return err + + +# -------------------------------------------------- +# THEN +# -------------------------------------------------- + + +@then("tenant_id should be None") +def assert_none(result): + assert result.tenant_id is None + + +@then("tenant_id should be a valid UUID") +def assert_uuid(result): + assert isinstance(result.tenant_id, UUID) + + +@then("a validation error should be raised") +def assert_validation_error(result): + assert isinstance(result, ValidationError) diff --git a/test/bdd/features/multi_tenant_api_routing.feature b/test/bdd/features/multi_tenant_api_routing.feature index 6cbd189..fb15e07 100644 --- a/test/bdd/features/multi_tenant_api_routing.feature +++ b/test/bdd/features/multi_tenant_api_routing.feature @@ -1,17 +1,17 @@ Feature: Multi-tenant API routing in OpenAEV client Scenario: Full URL bypasses tenant routing - Given an OpenAEV client with tenant_id "2cffad3a-0001-4078-b0e2-ef74274022c3" + Given an OpenAEV client with any tenant configuration When I build the URL for "https://external.service/api/path" Then the resulting URL should be "https://external.service/api/path" - Scenario Outline: Relative path routing behavior + + Scenario Outline: Relative path routing depends on tenant configuration Given an OpenAEV client with tenant_id "" When I build the URL for "/path" Then the resulting URL should be "" Examples: - | tenant_id | output | - | None | base_url/api/path | - | 2cffad3a-0001-4078-b0e2-ef74274022c3 | base_url/api/tenants/2cffad3a-0001-4078-b0e2-ef74274022c3/path | - + | tenant_id | output | + | None | base_url/api/path | + | 2cffad3a-0001-4078-b0e2-ef74274022c3 | base_url/api/tenants/2cffad3a-0001-4078-b0e2-ef74274022c3/path | diff --git a/test/bdd/features/multi_tenant_base_daemon_propagation.feature b/test/bdd/features/multi_tenant_base_daemon_propagation.feature index 4698deb..69bc76c 100644 --- a/test/bdd/features/multi_tenant_base_daemon_propagation.feature +++ b/test/bdd/features/multi_tenant_base_daemon_propagation.feature @@ -1,12 +1,12 @@ Feature: Tenant propagation in BaseDaemon API client initialization Scenario Outline: BaseDaemon propagates tenant_id correctly from configuration - Given a daemon configuration with + Given a daemon configuration with "" When the BaseDaemon is initialized Then the API client should be created with tenant_id "" Examples: - | tenant_id | expected_tenant_id | - | Missing tenant key | None | - | None | None | - | 2cffad3a-0001-4078-b0e2-ef74274022c3 | 2cffad3a-0001-4078-b0e2-ef74274022c3 | \ No newline at end of file + | tenant_case | expected_tenant_id | + | missing_key | None | + | explicit_none | None | + | valid_uuid | 2cffad3a-0001-4078-b0e2-ef74274022c3 | \ No newline at end of file diff --git a/test/bdd/features/multi_tenant_endpoint_search_targets.feature b/test/bdd/features/multi_tenant_endpoint_search_targets.feature index 9b2ae5a..e30bd66 100644 --- a/test/bdd/features/multi_tenant_endpoint_search_targets.feature +++ b/test/bdd/features/multi_tenant_endpoint_search_targets.feature @@ -7,6 +7,6 @@ Feature: searchTargets API routing with and without tenant_id Then the request URL should be "" Examples: - | tenant_id | expected_url | - | None | url/api/endpoints/targets | + | tenant_id | expected_url | + | None | url/api/endpoints/targets | | 2cffad3a-0001-4078-b0e2-ef74274022c3 | url/api/tenants/2cffad3a-0001-4078-b0e2-ef74274022c3/endpoints/targets | \ No newline at end of file diff --git a/test/bdd/features/test_multi_tenant_api_routing.py b/test/bdd/features/test_multi_tenant_api_routing.py index e82ba2d..8e6e2b1 100644 --- a/test/bdd/features/test_multi_tenant_api_routing.py +++ b/test/bdd/features/test_multi_tenant_api_routing.py @@ -1,46 +1,81 @@ from uuid import UUID -import pytest +from pytest_bdd import given, parsers, scenario, then, when from pyoaev import OpenAEV +# -------------------------------------------------- +# SCENARIOS +# -------------------------------------------------- -@pytest.mark.parametrize( - "tenant_id, path, expected", - [ - ( - None, - "/path", - "base_url/api/path", - ), - ( - UUID("2cffad3a-0001-4078-b0e2-ef74274022c3"), - "/path", - "base_url/api/tenants/2cffad3a-0001-4078-b0e2-ef74274022c3/path", - ), - ( - None, - "https://external.service/api/path", - "https://external.service/api/path", - ), - ( - UUID("2cffad3a-0001-4078-b0e2-ef74274022c3"), - "https://external.service/api/path", - "https://external.service/api/path", - ), - ], - ids=[ - "legacy-relative-path", - "tenant-relative-path", - "legacy-full-url-bypass", - "tenant-full-url-bypass", - ], + +@scenario( + "multi_tenant_api_routing.feature", + "Full URL bypasses tenant routing", +) +def test_full_url_bypasses_tenant_routing(): + pass + + +@scenario( + "multi_tenant_api_routing.feature", + "Relative path routing depends on tenant configuration", +) +def test_relative_path_routing(): + pass + + +# -------------------------------------------------- +# GIVEN +# -------------------------------------------------- + + +@given( + "an OpenAEV client with any tenant configuration", + target_fixture="client", +) +def client_any(): + return OpenAEV( + "base_url", + "token", + tenant_id=None, + ) + + +@given( + parsers.parse('an OpenAEV client with tenant_id "{tenant_id}"'), + target_fixture="client", ) -def test_build_url_behavior(tenant_id, path, expected): - client = OpenAEV( +def client_with_tenant(tenant_id): + if tenant_id is None or tenant_id == "None": + tenant_id_value = None + else: + tenant_id_value = UUID(tenant_id) + return OpenAEV( "base_url", "token", - tenant_id=tenant_id, + tenant_id=tenant_id_value, ) - result = client._build_url(path) - assert result == expected + + +# -------------------------------------------------- +# WHEN +# -------------------------------------------------- + + +@when( + parsers.parse('I build the URL for "{path}"'), + target_fixture="result", +) +def build_url(client, path): + return client._build_url(path) + + +# -------------------------------------------------- +# THEN +# -------------------------------------------------- + + +@then(parsers.parse('the resulting URL should be "{output}"')) +def assert_url(result, output): + assert result == output diff --git a/test/bdd/features/test_multi_tenant_base_daemon_propagation.py b/test/bdd/features/test_multi_tenant_base_daemon_propagation.py index 5fe15ca..e9d1e8c 100644 --- a/test/bdd/features/test_multi_tenant_base_daemon_propagation.py +++ b/test/bdd/features/test_multi_tenant_base_daemon_propagation.py @@ -2,55 +2,65 @@ from uuid import UUID import pytest +from pytest_bdd import given, parsers, scenario, then, when from pyoaev.daemons.base_daemon import BaseDaemon +# -------------------------------------------------- +# SCENARIO +# -------------------------------------------------- -class DummyDaemon(BaseDaemon): - def _setup(self): - pass - - def _start_loop(self): - pass - - -@pytest.mark.parametrize( - "config_map, expected_tenant", - [ - ( - { - "openaev_url": "url", - "openaev_token": "token", - }, - None, - ), - ( - { - "openaev_url": "url", - "openaev_token": "token", - "openaev_tenant_id": None, - }, - None, - ), - ( - { - "openaev_url": "url", - "openaev_token": "token", - "openaev_tenant_id": UUID("2cffad3a-0001-4078-b0e2-ef74274022c3"), - }, - UUID("2cffad3a-0001-4078-b0e2-ef74274022c3"), - ), - ], - ids=[ - "missing_tenant_key", - "explicit_none_tenant", - "valid_uuid_tenant", - ], + +@scenario( + "multi_tenant_base_daemon_propagation.feature", + "BaseDaemon propagates tenant_id correctly from configuration", ) -def test_default_api_client_propagates_tenant_id( - monkeypatch, config_map, expected_tenant -): - captured = {} +def test_base_daemon_propagates_tenant_id(): + pass + + +# -------------------------------------------------- +# FIXTURE CONTEXT +# -------------------------------------------------- + + +@pytest.fixture +def context(): + return {} + + +# -------------------------------------------------- +# HELPERS +# -------------------------------------------------- + + +def build_config(tenant_case): + base = { + "openaev_url": "url", + "openaev_token": "token", + } + + if tenant_case == "missing_key": + return base + + if tenant_case == "explicit_none": + base["openaev_tenant_id"] = None + return base + + if tenant_case == "valid_uuid": + base["openaev_tenant_id"] = UUID("2cffad3a-0001-4078-b0e2-ef74274022c3") + return base + return base + + +# -------------------------------------------------- +# GIVEN +# -------------------------------------------------- + + +@given(parsers.parse('a daemon configuration with "{tenant_case}"')) +def daemon_config(context, monkeypatch, tenant_case): + captured = build_config(tenant_case) def fake_client(url, token, tenant_id=None): captured["url"] = url @@ -61,12 +71,47 @@ def fake_client(url, token, tenant_id=None): monkeypatch.setattr("pyoaev.daemons.base_daemon.OpenAEV", fake_client) config = MagicMock() + config_map = build_config(tenant_case) config.get.side_effect = lambda key: config_map.get(key) - daemon = DummyDaemon(configuration=config) + context["config"] = config + context["captured"] = captured + + +# -------------------------------------------------- +# WHEN +# -------------------------------------------------- + + +@when("the BaseDaemon is initialized") +def init_daemon(context): + class DummyDaemon(BaseDaemon): + def _setup(self): + pass + + def _start_loop(self): + pass + + context["daemon"] = DummyDaemon(configuration=context["config"]) + + +# -------------------------------------------------- +# THEN +# -------------------------------------------------- + + +@then( + parsers.parse( + 'the API client should be created with tenant_id "{expected_tenant_id}"' + ) +) +def check_tenant(context, expected_tenant_id): + captured = context["captured"] + + daemon = context["daemon"] assert daemon.api is not None - assert "openaev_tenant_id" in config_map or expected_tenant is None + expected = None if expected_tenant_id == "None" else UUID(expected_tenant_id) assert captured["url"] == "url" assert captured["token"] == "token" - assert captured["tenant_id"] == expected_tenant + assert captured["tenant_id"] == expected diff --git a/test/bdd/features/test_multi_tenant_endpoint_search_targets.py b/test/bdd/features/test_multi_tenant_endpoint_search_targets.py index 10c3162..081364c 100644 --- a/test/bdd/features/test_multi_tenant_endpoint_search_targets.py +++ b/test/bdd/features/test_multi_tenant_endpoint_search_targets.py @@ -2,11 +2,38 @@ from uuid import UUID import pytest +from pytest_bdd import given, parsers, scenario, then, when from pyoaev import OpenAEV from pyoaev.apis.inputs.search import Filter, FilterGroup, SearchPaginationInput +# -------------------------------------------------- +# SCENARIO +# -------------------------------------------------- +@scenario( + "multi_tenant_endpoint_search_targets.feature", + "searchTargets routing behavior", +) +def test_search_targets_routing(): + pass + + +# -------------------------------------------------- +# FIXTURE CONTEXT +# -------------------------------------------------- + + +@pytest.fixture +def context(): + return {} + + +# -------------------------------------------------- +# HELPERS +# -------------------------------------------------- + + class MockResponse: def __init__(self, json_data=None, status_code=200): self._json_data = json_data @@ -39,35 +66,62 @@ def build_search_input(): ) -@pytest.mark.parametrize( - "tenant_id, expected_url", - [ - ( - None, - "url/api/endpoints/targets", - ), - ( - UUID("2cffad3a-0001-4078-b0e2-ef74274022c3"), - "url/api/tenants/2cffad3a-0001-4078-b0e2-ef74274022c3/endpoints/targets", - ), - ], - ids=[ - "legacy_routing_no_tenant", - "tenant_routing_enabled", - ], -) -def test_search_input_correctly_serialised(monkeypatch, tenant_id, expected_url): - mock_request = MagicMock(return_value=MockResponse()) +# -------------------------------------------------- +# GIVEN +# -------------------------------------------------- + + +@given(parsers.parse('an OpenAEV client with tenant_id "{tenant_id}"')) +def client(context, monkeypatch, tenant_id): + captured = {} + + def _fake_request(method, url, **kwargs): + captured["method"] = method + captured["url"] = url + captured["json"] = kwargs.get("json") + return MockResponse() + + mock_request = MagicMock(side_effect=_fake_request) monkeypatch.setattr("requests.Session.request", mock_request) + context["mock_request"] = mock_request - api_client = OpenAEV("url", "token", tenant_id=tenant_id) - search_input = build_search_input() - expected_json = search_input.to_dict() - api_client.endpoint.searchTargets(search_input) + context["tenant_id"] = None if tenant_id == "None" else UUID(tenant_id) + context["captured"] = captured - assert mock_request.call_count == 1 - _, kwargs = mock_request.call_args - assert kwargs["method"] == "post" - assert kwargs["json"] == expected_json - assert kwargs["url"] == expected_url +@given("a valid SearchPaginationInput") +def search_input(context): + context["search_input"] = build_search_input() + + +# -------------------------------------------------- +# WHEN +# -------------------------------------------------- + + +@when("I call searchTargets on endpoint") +def call_search_targets(context): + api_client = OpenAEV( + "url", + "token", + tenant_id=context["tenant_id"], + ) + + api_client.endpoint.searchTargets(context["search_input"]) + + +# -------------------------------------------------- +# THEN +# -------------------------------------------------- + + +@then(parsers.parse('the request URL should be "{expected_url}"')) +def check_request(context, expected_url): + captured = context["captured"] + search_input = context["search_input"] + mock_request = context["mock_request"] + + assert mock_request.call_count == 1 + assert captured["method"] == "post" + assert captured["url"] == expected_url + assert captured["json"] == search_input.to_dict() From 1ed3ba4c745117b8951fbe0ad710c42664f0d3ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Basler?= Date: Mon, 27 Apr 2026 15:32:23 +0200 Subject: [PATCH 4/5] [client-python] Updating test --- ...test_multi_tenant_base_daemon_propagation.py | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/test/bdd/features/test_multi_tenant_base_daemon_propagation.py b/test/bdd/features/test_multi_tenant_base_daemon_propagation.py index e9d1e8c..ca85f22 100644 --- a/test/bdd/features/test_multi_tenant_base_daemon_propagation.py +++ b/test/bdd/features/test_multi_tenant_base_daemon_propagation.py @@ -60,18 +60,20 @@ def build_config(tenant_case): @given(parsers.parse('a daemon configuration with "{tenant_case}"')) def daemon_config(context, monkeypatch, tenant_case): - captured = build_config(tenant_case) + captured = {} + config_map = build_config(tenant_case) - def fake_client(url, token, tenant_id=None): + def _fake_client(url, token, tenant_id=None): captured["url"] = url captured["token"] = token captured["tenant_id"] = tenant_id return MagicMock() - monkeypatch.setattr("pyoaev.daemons.base_daemon.OpenAEV", fake_client) + mock_client = MagicMock(side_effect=_fake_client) + monkeypatch.setattr("pyoaev.daemons.base_daemon.OpenAEV", mock_client) + context["mock_client"] = mock_client config = MagicMock() - config_map = build_config(tenant_case) config.get.side_effect = lambda key: config_map.get(key) context["config"] = config @@ -108,10 +110,11 @@ def _start_loop(self): def check_tenant(context, expected_tenant_id): captured = context["captured"] - daemon = context["daemon"] - assert daemon.api is not None + mock_client = context["mock_client"] + assert mock_client.call_count == 1 - expected = None if expected_tenant_id == "None" else UUID(expected_tenant_id) assert captured["url"] == "url" assert captured["token"] == "token" + + expected = None if expected_tenant_id == "None" else UUID(expected_tenant_id) assert captured["tenant_id"] == expected From 601ebff12701d5bdf84997b01ad32d041941988a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Basler?= Date: Mon, 27 Apr 2026 15:34:13 +0200 Subject: [PATCH 5/5] [client-python] Up linter --- .../constraints/test_multi_tenant_validation_uuid_constraint.py | 1 - 1 file changed, 1 deletion(-) diff --git a/test/bdd/constraints/test_multi_tenant_validation_uuid_constraint.py b/test/bdd/constraints/test_multi_tenant_validation_uuid_constraint.py index 5cfcf89..df866e5 100644 --- a/test/bdd/constraints/test_multi_tenant_validation_uuid_constraint.py +++ b/test/bdd/constraints/test_multi_tenant_validation_uuid_constraint.py @@ -2,7 +2,6 @@ from uuid import UUID -import pytest from pydantic import ValidationError from pytest_bdd import given, parsers, scenario, then, when