diff --git a/.env_integration_tests.example b/.env_integration_tests.example index d51c852..f90d019 100644 --- a/.env_integration_tests.example +++ b/.env_integration_tests.example @@ -17,7 +17,5 @@ CLOUD_SDK_CFG_DESTINATION_DEFAULT_IDENTITYZONE=your-identity-zone-here CLOUD_SDK_CFG_SDM_DEFAULT_URI=https://your-sdm-api-uri-here CLOUD_SDK_CFG_SDM_DEFAULT_UAA='{"url":"https://your-auth-url","clientid":"your-client-id","clientsecret":"your-client-secret","identityzone":"your-identity-zone"}' -CLOUD_SDK_CFG_HANA_AGENT_MEMORY_DEFAULT_APPLICATION_URL=https://your-agent-memory-api-url-here -CLOUD_SDK_CFG_HANA_AGENT_MEMORY_DEFAULT_UAA_URL=https://your-auth-url-here -CLOUD_SDK_CFG_HANA_AGENT_MEMORY_DEFAULT_UAA_CLIENTID=your-client-id-here -CLOUD_SDK_CFG_HANA_AGENT_MEMORY_DEFAULT_UAA_CLIENTSECRET=your-client-secret-here +CLOUD_SDK_CFG_HANA_AGENT_MEMORY_DEFAULT_URL=https://your-agent-memory-api-url-here +CLOUD_SDK_CFG_HANA_AGENT_MEMORY_DEFAULT_UAA='{"url":"https://your-auth-url","clientid":"your-client-id","clientsecret":"your-client-secret"}' diff --git a/README.md b/README.md index e35f7b8..2ac866f 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ The Python SDK offers a clean, type-safe API following Python best practices whi - **Agent Decorators** - **AI Core Integration** - **Audit Log Service** +- **Agent Memory Service** - **Destination Service** - **Document Management Service** - **ObjectStore Service** @@ -25,7 +26,6 @@ The Python SDK offers a clean, type-safe API following Python best practices whi ### Installation - #### uv ```bash @@ -42,7 +42,7 @@ poetry add sap-cloud-sdk ```bash pip install sap-cloud-sdk -```` +``` ### Environment Configuration @@ -61,6 +61,7 @@ Each module has comprehensive usage guides: - [Agent Decorators](src/sap_cloud_sdk/agent_decorators/user-guide.md) - [AuditLog](src/sap_cloud_sdk/core/auditlog/user-guide.md) +- [Agent Memory](src/sap_cloud_sdk/agent_memory/user-guide.md) - [Destination](src/sap_cloud_sdk/destination/user-guide.md) - [DMS](src/sap_cloud_sdk/dms/user-guide.md) - [ObjectStore](src/sap_cloud_sdk/objectstore/user-guide.md) diff --git a/docs/INTEGRATION_TESTS.md b/docs/INTEGRATION_TESTS.md index 34f6b3c..5a33ff5 100644 --- a/docs/INTEGRATION_TESTS.md +++ b/docs/INTEGRATION_TESTS.md @@ -70,10 +70,8 @@ For Agent Memory integration tests, configure the following variables in `.env_i ```bash # Agent Memory Configuration -CLOUD_SDK_CFG_AGENT_MEMORY_DEFAULT_URL=https://your-agent-memory-api-url -CLOUD_SDK_CFG_AGENT_MEMORY_DEFAULT_AUTH_URL=https://your-auth-url -CLOUD_SDK_CFG_AGENT_MEMORY_DEFAULT_CLIENTID=your-client-id -CLOUD_SDK_CFG_AGENT_MEMORY_DEFAULT_CLIENTSECRET=your-client-secret +CLOUD_SDK_CFG_HANA_AGENT_MEMORY_DEFAULT_URL=https://your-agent-memory-api-url +CLOUD_SDK_CFG_HANA_AGENT_MEMORY_DEFAULT_UAA='{"url":"https://your-auth-url","clientid":"your-client-id","clientsecret":"your-client-secret"}' ``` ## Running Integration Tests diff --git a/src/sap_cloud_sdk/agent_memory/config.py b/src/sap_cloud_sdk/agent_memory/config.py index fbe4f17..ab41f86 100644 --- a/src/sap_cloud_sdk/agent_memory/config.py +++ b/src/sap_cloud_sdk/agent_memory/config.py @@ -5,21 +5,21 @@ Mount path convention:: + /etc/secrets/appfnd/hana-agent-memory/default/url + /etc/secrets/appfnd/hana-agent-memory/default/uaa +``url`` is the Agent Memory service base URL (plain string). +``uaa`` is a JSON string with OAuth2 credentials containing at minimum: +``clientid``, ``clientsecret``, and ``url`` (UAA base URL). - /etc/secrets/appfnd/hana-agent-memory/default/{field_key} +Env fallback convention:: -Keys: ``application_url``, ``uaa.url``, ``uaa.clientid``, ``uaa.clientsecret`` - -Env fallback convention (uppercased):: - - CLOUD_SDK_CFG_HANA_AGENT_MEMORY_DEFAULT_APPLICATION_URL - CLOUD_SDK_CFG_HANA_AGENT_MEMORY_DEFAULT_UAA_URL - CLOUD_SDK_CFG_HANA_AGENT_MEMORY_DEFAULT_UAA_CLIENTID - CLOUD_SDK_CFG_HANA_AGENT_MEMORY_DEFAULT_UAA_CLIENTSECRET + CLOUD_SDK_CFG_HANA_AGENT_MEMORY_DEFAULT_URL + CLOUD_SDK_CFG_HANA_AGENT_MEMORY_DEFAULT_UAA """ -from dataclasses import dataclass, field +import json +from dataclasses import dataclass from typing import Optional from sap_cloud_sdk.agent_memory.exceptions import AgentMemoryConfigError @@ -60,11 +60,18 @@ class AgentMemoryConfig: def __post_init__(self) -> None: if not self.base_url: raise AgentMemoryConfigError("base_url must be a non-empty string") - - -# NOTE: BindingData must NOT use `from __future__ import annotations` -# because the secret resolver checks `f.type is str` at runtime, which requires -# actual type objects rather than string annotations. + if self.token_url is not None and not self.token_url: + raise AgentMemoryConfigError( + "token_url must be a non-empty string when provided" + ) + if self.client_id is not None and not self.client_id: + raise AgentMemoryConfigError( + "client_id must be a non-empty string when provided" + ) + if self.client_secret is not None and not self.client_secret: + raise AgentMemoryConfigError( + "client_secret must be a non-empty string when provided" + ) @dataclass @@ -74,73 +81,42 @@ class BindingData: All fields must be plain ``str`` to satisfy the resolver contract. """ - application_url: str = "" - uaa_url: str = field(default="", metadata={"secret": "uaa.url"}) - uaa_clientid: str = field(default="", metadata={"secret": "uaa.clientid"}) - uaa_clientsecret: str = field(default="", metadata={"secret": "uaa.clientsecret"}) + url: str = "" + uaa: str = "" def validate(self) -> None: """Raise ``AgentMemoryConfigError`` if any required field is empty.""" - missing = [ - f - for f in ("application_url", "uaa_url", "uaa_clientid", "uaa_clientsecret") - if not getattr(self, f) - ] - if missing: + if not self.url: + raise AgentMemoryConfigError( + "Agent Memory binding is missing required field: url" + ) + if not self.uaa: raise AgentMemoryConfigError( - f"Agent Memory binding is missing required fields: {', '.join(missing)}" + "Agent Memory binding is missing required field: uaa" ) def extract_config(self) -> AgentMemoryConfig: - """Derive an ``AgentMemoryConfig`` from the raw binding fields.""" - return AgentMemoryConfig( - base_url=self.application_url, - token_url=self.uaa_url.rstrip("/") + "/oauth/token", - client_id=self.uaa_clientid, - client_secret=self.uaa_clientsecret, - ) - - -_ENV_PREFIX = "CLOUD_SDK_CFG_HANA_AGENT_MEMORY_DEFAULT" - -# Explicit env var names — dots are not valid in shell variable names, -# so we define these directly rather than deriving them from BindingData metadata keys -# (which use dots to match the BTP mount-path file naming convention). -_ENV_VARS = { - "application_url": f"{_ENV_PREFIX}_APPLICATION_URL", - "uaa_url": f"{_ENV_PREFIX}_UAA_URL", - "uaa_clientid": f"{_ENV_PREFIX}_UAA_CLIENTID", - "uaa_clientsecret": f"{_ENV_PREFIX}_UAA_CLIENTSECRET", -} - - -def _load_binding_from_env() -> BindingData: - """Read Agent Memory binding from environment variables. - - Raises: - AgentMemoryConfigError: If any required variable is absent. - """ - import os - - binding = BindingData() - missing: list[str] = [] - for attr, var in _ENV_VARS.items(): - value = os.environ.get(var) - if not value: - missing.append(var) - else: - setattr(binding, attr, value) - if missing: - raise AgentMemoryConfigError( - f"Missing required environment variables: {', '.join(missing)}" - ) - return binding + """Parse the UAA JSON string and return an ``AgentMemoryConfig``.""" + try: + uaa_data = json.loads(self.uaa, strict=False) + except json.JSONDecodeError as e: + raise AgentMemoryConfigError(f"Failed to parse uaa JSON: {e}") + + try: + return AgentMemoryConfig( + base_url=self.url, + token_url=uaa_data["url"].rstrip("/") + "/oauth/token", + client_id=uaa_data["clientid"], + client_secret=uaa_data["clientsecret"], + ) + except KeyError as e: + raise AgentMemoryConfigError(f"Missing required field in uaa JSON: {e}") def _load_config_from_env() -> AgentMemoryConfig: """Load Agent Memory configuration from a mounted volume or environment variables. - Tries (in order): + Uses the secret resolver with fallback order: 1. Mount at ``/etc/secrets/appfnd/hana-agent-memory/default/`` 2. Environment variables ``CLOUD_SDK_CFG_HANA_AGENT_MEMORY_DEFAULT_*`` @@ -150,24 +126,24 @@ def _load_config_from_env() -> AgentMemoryConfig: Raises: AgentMemoryConfigError: If configuration cannot be loaded or is incomplete. """ - from sap_cloud_sdk.core.secret_resolver.resolver import _load_from_mount + from sap_cloud_sdk.core.secret_resolver import ( + read_from_mount_and_fallback_to_env_var, + ) - mount_error: Exception | None = None try: binding = BindingData() - _load_from_mount("/etc/secrets/appfnd", "hana-agent-memory", "default", binding) - binding.validate() - return binding.extract_config() - except Exception as exc: - mount_error = exc - - try: - binding = _load_binding_from_env() + read_from_mount_and_fallback_to_env_var( + base_volume_mount="/etc/secrets/appfnd", + base_var_name="CLOUD_SDK_CFG", + module="hana-agent-memory", + instance="default", + target=binding, + ) binding.validate() return binding.extract_config() except AgentMemoryConfigError: raise except Exception as exc: raise AgentMemoryConfigError( - f"Failed to load Agent Memory configuration: mount={mount_error}; env={exc}" + f"Failed to load Agent Memory configuration: {exc}" ) from exc diff --git a/src/sap_cloud_sdk/agent_memory/user-guide.md b/src/sap_cloud_sdk/agent_memory/user-guide.md index 338fa97..16e9644 100644 --- a/src/sap_cloud_sdk/agent_memory/user-guide.md +++ b/src/sap_cloud_sdk/agent_memory/user-guide.md @@ -772,14 +772,37 @@ environment variables or service binding. ## Configuration -`create_client()` resolves credentials automatically in the following order: +### Service Binding -1. **Mounted volume** — `/etc/secrets/appfnd/hana-agent-memory/default/{field}` -2. **Environment variables** — `CLOUD_SDK_CFG_HANA_AGENT_MEMORY_DEFAULT_*` +- **Mount path**: `$SERVICE_BINDING_ROOT/hana-agent-memory/default/` (defaults to `/etc/secrets/appfnd/hana-agent-memory/default/`) +- **Required keys**: `url` (Agent Memory service URL), `uaa` (JSON string with XSUAA credentials) +- **Env var fallback**: `CLOUD_SDK_CFG_HANA_AGENT_MEMORY_DEFAULT_{FIELD}` (uppercased) -| Environment Variable | Description | -| ---------------------------------------------------------- | ------------------------------------ | -| `CLOUD_SDK_CFG_HANA_AGENT_MEMORY_DEFAULT_APPLICATION_URL` | Base URL of the Agent Memory service | -| `CLOUD_SDK_CFG_HANA_AGENT_MEMORY_DEFAULT_UAA_URL` | OAuth2 authorization server base URL | -| `CLOUD_SDK_CFG_HANA_AGENT_MEMORY_DEFAULT_UAA_CLIENTID` | OAuth2 client ID | -| `CLOUD_SDK_CFG_HANA_AGENT_MEMORY_DEFAULT_UAA_CLIENTSECRET` | OAuth2 client secret | +> **Note:** `SERVICE_BINDING_ROOT` defaults to `/etc/secrets/appfnd` when not set. See the [Secret Resolver guide](../core/secret_resolver/user-guide.md) for details. + +#### Mounted Secrets (Kubernetes) + +``` +$SERVICE_BINDING_ROOT/hana-agent-memory/default/ +├── url +└── uaa +``` + +#### Environment Variables + +```bash +export CLOUD_SDK_CFG_HANA_AGENT_MEMORY_DEFAULT_URL="https://agent-memory.example.com" +export CLOUD_SDK_CFG_HANA_AGENT_MEMORY_DEFAULT_UAA='{"clientid":"...","clientsecret":"...","url":"https://..."}' +``` + +#### UAA JSON Schema + +The `uaa` key must contain a JSON string with the XSUAA credentials: + +```json +{ + "clientid": "sb-xxx", + "clientsecret": "xxx", + "url": "https://subdomain.authentication.region.hana.ondemand.com" +} +``` diff --git a/tests/agent_memory/unit/test_client.py b/tests/agent_memory/unit/test_client.py index feb0b18..c7a94ec 100644 --- a/tests/agent_memory/unit/test_client.py +++ b/tests/agent_memory/unit/test_client.py @@ -45,10 +45,13 @@ def test_uses_provided_config(self): def test_reads_env_when_no_config_provided(self, monkeypatch): """Factory falls back to environment variables when no config given.""" - monkeypatch.setenv("CLOUD_SDK_CFG_HANA_AGENT_MEMORY_DEFAULT_APPLICATION_URL", "http://memory.example.com") - monkeypatch.setenv("CLOUD_SDK_CFG_HANA_AGENT_MEMORY_DEFAULT_UAA_URL", "http://auth.example.com") - monkeypatch.setenv("CLOUD_SDK_CFG_HANA_AGENT_MEMORY_DEFAULT_UAA_CLIENTID", "client-id") - monkeypatch.setenv("CLOUD_SDK_CFG_HANA_AGENT_MEMORY_DEFAULT_UAA_CLIENTSECRET", "client-secret") + import json + monkeypatch.setenv("CLOUD_SDK_CFG_HANA_AGENT_MEMORY_DEFAULT_URL", "http://memory.example.com") + monkeypatch.setenv("CLOUD_SDK_CFG_HANA_AGENT_MEMORY_DEFAULT_UAA", json.dumps({ + "url": "http://auth.example.com", + "clientid": "client-id", + "clientsecret": "client-secret", + })) with patch("sap_cloud_sdk.agent_memory.HttpTransport") as MockTransport: MockTransport.return_value = MagicMock(spec=HttpTransport) client = create_client() diff --git a/tests/agent_memory/unit/test_config.py b/tests/agent_memory/unit/test_config.py index fc94745..8dae51c 100644 --- a/tests/agent_memory/unit/test_config.py +++ b/tests/agent_memory/unit/test_config.py @@ -1,5 +1,6 @@ """Unit tests for AgentMemoryConfig, BindingData, and _load_config_from_env.""" +import json from unittest.mock import patch import pytest @@ -11,164 +12,178 @@ ) from sap_cloud_sdk.agent_memory.exceptions import AgentMemoryConfigError +_VALID_UAA = json.dumps({ + "url": "https://auth.example.com", + "clientid": "my-client", + "clientsecret": "my-secret", +}) + +_RESOLVER = "sap_cloud_sdk.core.secret_resolver.read_from_mount_and_fallback_to_env_var" + # ── AgentMemoryConfig ───────────────────────────────────────────────────────── class TestAgentMemoryConfig: def test_raises_when_base_url_empty(self): - """AgentMemoryConfig rejects an empty base_url.""" with pytest.raises(AgentMemoryConfigError, match="base_url"): AgentMemoryConfig(base_url="") + def test_raises_when_token_url_empty_string(self): + with pytest.raises(AgentMemoryConfigError, match="token_url"): + AgentMemoryConfig(base_url="http://localhost", token_url="") + + def test_raises_when_client_id_empty_string(self): + with pytest.raises(AgentMemoryConfigError, match="client_id"): + AgentMemoryConfig(base_url="http://localhost", client_id="") + + def test_raises_when_client_secret_empty_string(self): + with pytest.raises(AgentMemoryConfigError, match="client_secret"): + AgentMemoryConfig(base_url="http://localhost", client_secret="") + def test_optional_fields_default_to_none(self): - """token_url, client_id, and client_secret default to None.""" config = AgentMemoryConfig(base_url="http://localhost:8080") assert config.token_url is None assert config.client_id is None assert config.client_secret is None def test_timeout_default(self): - """Default timeout is 30.0 seconds.""" config = AgentMemoryConfig(base_url="http://localhost:8080") assert config.timeout == 30.0 + def test_valid_config_with_all_fields_does_not_raise(self): + AgentMemoryConfig( + base_url="https://memory.example.com", + token_url="https://auth.example.com/oauth/token", + client_id="my-client", + client_secret="my-secret", + ) + # ── BindingData ─────────────────────────────────────────────────────────────── class TestBindingData: - def test_validate_raises_when_all_fields_empty(self): - """validate() raises AgentMemoryConfigError when all fields are empty.""" - with pytest.raises(AgentMemoryConfigError, match="missing required fields"): - BindingData().validate() + def test_validate_raises_when_url_missing(self): + with pytest.raises(AgentMemoryConfigError, match="url"): + BindingData(url="", uaa=_VALID_UAA).validate() - def test_validate_raises_when_some_fields_empty(self): - """validate() raises when only some fields are populated.""" - binding = BindingData(application_url="https://example.com") - with pytest.raises(AgentMemoryConfigError, match="missing required fields"): - binding.validate() + def test_validate_raises_when_uaa_missing(self): + with pytest.raises(AgentMemoryConfigError, match="uaa"): + BindingData(url="https://memory.example.com", uaa="").validate() def test_validate_passes_when_all_fields_set(self): - """validate() does not raise when all required fields are populated.""" - binding = BindingData( - application_url="https://example.com", - uaa_url="https://auth.example.com", - uaa_clientid="client-id", - uaa_clientsecret="client-secret", - ) - binding.validate() # should not raise + BindingData(url="https://memory.example.com", uaa=_VALID_UAA).validate() + + def test_extract_config_maps_url(self): + config = BindingData(url="https://memory.example.com", uaa=_VALID_UAA).extract_config() + assert config.base_url == "https://memory.example.com" def test_extract_config_derives_token_url(self): - """extract_config() appends /oauth/token to uaa_url.""" - binding = BindingData( - application_url="https://memory.example.com", - uaa_url="https://auth.example.com", - uaa_clientid="cid", - uaa_clientsecret="csec", - ) - config = binding.extract_config() + config = BindingData(url="https://memory.example.com", uaa=_VALID_UAA).extract_config() assert config.token_url == "https://auth.example.com/oauth/token" def test_extract_config_strips_trailing_slash_from_uaa_url(self): - """extract_config() strips a trailing slash before appending /oauth/token.""" - binding = BindingData( - application_url="https://memory.example.com", - uaa_url="https://auth.example.com/", - uaa_clientid="cid", - uaa_clientsecret="csec", - ) - config = binding.extract_config() + uaa = json.dumps({"url": "https://auth.example.com/", "clientid": "c", "clientsecret": "s"}) + config = BindingData(url="https://memory.example.com", uaa=uaa).extract_config() assert config.token_url == "https://auth.example.com/oauth/token" - def test_extract_config_maps_all_fields(self): - """extract_config() maps all binding fields to AgentMemoryConfig.""" - binding = BindingData( - application_url="https://memory.example.com", - uaa_url="https://auth.example.com", - uaa_clientid="my-client", - uaa_clientsecret="my-secret", - ) - config = binding.extract_config() - assert config.base_url == "https://memory.example.com" + def test_extract_config_maps_client_credentials(self): + config = BindingData(url="https://memory.example.com", uaa=_VALID_UAA).extract_config() assert config.client_id == "my-client" assert config.client_secret == "my-secret" + def test_extract_config_raises_on_invalid_json(self): + with pytest.raises(AgentMemoryConfigError, match="Failed to parse uaa JSON"): + BindingData(url="https://memory.example.com", uaa="not-json").extract_config() + + def test_extract_config_raises_on_missing_json_key(self): + uaa = json.dumps({"url": "https://auth.example.com"}) # missing clientid/clientsecret + with pytest.raises(AgentMemoryConfigError, match="Missing required field in uaa JSON"): + BindingData(url="https://memory.example.com", uaa=uaa).extract_config() + + def test_extract_config_ignores_extra_uaa_fields(self): + uaa = json.dumps({ + "apiurl": "https://api.authentication.eu12.hana.ondemand.com", + "clientid": "my-client", + "clientsecret": "my-secret", + "credential-type": "binding-secret", + "identityzone": "my-zone", + "tenantid": "tenant-123", + "url": "https://auth.example.com", + "xsappname": "my-app", + "zoneid": "1acb547d-6df6-40a6-abb6-e41dd7d079d1", + }) + config = BindingData(url="https://memory.example.com", uaa=uaa).extract_config() + assert config.base_url == "https://memory.example.com" + assert config.token_url == "https://auth.example.com/oauth/token" + assert config.client_id == "my-client" + assert config.client_secret == "my-secret" -# ── _load_config_from_env ───────────────────────────────────────────────────── + def test_extract_config_raises_on_empty_uaa_object(self): + with pytest.raises(AgentMemoryConfigError, match="Missing required field in uaa JSON"): + BindingData(url="https://memory.example.com", uaa="{}").extract_config() -_MOUNT_LOADER = "sap_cloud_sdk.core.secret_resolver.resolver._load_from_mount" -_ENV_VARS = { - "CLOUD_SDK_CFG_HANA_AGENT_MEMORY_DEFAULT_APPLICATION_URL": "https://memory.example.com", - "CLOUD_SDK_CFG_HANA_AGENT_MEMORY_DEFAULT_UAA_URL": "https://auth.example.com", - "CLOUD_SDK_CFG_HANA_AGENT_MEMORY_DEFAULT_UAA_CLIENTID": "env-client", - "CLOUD_SDK_CFG_HANA_AGENT_MEMORY_DEFAULT_UAA_CLIENTSECRET": "env-secret", -} +# ── _load_config_from_env ───────────────────────────────────────────────────── -def _fill_binding(_base_volume_mount, _module, _instance, target) -> None: - target.application_url = "https://memory.example.com" - target.uaa_url = "https://auth.example.com" - target.uaa_clientid = "resolved-client" - target.uaa_clientsecret = "resolved-secret" +def _fill_binding(**kwargs) -> None: + target = kwargs["target"] + target.url = "https://memory.example.com" + target.uaa = _VALID_UAA class TestLoadConfigFromEnv: - def test_success_from_mount(self): - """_load_config_from_env() returns a valid AgentMemoryConfig when mount succeeds.""" - with patch(_MOUNT_LOADER, side_effect=_fill_binding): + def test_success_via_resolver(self): + with patch(_RESOLVER, side_effect=_fill_binding): config = _load_config_from_env() assert config.base_url == "https://memory.example.com" assert config.token_url == "https://auth.example.com/oauth/token" - assert config.client_id == "resolved-client" - assert config.client_secret == "resolved-secret" + assert config.client_id == "my-client" + assert config.client_secret == "my-secret" - def test_calls_mount_loader_with_correct_arguments(self): - """_load_config_from_env() calls _load_from_mount with the correct path/module/instance.""" - with patch(_MOUNT_LOADER, side_effect=_fill_binding) as mock_mount: + def test_calls_resolver_with_correct_arguments(self): + with patch(_RESOLVER, side_effect=_fill_binding) as mock_resolver: _load_config_from_env() - mock_mount.assert_called_once() - args = mock_mount.call_args[0] - assert args[0] == "/etc/secrets/appfnd" - assert args[1] == "hana-agent-memory" - assert args[2] == "default" + mock_resolver.assert_called_once() + _, kwargs = mock_resolver.call_args + assert kwargs["base_volume_mount"] == "/etc/secrets/appfnd" + assert kwargs["base_var_name"] == "CLOUD_SDK_CFG" + assert kwargs["module"] == "hana-agent-memory" + assert kwargs["instance"] == "default" - def test_falls_back_to_env_when_mount_fails(self, monkeypatch): - """_load_config_from_env() reads env vars when the mount path is unavailable.""" - for var, val in _ENV_VARS.items(): - monkeypatch.setenv(var, val) + def test_falls_back_to_env_vars(self, monkeypatch): + monkeypatch.setenv("CLOUD_SDK_CFG_HANA_AGENT_MEMORY_DEFAULT_URL", "https://memory.example.com") + monkeypatch.setenv("CLOUD_SDK_CFG_HANA_AGENT_MEMORY_DEFAULT_UAA", _VALID_UAA) - with patch(_MOUNT_LOADER, side_effect=FileNotFoundError("no mount")): + # Let the real resolver run — mount will fail, env vars will succeed + with patch("os.stat", side_effect=FileNotFoundError("no mount")): config = _load_config_from_env() assert config.base_url == "https://memory.example.com" - assert config.client_id == "env-client" - - def test_raises_config_error_when_mount_and_env_both_fail(self, monkeypatch): - """_load_config_from_env() raises AgentMemoryConfigError when both mount and env vars are absent.""" - for var in _ENV_VARS: - monkeypatch.delenv(var, raising=False) + assert config.client_id == "my-client" - with patch(_MOUNT_LOADER, side_effect=FileNotFoundError("no mount")): - with pytest.raises( - AgentMemoryConfigError, match="Missing required environment variables" - ): + def test_raises_config_error_when_resolver_fails(self): + with patch(_RESOLVER, side_effect=RuntimeError("both sources failed")): + with pytest.raises(AgentMemoryConfigError, match="Failed to load Agent Memory configuration"): _load_config_from_env() - def test_raises_config_error_when_mount_binding_incomplete_and_env_missing( - self, monkeypatch - ): - """_load_config_from_env() raises AgentMemoryConfigError when mount gives partial data and env is absent.""" - for var in _ENV_VARS: - monkeypatch.delenv(var, raising=False) + def test_raises_config_error_when_binding_incomplete(self): + def partial_fill(**kwargs): + kwargs["target"].url = "https://memory.example.com" + # uaa remains empty → validate() raises + + with patch(_RESOLVER, side_effect=partial_fill): + with pytest.raises(AgentMemoryConfigError, match="uaa"): + _load_config_from_env() - def incomplete_fill(_bvm, _mod, _inst, target): - target.application_url = "https://example.com" - # uaa_url/uaa_clientid/uaa_clientsecret remain empty → validate() raises + def test_raises_config_error_when_uaa_json_invalid(self, monkeypatch): + monkeypatch.setenv("CLOUD_SDK_CFG_HANA_AGENT_MEMORY_DEFAULT_URL", "https://memory.example.com") + monkeypatch.setenv("CLOUD_SDK_CFG_HANA_AGENT_MEMORY_DEFAULT_UAA", "not-valid-json") - with patch(_MOUNT_LOADER, side_effect=incomplete_fill): - with pytest.raises(AgentMemoryConfigError): + with patch("os.stat", side_effect=FileNotFoundError("no mount")): + with pytest.raises(AgentMemoryConfigError, match="Failed to parse uaa JSON"): _load_config_from_env()