"""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"})