bifroest/tests/test_approvals.py
2026-10-27 08:34:17 +01:00

254 lines
13 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Freigaben: Antrag, Entscheidung, Ausführung, Ablauf, Abgleich (→ Konzept 9.2, 10.4–10.6).
Mit dem schreibenden Testwerkzeug demo_write, unabhängig von Git."""
import re
import uuid
from datetime import timedelta
import pytest
from sqlalchemy import select
from app.approvals import service as approvals
from app.approvals.models import ApprovalRequest
from app.audit.models import AuditEvent
from app.chat.models import Message
from app.core.database import now
from app.identity import Principal
from app.tasks import engine, states
from app.tasks.models import TaskRun
from app.tasks.worker import Worker
from app.tools import service as tools
from app.tools.models import ALLOW, ToolCall
from helpers import DemoTarget, connect_repo, create_workspace
CHANGE = {"title": "README ergänzen", "text": "Neue Zeile."}
@pytest.fixture
def setup(browser, people, db, git_repo):
"""Workspace mit Repo; anna ist Workspace-Admin, ben Mitglied und fragt im Chat."""
anna = browser("anna")
workspace_id = create_workspace(anna, people["team_demo"])
connect_repo(anna, workspace_id, git_repo)
anna.post(f"/workspaces/{workspace_id}/members", {"username": "ben", "role": "member"})
admin = Principal(user_id=people["anna"], username="anna", display_name="Anna", is_admin=True,
team_ids=frozenset({people["team_demo"]}))
with db.session() as session:
tools.classify_suggested(session, admin)
ben = browser("ben")
chat_id = int(ben.post(f"/workspaces/{workspace_id}/chats").headers["location"].rsplit("/", 1)[1])
return {"anna": anna, "ben": ben, "chat_id": chat_id, "workspace_id": workspace_id,
"admin": admin}
def ask(b, chat_id: int, text: str = "Bitte README ergänzen") -> uuid.UUID:
response = b.post(f"/chats/{chat_id}/messages", {"content": text}, headers={"HX-Request": "true"})
return uuid.UUID(re.search(r"/runs/([0-9a-f-]+)/events", response.text).group(1))
def work(db, settings) -> None:
Worker(db, settings).run_once()
def status(db, run_id) -> str:
with db.session() as session:
return session.get(TaskRun, run_id).status
def only_approval(db) -> ApprovalRequest:
with db.session() as session:
approval = session.scalar(select(ApprovalRequest).order_by(ApprovalRequest.id.desc()))
session.expunge(approval)
return approval
def propose(setup, db, settings, fake_provider, *rest) -> uuid.UUID:
fake_provider.script = [[("demo_write", CHANGE)], *rest]
run_id = ask(setup["ben"], setup["chat_id"])
work(db, settings)
assert status(db, run_id) == states.WAITING_APPROVAL
return run_id
def test_genehmigt_fuehrt_aus_und_setzt_fort(setup, db, settings, fake_provider):
run_id = propose(setup, db, settings, fake_provider, "Erledigt.")
approval = only_approval(db)
assert approval.status == "pending" and "+Neue Zeile." in approval.preview
assert DemoTarget.written == [] # vor der Freigabe wirkt nichts nach außen
with db.session() as session:
assert session.scalar(select(ToolCall.status).where(ToolCall.run_id == run_id)) == "pending"
# Antragsteller sieht den Antrag, darf aber nicht entscheiden
page = setup["ben"].get(f"/approvals/{approval.id}").text
assert "+Neue Zeile." in page and "Freigeben" not in page
assert setup["ben"].post(f"/approvals/{approval.id}/decide", {"decision": "approve"}).status_code == 403
# Admin sieht Badge, Liste und Vorschau samt gekennzeichneter KI-Beschreibung
assert 'badge-warning">1<' in setup["anna"].get("/approvals/count").text
assert "README ergänzen" in setup["anna"].get("/approvals").text
page = setup["anna"].get(f"/approvals/{approval.id}").text
assert "aiw-diff-add" in page and "Beschreibung der KI (nicht geprüft" in page
assert setup["anna"].post(f"/approvals/{approval.id}/decide", {"decision": "approve"}).status_code == 303
assert status(db, run_id) == states.QUEUED
work(db, settings)
assert status(db, run_id) == states.COMPLETED
approval = only_approval(db)
assert approval.status == "executed" and not approval.self_approved
assert DemoTarget.written == [("README ergänzen", "Neue Zeile.")]
# Das Modell erhält das echte Ergebnis als Tool-Ausgabe
last_input = fake_provider.calls[-1]["input"][-1]
assert last_input["type"] == "function_call_output" and "Geschrieben: README ergänzen" in last_input["output"]
with db.session() as session:
assert session.scalar(select(ToolCall.status).where(ToolCall.run_id == run_id)) == "ok"
assert session.scalar(select(TaskRun.state).where(TaskRun.id == run_id)) == {}
def test_abgelehnt_fuehrt_nicht_aus(setup, db, settings, fake_provider):
run_id = propose(setup, db, settings, fake_provider, [("demo_write", CHANGE)], "Dann eben nicht.")
approval = only_approval(db)
assert setup["anna"].post(f"/approvals/{approval.id}/decide", {"decision": "reject"}).status_code == 400
setup["anna"].post(f"/approvals/{approval.id}/decide", {"decision": "reject", "comment": "Nicht jetzt"})
work(db, settings)
assert status(db, run_id) == states.COMPLETED
assert DemoTarget.written == []
outputs = [i for i in fake_provider.calls[1]["input"] if i.get("type") == "function_call_output"]
assert "Abgelehnt von Anna: Nicht jetzt" in outputs[-1]["output"]
# Derselbe Vorschlag erneut: wird ohne neuen Antrag abgewiesen (→ Konzept 9.2)
outputs = [i for i in fake_provider.calls[2]["input"] if i.get("type") == "function_call_output"]
assert "bereits abgelehnt" in outputs[-1]["output"]
with db.session() as session:
assert session.scalar(select(ApprovalRequest.id).where(ApprovalRequest.status == "pending")) is None
# Abgelehnte Anträge lassen sich nicht erneut beantragen
assert setup["ben"].post(f"/approvals/{approval.id}/rerequest").status_code == 400
def test_selbstfreigabe_im_audit(setup, db, settings, fake_provider):
fake_provider.script = [[("demo_write", CHANGE)], "Erledigt."]
anna_chat = int(setup["anna"].post(f"/workspaces/{setup['workspace_id']}/chats").headers["location"]
.rsplit("/", 1)[1])
run_id = ask(setup["anna"], anna_chat)
work(db, settings)
approval = only_approval(db)
assert "Selbstfreigabe" in setup["anna"].get(f"/approvals/{approval.id}").text
setup["anna"].post(f"/approvals/{approval.id}/decide", {"decision": "approve"})
work(db, settings)
assert status(db, run_id) == states.COMPLETED
with db.session() as session:
event = session.scalar(select(AuditEvent).where(AuditEvent.action == "approval.approved"))
assert event.data["self_approved"] is True
assert only_approval(db).self_approved
def test_ablauf_und_erneut_beantragen(setup, db, settings, fake_provider):
run_id = propose(setup, db, settings, fake_provider, "Abgelaufen, schade.")
approval = only_approval(db)
with db.session() as session:
session.get(ApprovalRequest, approval.id).expires_at = now() - timedelta(minutes=1)
work(db, settings) # Worker schließt den Antrag und setzt den Run fort
assert only_approval(db).status == "expired" and status(db, run_id) == states.COMPLETED
assert "abgelaufen" in fake_provider.calls[-1]["input"][-1]["output"]
assert setup["anna"].post(f"/approvals/{approval.id}/decide", {"decision": "approve"}).status_code == 400
assert "Erneut beantragen" in setup["ben"].get(f"/approvals/{approval.id}").text
assert setup["ben"].post(f"/approvals/{approval.id}/rerequest").status_code == 303
assert setup["ben"].post(f"/approvals/{approval.id}/rerequest").status_code == 400 # nur einmal
work(db, settings)
renewed = only_approval(db)
assert renewed.replaces_id == approval.id and renewed.status == "pending"
setup["anna"].post(f"/approvals/{renewed.id}/decide", {"decision": "approve"})
work(db, settings)
assert only_approval(db).status == "executed" and DemoTarget.written
with db.session() as session:
text = session.scalars(select(Message.content).where(Message.chat_session_id == setup["chat_id"])
.order_by(Message.id.desc())).first()
assert text.startswith("**Erneut beantragte Änderung:** Geschrieben")
def test_manipulierter_snapshot_wird_nicht_ausgefuehrt(setup, db, settings, fake_provider):
propose(setup, db, settings, fake_provider, "Ok.")
approval = only_approval(db)
setup["anna"].post(f"/approvals/{approval.id}/decide", {"decision": "approve"})
with db.session() as session:
stored = session.get(ApprovalRequest, approval.id)
stored.arguments = {**stored.arguments, "title": "Etwas anderes"}
work(db, settings)
assert only_approval(db).status == "failed" and DemoTarget.written == []
assert "Snapshot passt nicht" in fake_provider.calls[-1]["input"][-1]["output"]
def test_fremde_sehen_keine_antraege(setup, db, settings, fake_provider, browser):
propose(setup, db, settings, fake_provider, "Ok.")
approval = only_approval(db)
dora = browser("dora")
assert dora.get(f"/approvals/{approval.id}").status_code == 404
assert dora.post(f"/approvals/{approval.id}/decide", {"decision": "approve"}).status_code == 404
assert "README ergänzen" not in dora.get("/approvals").text
clara = browser("clara") # im Team, aber nicht im Workspace
assert clara.get(f"/approvals/{approval.id}").status_code == 404
def test_abbruch_zieht_antrag_zurueck(setup, db, settings, fake_provider):
run_id = propose(setup, db, settings, fake_provider)
approval = only_approval(db)
assert setup["ben"].post(f"/runs/{run_id}/cancel").status_code == 204
assert status(db, run_id) == states.CANCELLED and only_approval(db).status == "cancelled"
assert setup["anna"].post(f"/approvals/{approval.id}/decide", {"decision": "approve"}).status_code == 400
def test_absturz_nach_schreiben_wird_abgeglichen(setup, db, settings, fake_provider):
"""Ausgang unklar (Absturz nach dem Schreiben): needs_reconciliation, danach kein doppeltes Schreiben."""
run_id = propose(setup, db, settings, fake_provider, "Fertig nach Abgleich.")
approval = only_approval(db)
setup["anna"].post(f"/approvals/{approval.id}/decide", {"decision": "approve"})
DemoTarget.crash = True
work(db, settings)
assert status(db, run_id) == states.NEEDS_RECONCILIATION and only_approval(db).status == "unknown"
page = setup["ben"].get(f"/runs/{run_id}/answer").text
assert "unklar" in page and "Abgleichen und fortsetzen" not in page # nur Admins
assert setup["ben"].post(f"/runs/{run_id}/reconcile").status_code == 403
assert "Abgleichen und fortsetzen" in setup["anna"].get(f"/runs/{run_id}/answer").text
assert setup["anna"].post(f"/runs/{run_id}/reconcile").status_code == 303
DemoTarget.crash = False
work(db, settings)
assert status(db, run_id) == states.COMPLETED
assert only_approval(db).status == "executed" and len(DemoTarget.written) == 1
def test_lease_ablauf_waehrend_schreibaktion(setup, db, settings, fake_provider):
run_id = propose(setup, db, settings, fake_provider)
approval = only_approval(db)
with db.session() as session:
run = session.get(TaskRun, run_id)
engine.set_status(session, run, states.QUEUED)
engine.set_status(session, run, states.RUNNING)
run.locked_by, run.locked_until = "toter-worker", now() - timedelta(seconds=5)
session.get(ApprovalRequest, approval.id).status = "executing"
assert engine.recover_expired(db, settings) == 1
assert status(db, run_id) == states.NEEDS_RECONCILIATION and only_approval(db).status == "unknown"
def test_policy_allow_fuehrt_ohne_pause_aus(setup, db, settings, fake_provider):
with db.session() as session:
tools.classify(session, setup["admin"], "demo_write", "write_external", ALLOW)
fake_provider.script = [[("demo_write", CHANGE)], "Direkt erledigt."]
run_id = ask(setup["ben"], setup["chat_id"])
work(db, settings)
assert status(db, run_id) == states.COMPLETED
approval = only_approval(db)
assert approval.status == "executed" and approval.decided_by is None and approval.comment == "Durch Policy erlaubt"
assert len(DemoTarget.written) == 1
def test_modus_b_verlangt_auch_bei_allow_freigabe(setup, db, settings, fake_provider):
with db.session() as session:
tools.classify(session, setup["admin"], "demo_write", "write_external", ALLOW)
from app.workspaces.models import Workspace
session.get(Workspace, setup["workspace_id"]).approval_mode = "all_writes"
propose(setup, db, settings, fake_provider)
assert only_approval(db).reason == "Modus B"
def test_hash_deckt_snapshot_ab():
policy = {"tool_hash": "abc"}
base = approvals.snapshot_hash("t", 1, {"a": 1}, "diff", {"base": "x"}, policy)
assert base != approvals.snapshot_hash("t", 1, {"a": 2}, "diff", {"base": "x"}, policy)
assert base != approvals.snapshot_hash("t", 1, {"a": 1}, "diff", {"base": "y"}, policy)
assert base != approvals.snapshot_hash("t", 1, {"a": 1}, "diff", {"base": "x"}, {"tool_hash": "def"})