portfolio-optimiser/tests/test_hosted_backend_loadbearing.py
Kjell Tore Guttormsen 63eec917d2 feat(4b): AZURE-profilen leser miljøet sitt, ikke operatørens laptop
Endepunktet løses som første ikke-tomme av vårt eget
PORTFOLIO_FOUNDRY_PROJECT_ENDPOINT og Foundrys injiserte
FOUNDRY_PROJECT_ENDPOINT — vårt vinner, fallbacken lar samme image kjøre
hostet uten ekstra wiring. Presedensen gjelder verdier, ikke deklarasjoner.
Credential velges av samme miljø: AzureCliCredential lokalt,
ManagedIdentityCredential når FOUNDRY_HOSTING_ENVIRONMENT er satt, fordi
containeren ikke har noen Azure CLI. Ikke DefaultAzureCredential — Learns
MAF-veiledning navngir den spesifikke credentialen for å unngå probing.

Load-bearing målt mot hele suiten, fire mutasjoner alle røde + grønn
kontroll: detach credential-valget · presence i stedet for truthiness ·
detach fallbacken · snu presedensen. Fail-fast-testen var vakuøs først —
vårt variabelnavn inneholder det injiserte som delstreng.

De fire åpne azure.yaml-valgene lukket mot de to JSON-skjemaene og ført i
docs/2026-08-13-fase4-azure-yaml-valg.md. Ingen azure.yaml skrevet (4d).

821 passed / 4 skipped. Ruff + format + mypy rene.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jk8tauXXAojNKC7Tzq7ziF
2026-08-13 22:27:21 +02:00

157 lines
8.1 KiB
Python

"""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"
(<https://learn.microsoft.com/agent-framework/integrations/by-component/agent-services/foundry>).
``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", "")