"""Fase 4b — the AZURE backend must read its environment, not assume the operator's laptop. Two seams, each with the control that makes it discriminate: * **Credential by environment.** ``AzureCliCredential`` is the friction-minimal path on a developer host (``az login`` is a manual step), but inside a Foundry hosted agent there is no Azure CLI at all — the platform mints a dedicated Entra agent identity for the container, and Learn's own MAF guidance says to prefer a specific credential there: "``DefaultAzureCredential`` is convenient for development. In production, prefer a specific credential such as ``ManagedIdentityCredential`` to avoid unintended credential probing" (). ``FOUNDRY_HOSTING_ENVIRONMENT`` is the marker: it is one of the variables the platform injects at startup (spike §"Ikke verifisert" pkt. 2) and exists nowhere else. * **Endpoint name fallback.** The platform injects the project endpoint as ``FOUNDRY_PROJECT_ENDPOINT``; our own ``PORTFOLIO_FOUNDRY_PROJECT_ENDPOINT`` predates it and is what every existing doc, test and recipe sets. Ours WINS — an operator who exports our name is making a deliberate choice, and a platform value silently overriding it would be unexplainable from the outside. The injected name is a fallback, so a container needs no extra wiring. The recorder tests below patch ``backends.FoundryChatClient`` to observe what is passed to it — the client itself exposes no credential attribute (MEASURED: ``[a for a in dir(client) if 'cred' in a.lower()]`` is empty), so there is no way to read the choice back off a built client. That patch would, alone, stop proving the real client ACCEPTS the credential — so ``test_real_client_accepts_managed_identity_offline`` keeps one unpatched arm. """ from __future__ import annotations from typing import Any import pytest from agent_framework import BaseChatClient from azure.identity.aio import AzureCliCredential, ManagedIdentityCredential from portfolio_optimiser import backends _ENDPOINT = "https://x.services.ai.azure.com/api/projects/p" _INJECTED_ENDPOINT = "https://platform.services.ai.azure.com/api/projects/injected" @pytest.fixture(autouse=True) def _clean_env(monkeypatch: pytest.MonkeyPatch) -> None: """Hermetic: BOTH endpoint names and the hosting marker are cleared. Without clearing the injected name too, an ambient value would invert the precedence arms.""" monkeypatch.delenv("PORTFOLIO_FOUNDRY_PROJECT_ENDPOINT", raising=False) monkeypatch.delenv("FOUNDRY_PROJECT_ENDPOINT", raising=False) monkeypatch.delenv("FOUNDRY_HOSTING_ENVIRONMENT", raising=False) @pytest.fixture() def captured(monkeypatch: pytest.MonkeyPatch) -> dict[str, Any]: """Record the kwargs that reach ``FoundryChatClient`` on the real ``create_chat_client`` path.""" seen: dict[str, Any] = {} def _recorder(**kwargs: Any) -> object: seen.update(kwargs) return object() monkeypatch.setattr(backends, "FoundryChatClient", _recorder) return seen # --- Seam 1: credential by environment ------------------------------------------------------ def test_hosted_marker_selects_managed_identity( captured: dict[str, Any], monkeypatch: pytest.MonkeyPatch ) -> None: """Detach point: construct ``AzureCliCredential`` unconditionally → RED here.""" monkeypatch.setenv("PORTFOLIO_FOUNDRY_PROJECT_ENDPOINT", _ENDPOINT) monkeypatch.setenv("FOUNDRY_HOSTING_ENVIRONMENT", "AzureFoundryAgentService") backends.get_backend("azure").create_chat_client(model="gpt-4o-mini") assert isinstance(captured["credential"], ManagedIdentityCredential) def test_unhosted_selects_azure_cli_credential( captured: dict[str, Any], monkeypatch: pytest.MonkeyPatch ) -> None: """CONTROL — without it, an implementation that ALWAYS returns a managed identity passes the test above. The selector must discriminate, not merely reach the hosted branch.""" monkeypatch.setenv("PORTFOLIO_FOUNDRY_PROJECT_ENDPOINT", _ENDPOINT) backends.get_backend("azure").create_chat_client(model="gpt-4o-mini") assert isinstance(captured["credential"], AzureCliCredential) def test_empty_hosting_marker_is_not_hosted( captured: dict[str, Any], monkeypatch: pytest.MonkeyPatch ) -> None: """An exported-but-empty marker is not a hosting signal. Detach point: test presence with ``is not None`` instead of truthiness → RED here. This is the arm that separates "the variable exists" from "we are hosted"; a shell that exports an empty value is a real, cheap accident.""" monkeypatch.setenv("PORTFOLIO_FOUNDRY_PROJECT_ENDPOINT", _ENDPOINT) monkeypatch.setenv("FOUNDRY_HOSTING_ENVIRONMENT", "") backends.get_backend("azure").create_chat_client(model="gpt-4o-mini") assert isinstance(captured["credential"], AzureCliCredential) def test_real_client_accepts_managed_identity_offline(monkeypatch: pytest.MonkeyPatch) -> None: """UNPATCHED arm: the real ``FoundryChatClient`` accepts the hosted credential and construction stays OFFLINE (no token acquired). Without this, the recorder tests would prove only that we pass SOMETHING named ``credential``.""" monkeypatch.setenv("PORTFOLIO_FOUNDRY_PROJECT_ENDPOINT", _ENDPOINT) monkeypatch.setenv("FOUNDRY_HOSTING_ENVIRONMENT", "AzureFoundryAgentService") client = backends.get_backend("azure").create_chat_client(model="gpt-4o-mini") assert isinstance(client, BaseChatClient) # --- Seam 2: endpoint name fallback --------------------------------------------------------- def test_injected_endpoint_used_when_ours_is_absent( captured: dict[str, Any], monkeypatch: pytest.MonkeyPatch ) -> None: """Detach point: read only ``PORTFOLIO_FOUNDRY_PROJECT_ENDPOINT`` → RED here (raises).""" monkeypatch.setenv("FOUNDRY_PROJECT_ENDPOINT", _INJECTED_ENDPOINT) backends.get_backend("azure").create_chat_client(model="gpt-4o-mini") assert captured["project_endpoint"] == _INJECTED_ENDPOINT def test_our_endpoint_wins_over_injected( captured: dict[str, Any], monkeypatch: pytest.MonkeyPatch ) -> None: """CONTROL for precedence — the two values must DIFFER, otherwise the assertion cannot tell which name was read. Detach point: let the injected name win → RED here.""" monkeypatch.setenv("PORTFOLIO_FOUNDRY_PROJECT_ENDPOINT", _ENDPOINT) monkeypatch.setenv("FOUNDRY_PROJECT_ENDPOINT", _INJECTED_ENDPOINT) backends.get_backend("azure").create_chat_client(model="gpt-4o-mini") assert captured["project_endpoint"] == _ENDPOINT def test_empty_own_endpoint_falls_through_to_injected( captured: dict[str, Any], monkeypatch: pytest.MonkeyPatch ) -> None: """Precedence is over VALUES, not over declarations: an exported-but-empty own name must not shadow a real injected one into a fail-fast.""" monkeypatch.setenv("PORTFOLIO_FOUNDRY_PROJECT_ENDPOINT", "") monkeypatch.setenv("FOUNDRY_PROJECT_ENDPOINT", _INJECTED_ENDPOINT) backends.get_backend("azure").create_chat_client(model="gpt-4o-mini") assert captured["project_endpoint"] == _INJECTED_ENDPOINT def test_neither_endpoint_fails_fast_naming_both() -> None: """Fail-fast is unchanged in kind, but the message must name BOTH names — an operator in a container and an operator on a laptop set different ones, and a message naming only ours sends the container operator looking for the wrong variable. The naive form of the second assertion is VACUOUS and was written that way first: ``"FOUNDRY_PROJECT_ENDPOINT" in message`` is satisfied by the substring inside ``PORTFOLIO_FOUNDRY_PROJECT_ENDPOINT``, so a message naming ONLY our own variable passes it. That is the repo's own 08-09 defect class ("assert never on a substring two branches share"). Removing our name first is what makes the assertion able to fail.""" with pytest.raises(ValueError) as excinfo: backends.get_backend("azure").create_chat_client(model="gpt-4o-mini") message = str(excinfo.value) assert "PORTFOLIO_FOUNDRY_PROJECT_ENDPOINT" in message assert "FOUNDRY_PROJECT_ENDPOINT" in message.replace("PORTFOLIO_FOUNDRY_PROJECT_ENDPOINT", "")