portfolio-optimiser/tests/test_preflight.py
Kjell Tore Guttormsen 88c223276c fix(5): preflight kjenner samme endepunkt-variabler som kjørestien [skip-docs]
Målt fra den utpakkede overleveringspakka: med KUN plattformens injiserte
FOUNDRY_PROJECT_ENDPOINT — altså nøyaktig situasjonen i en hostet Foundry-container —
avslo preflight en konfigurasjon backends.py ville godtatt. Gaten og kjørestien kjente
ulike navn; det er repoets egen «checker og kjøresti validerer ulikt»-klasse, og for
mottakeren av pakka er det et falskt avslag på riktig oppsett.

_ENDPOINT_ENVS IMPORTERES nå fra backends i stedet for å gjentas, så de to kan ikke
drifte fra hverandre igjen. Presedens over VERDIER, ikke deklarasjoner: et eksportert-men-
tomt eget navn faller igjennom i stedet for å skygge et ekte injisert inn i en fail-fast.
Avslaget navngir BEGGE variablene.

Iron Law: 3 røde diskriminatorer + 1 grønn kontroll FØR fiksen. 854 passed / 4 skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SeW1LhH5TtXxKZPe9JkqL1
2026-08-14 10:48:45 +02:00

212 lines
10 KiB
Python

"""S4.1 preflight — unit + CLI tests: happy path, refusals, structured error handling, doc-guard.
Load-bearing detach seams (AST no-network/no-auto-login guard + refusal teeth) live in
``test_preflight_loadbearing.py``; these are the happy-path + behaviour tests.
"""
from __future__ import annotations
import json
import os
from pathlib import Path
import pytest
from portfolio_optimiser import preflight
_VALID_ENDPOINT = "https://x.services.ai.azure.com/api/projects/p"
_VALID_MAP = {
"local": {"default": "qwen3:4b", "proposer": "qwen3:4b", "checker": "qwen3:4b"},
"azure": {"default": "gpt-4o-mini", "proposer": "gpt-4o-mini", "checker": "gpt-4o-mini"},
}
def _write_map(tmp_path: Path, data: dict) -> Path:
p = tmp_path / "model_map.json"
p.write_text(json.dumps(data), encoding="utf-8")
return p
def test_sc1_placeholder_refusal(
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
# SC1: bundled azure map ships REPLACE-WITH-* → refusal naming the deployment (endpoint valid,
# so the placeholder is the sole failure). Value-pinned per test_costsim.py:95-106.
monkeypatch.delenv("PORTFOLIO_MODEL_MAP", raising=False)
monkeypatch.setenv("PORTFOLIO_FOUNDRY_PROJECT_ENDPOINT", _VALID_ENDPOINT)
rc = preflight.main(["--profile", "azure"])
assert rc == 1
assert "REPLACE-WITH-FOUNDRY-DEPLOYMENT" in capsys.readouterr().err
def test_sc2_happy_path(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
# SC2: complete valid override map + URL-shaped endpoint → rc 0, stdout carries the exact
# necessary-but-not-sufficient marker.
monkeypatch.setenv("PORTFOLIO_MODEL_MAP", str(_write_map(tmp_path, _VALID_MAP)))
monkeypatch.setenv("PORTFOLIO_FOUNDRY_PROJECT_ENDPOINT", _VALID_ENDPOINT)
rc = preflight.main(["--profile", "azure"])
assert rc == 0
assert "ikke tilstrekkelig" in capsys.readouterr().out
def test_sc3_missing_endpoint(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
# SC3: unset endpoint → rc 1 + message names the env var.
monkeypatch.setenv("PORTFOLIO_MODEL_MAP", str(_write_map(tmp_path, _VALID_MAP)))
monkeypatch.delenv("PORTFOLIO_FOUNDRY_PROJECT_ENDPOINT", raising=False)
rc = preflight.main(["--profile", "azure"])
assert rc == 1
assert "PORTFOLIO_FOUNDRY_PROJECT_ENDPOINT" in capsys.readouterr().err
def test_wrong_host_surface_refused(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
# A URL-valid but wrong-surface endpoint (*.openai.azure.com) is refused, naming the host.
monkeypatch.setenv("PORTFOLIO_MODEL_MAP", str(_write_map(tmp_path, _VALID_MAP)))
monkeypatch.setenv("PORTFOLIO_FOUNDRY_PROJECT_ENDPOINT", "https://x.openai.azure.com/foo")
rc = preflight.main(["--profile", "azure"])
assert rc == 1
assert "openai.azure.com" in capsys.readouterr().err
def test_bare_host_endpoint_accepted(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
# Research §3: official samples use the bare-host form (no /api/projects/) — must be accepted.
monkeypatch.setenv("PORTFOLIO_MODEL_MAP", str(_write_map(tmp_path, _VALID_MAP)))
monkeypatch.setenv("PORTFOLIO_FOUNDRY_PROJECT_ENDPOINT", "https://x.services.ai.azure.com")
assert isinstance(preflight.check_azure_preflight("azure"), preflight.PreflightOK)
def test_missing_override_file_is_structured_refusal(
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
# A bad PORTFOLIO_MODEL_MAP must NOT traceback — structured refusal, rc 1.
monkeypatch.setenv("PORTFOLIO_FOUNDRY_PROJECT_ENDPOINT", _VALID_ENDPOINT)
monkeypatch.setenv("PORTFOLIO_MODEL_MAP", "/nonexistent/model_map.json")
rc = preflight.main(["--profile", "azure"])
assert rc == 1
assert "model map not found" in capsys.readouterr().err
def test_malformed_override_is_structured_refusal(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
# Override missing the azure block → ModelMapContract ValidationError → structured refusal.
bad = _write_map(tmp_path, {"local": {"default": "qwen3:4b"}})
monkeypatch.setenv("PORTFOLIO_FOUNDRY_PROJECT_ENDPOINT", _VALID_ENDPOINT)
monkeypatch.setenv("PORTFOLIO_MODEL_MAP", str(bad))
rc = preflight.main(["--profile", "azure"])
assert rc == 1
assert capsys.readouterr().err.strip()
def test_non_https_endpoint_refused(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
# Review MAJOR (d87baffb…): the non-https scheme guard (preflight.py:61) had no test — every
# other endpoint fixture is https://. Drive an http:// endpoint through it → rc 1 naming the
# https requirement, so an inverted condition / broken message can't regress silently.
monkeypatch.setenv("PORTFOLIO_MODEL_MAP", str(_write_map(tmp_path, _VALID_MAP)))
monkeypatch.setenv("PORTFOLIO_FOUNDRY_PROJECT_ENDPOINT", "http://x.services.ai.azure.com")
rc = preflight.main(["--profile", "azure"])
assert rc == 1
assert "https://" in capsys.readouterr().err
def test_non_azure_profile_refused(monkeypatch: pytest.MonkeyPatch) -> None:
# Review MAJOR (d87baffb…): the non-azure profile refusal (preflight.py:82-86) had no test.
# check_azure_preflight('local') must return a PreflightRefusal (the local profile needs no
# preflight), not a PreflightOK — the branch fires before any endpoint/map lookup, so no env.
result = preflight.check_azure_preflight("local")
assert isinstance(result, preflight.PreflightRefusal)
assert "local" in result.reason
def test_unreadable_override_is_structured_refusal(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
# Review MINOR (07dab06f…): an existing-but-UNREADABLE PORTFOLIO_MODEL_MAP raises PermissionError
# (an OSError, not a ValueError) from _load_effective_map's read_text — it passes is_file() first.
# Preflight must still refuse cleanly (rc 1, no traceback), guarding the traceback-free invariant
# on the OSError branch, not just FileNotFoundError.
if hasattr(os, "geteuid") and os.geteuid() == 0:
pytest.skip("root bypasses the file-permission bits; chmod 000 would stay readable")
bad = _write_map(tmp_path, _VALID_MAP)
bad.chmod(0o000)
try:
monkeypatch.setenv("PORTFOLIO_FOUNDRY_PROJECT_ENDPOINT", _VALID_ENDPOINT)
monkeypatch.setenv("PORTFOLIO_MODEL_MAP", str(bad))
rc = preflight.main(["--profile", "azure"])
assert rc == 1
assert capsys.readouterr().err.strip()
finally:
bad.chmod(0o644)
def test_auth_recipe_doc_exists_and_names_facts() -> None:
# SC8 doc-guard: the verified recipe lives in docs/ (NOT the preflight docstring, so the
# AST grep-guard stays clean) and pins the load-bearing facts so it can't rot silently. The
# English "necessary-but-not-sufficient" marker is distinct from the CLI's Norwegian marker.
doc = Path(__file__).resolve().parents[1] / "docs" / "2026-07-15-foundry-auth-recipe.md"
text = doc.read_text(encoding="utf-8")
assert "Foundry User" in text
assert "services.ai.azure.com" in text
assert "necessary-but-not-sufficient" in text
# --- Fase 5: preflight and the run path must know the SAME endpoint variables -----------------
# Before this, preflight read ONLY our own name while backends.py accepted the platform-injected
# one as a fallback. Inside a hosted Foundry container — where the platform injects
# FOUNDRY_PROJECT_ENDPOINT and nothing else — the gate therefore refused a configuration the run
# path would have accepted. That is the repo's own "checker and run path validate differently"
# defect class, and it is friction the receiver of the handover package pays.
_INJECTED_ENV = "FOUNDRY_PROJECT_ENDPOINT"
_OURS_ENV = "PORTFOLIO_FOUNDRY_PROJECT_ENDPOINT"
def test_injected_endpoint_alone_is_accepted(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""A hosted container sets only the platform name. Detach the fallback → RED (refuses)."""
monkeypatch.delenv(_OURS_ENV, raising=False)
monkeypatch.setenv(_INJECTED_ENV, _VALID_ENDPOINT)
monkeypatch.setenv("PORTFOLIO_MODEL_MAP", str(_write_map(tmp_path, _VALID_MAP)))
assert isinstance(preflight.check_azure_preflight("azure"), preflight.PreflightOK)
def test_our_name_wins_over_the_injected_one(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""Precedence mirrors backends.py: ours first. The injected value here is a WRONG host, so a
green result can only mean ours was read — the two arms are distinguishable."""
monkeypatch.setenv(_OURS_ENV, _VALID_ENDPOINT)
monkeypatch.setenv(_INJECTED_ENV, "https://wrong.openai.azure.com/")
monkeypatch.setenv("PORTFOLIO_MODEL_MAP", str(_write_map(tmp_path, _VALID_MAP)))
assert isinstance(preflight.check_azure_preflight("azure"), preflight.PreflightOK)
def test_precedence_is_over_values_not_declarations(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""An exported-but-EMPTY own name must fall through to a real injected one rather than shadow
it into a refusal (the 4b rule, same seam)."""
monkeypatch.setenv(_OURS_ENV, "")
monkeypatch.setenv(_INJECTED_ENV, _VALID_ENDPOINT)
monkeypatch.setenv("PORTFOLIO_MODEL_MAP", str(_write_map(tmp_path, _VALID_MAP)))
assert isinstance(preflight.check_azure_preflight("azure"), preflight.PreflightOK)
def test_missing_endpoint_refusal_names_both_variables(monkeypatch: pytest.MonkeyPatch) -> None:
"""Line-anchored, not substring: PORTFOLIO_FOUNDRY_PROJECT_ENDPOINT CONTAINS
FOUNDRY_PROJECT_ENDPOINT, so a message naming only ours satisfies a naive assert (the repo's
08-09 defect class). Strip our name before looking for the injected one."""
monkeypatch.delenv(_OURS_ENV, raising=False)
monkeypatch.delenv(_INJECTED_ENV, raising=False)
result = preflight.check_azure_preflight("azure")
assert isinstance(result, preflight.PreflightRefusal)
assert _OURS_ENV in result.reason
assert _INJECTED_ENV in result.reason.replace(_OURS_ENV, "")