254 lines
13 KiB
Python
254 lines
13 KiB
Python
"""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"})
|