mirror of
http://100.103.83.12:3003/fegger/odoo-at-payroll.git
synced 2026-09-17 16:56:42 +00:00
feat(agent): harden API-first service
This commit is contained in:
@@ -0,0 +1,187 @@
|
||||
"""Offline-Tests für den versionierten API-Vertrag und seine Schutzgrenzen."""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from agent.api import app
|
||||
from agent.cli import _cmd_serve, _is_loopback_bind
|
||||
from agent.config import Config
|
||||
|
||||
|
||||
class FakeClient:
|
||||
def is_up(self):
|
||||
return True
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
|
||||
class FakeRetriever:
|
||||
def stats(self):
|
||||
return {
|
||||
"n_entries": 3,
|
||||
"n_chunks": 9,
|
||||
"n_vectors": 9,
|
||||
"dense_available": True,
|
||||
"stand_min": "202601",
|
||||
"stand_max": "202612",
|
||||
"built_at": "2026-09-16T00:00:00Z",
|
||||
}
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
|
||||
def answer_result(answer: str = "Belegte Antwort [lb-min-01].") -> dict:
|
||||
return {
|
||||
"question": "Was gilt?",
|
||||
"answer": answer,
|
||||
"refused": False,
|
||||
"verified": True,
|
||||
"citations": ["lb-min-01"],
|
||||
"sources": [
|
||||
{
|
||||
"id": "lb-min-01",
|
||||
"title": "Testquelle",
|
||||
"section": "Zusammenfassung",
|
||||
"stand": "2026-01",
|
||||
"work": "Testwerk",
|
||||
}
|
||||
],
|
||||
"n_context": 1,
|
||||
"model": "test-model",
|
||||
"regenerations": 0,
|
||||
"latency_ms": 12,
|
||||
"draft": None,
|
||||
"answer_type": "specific",
|
||||
"planned": False,
|
||||
"planned_queries": [
|
||||
{"text": "Was gilt?", "stand_year": None, "scope": None}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def configure_state(*, api_key: str = "", admin_key: str = "") -> None:
|
||||
app.state.rag.cfg = Config(api_key=api_key, admin_api_key=admin_key)
|
||||
app.state.rag.client = FakeClient()
|
||||
app.state.rag.retriever = FakeRetriever()
|
||||
|
||||
|
||||
def test_nonlocal_bind_without_api_key_is_rejected(capsys):
|
||||
class Args:
|
||||
host = "0.0.0.0"
|
||||
|
||||
assert _is_loopback_bind("127.0.0.1") is True
|
||||
assert _is_loopback_bind("::1") is True
|
||||
assert _is_loopback_bind("0.0.0.0") is False
|
||||
assert _cmd_serve(Args(), Config()) == 2
|
||||
assert "PV_API_KEY" in capsys.readouterr().err
|
||||
|
||||
|
||||
def test_v1_ask_requires_configured_bearer(monkeypatch):
|
||||
monkeypatch.setattr("agent.api.answer_question", lambda *args, **kwargs: answer_result())
|
||||
with TestClient(app) as client:
|
||||
configure_state(api_key="service-secret")
|
||||
unauthorized = client.post("/v1/ask", json={"question": "Was gilt?"})
|
||||
assert unauthorized.status_code == 401
|
||||
assert unauthorized.headers["www-authenticate"] == "Bearer"
|
||||
|
||||
response = client.post(
|
||||
"/v1/ask",
|
||||
json={"question": "Was gilt?"},
|
||||
headers={
|
||||
"Authorization": "Bearer service-secret",
|
||||
"X-Request-ID": "odoo-42",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["api_version"] == "v1"
|
||||
assert body["request_id"] == "odoo-42"
|
||||
assert response.headers["x-request-id"] == "odoo-42"
|
||||
assert body["status"] == "answered"
|
||||
assert body["grounding"] == {
|
||||
"data_scope": "knowledge_base_only",
|
||||
"citations_verified": True,
|
||||
"context_count": 1,
|
||||
"regenerations": 0,
|
||||
}
|
||||
assert body["planned_queries"][0]["text"] == "Was gilt?"
|
||||
|
||||
|
||||
def test_v1_contract_rejects_payroll_context(monkeypatch):
|
||||
monkeypatch.setattr("agent.api.answer_question", lambda *args, **kwargs: answer_result())
|
||||
with TestClient(app) as client:
|
||||
configure_state()
|
||||
response = client.post(
|
||||
"/v1/ask",
|
||||
json={
|
||||
"question": "Was gilt?",
|
||||
"employee_data": {"name": "Max", "salary": 5000},
|
||||
},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
def test_v1_extracts_grounded_conflict_and_clarification(monkeypatch):
|
||||
answer = (
|
||||
"⚠ Quelle A und Quelle B widersprechen einander [lb-min-01].\n\n"
|
||||
"Für welche Branche soll die Aussage geprüft werden?"
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"agent.api.answer_question", lambda *args, **kwargs: answer_result(answer)
|
||||
)
|
||||
with TestClient(app) as client:
|
||||
configure_state()
|
||||
response = client.post("/v1/ask", json={"question": "Was gilt?"})
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["conflicts"][0]["source_ids"] == ["lb-min-01"]
|
||||
assert body["clarification_question"] == (
|
||||
"Für welche Branche soll die Aussage geprüft werden?"
|
||||
)
|
||||
assert body["assumptions"] == []
|
||||
assert body["alternatives"] == []
|
||||
|
||||
|
||||
def test_health_does_not_expose_internal_ollama_url():
|
||||
with TestClient(app) as client:
|
||||
configure_state(api_key="service-secret")
|
||||
response = client.get("/v1/health")
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["status"] == "ok"
|
||||
assert body["authentication_enabled"] is True
|
||||
assert "ollama_url" not in response.text
|
||||
assert "100.103.83.12" not in response.text
|
||||
|
||||
|
||||
@dataclass
|
||||
class FakeStats:
|
||||
embed_error: str | None = None
|
||||
|
||||
def as_dict(self):
|
||||
return {"n_entries": 3, "n_chunks": 9}
|
||||
|
||||
|
||||
def test_reindex_uses_separate_admin_key(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"agent.api.build_index", lambda cfg, client=None: FakeStats()
|
||||
)
|
||||
with TestClient(app) as client:
|
||||
configure_state(api_key="service-secret", admin_key="admin-secret")
|
||||
denied = client.post(
|
||||
"/v1/reindex",
|
||||
headers={"Authorization": "Bearer service-secret"},
|
||||
)
|
||||
assert denied.status_code == 401
|
||||
|
||||
response = client.post(
|
||||
"/v1/reindex",
|
||||
headers={"Authorization": "Bearer admin-secret"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["api_version"] == "v1"
|
||||
assert response.json()["n_entries"] == 3
|
||||
@@ -1,5 +1,7 @@
|
||||
"""Tests: Hybrid-Retrieval (BM25-only offline): Fusion, Entry-Dedup,
|
||||
cross_ref-Erweiterung, leeres Retrieval."""
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
import pytest
|
||||
|
||||
from agent.retrieve import Retriever
|
||||
@@ -62,6 +64,14 @@ def test_recency_boost_prefers_newer_stand(retriever):
|
||||
assert main[0].entry_id in {"lb-min-01", "lb-min-02"}
|
||||
|
||||
|
||||
def test_shared_retriever_is_safe_across_worker_threads(retriever):
|
||||
"""FastAPI darf dieselbe Retriever-Instanz in mehreren Threads nutzen."""
|
||||
questions = ["Altersteilzeit Lohnausgleich", "Urlaubsanspruch Werktage"] * 4
|
||||
with ThreadPoolExecutor(max_workers=4) as pool:
|
||||
result_sets = list(pool.map(retriever.search, questions))
|
||||
assert all(results for results in result_sets)
|
||||
|
||||
|
||||
def test_stats_report(mini_index):
|
||||
r = Retriever(mini_index)
|
||||
try:
|
||||
|
||||
Reference in New Issue
Block a user