344 lines
12 KiB
Python
344 lines
12 KiB
Python
"""Step 3 tests: flows CRUD, versions, deploy, sync/stream runs, handoff E2E."""
|
|
|
|
import json
|
|
from uuid import UUID, uuid4
|
|
|
|
import pytest
|
|
|
|
from app.flows.sync import lfx_id_for
|
|
|
|
from .conftest import load_flow_fixture
|
|
|
|
CALLBACK_HEADERS = {"X-Callback-Token": "test-callback-token"}
|
|
|
|
|
|
def load_fixture(name: str) -> dict:
|
|
"""Alias of the shared conftest helper (kept for call-site stability)."""
|
|
return load_flow_fixture(name)
|
|
|
|
|
|
async def _create_flow(client, name="regeltest"):
|
|
response = await client.post("/api/flows", json={"name": name, "description": "test flow"})
|
|
assert response.status_code == 201, response.text
|
|
return response.json()
|
|
|
|
|
|
async def _create_version(client, flow_id, fixture="simple_rule.json"):
|
|
response = await client.post(
|
|
f"/api/flows/{flow_id}/versions",
|
|
json={"langflow_json": load_fixture(fixture), "author": "test"},
|
|
)
|
|
assert response.status_code == 201, response.text
|
|
return response.json()
|
|
|
|
|
|
async def _deploy(client, flow_id, version_no=1):
|
|
response = await client.post(f"/api/flows/{flow_id}/versions/{version_no}/deploy")
|
|
assert response.status_code == 200, response.text
|
|
return response.json()
|
|
|
|
|
|
async def test_flow_crud(client, login):
|
|
await login()
|
|
created = await _create_flow(client, "crud-flow")
|
|
assert created["status"] == "active"
|
|
|
|
response = await client.get(f"/api/flows/{created['id']}")
|
|
assert response.status_code == 200
|
|
assert response.json()["name"] == "crud-flow"
|
|
|
|
response = await client.get("/api/flows")
|
|
assert response.status_code == 200
|
|
assert any(f["id"] == created["id"] for f in response.json())
|
|
|
|
response = await client.post("/api/flows", json={"name": "crud-flow"})
|
|
assert response.status_code == 409
|
|
|
|
response = await client.get(f"/api/flows/{uuid4()}")
|
|
assert response.status_code == 404
|
|
|
|
|
|
MANAGEMENT_PROBES = [
|
|
("POST", "/api/flows", {"name": "x"}),
|
|
("POST", "/api/flows/sync", None),
|
|
("POST", "/api/flows/{flow_id}/versions", {"langflow_json": {}}),
|
|
("GET", "/api/flows/{flow_id}/versions", None),
|
|
("POST", "/api/flows/{flow_id}/versions/1/deploy", None),
|
|
("POST", "/api/flows/import", {"langflow_flow_id": "x"}),
|
|
("GET", "/api/flows/langflow/available", None),
|
|
]
|
|
|
|
|
|
@pytest.mark.parametrize(("method", "path", "body"), MANAGEMENT_PROBES)
|
|
async def test_management_endpoints_require_admin(client, login, method, path, body):
|
|
"""Anonymous -> 401, member -> 403 (parametrized per endpoint)."""
|
|
flow_id = uuid4()
|
|
target = path.format(flow_id=flow_id)
|
|
if method == "POST":
|
|
response = await client.post(target, json=body or {})
|
|
else:
|
|
response = await client.get(target)
|
|
assert response.status_code == 401, (method, target, response.text)
|
|
|
|
await login(email="member@example.com", role="member")
|
|
if method == "POST":
|
|
response = await client.post(target, json=body or {})
|
|
else:
|
|
response = await client.get(target)
|
|
assert response.status_code == 403, (method, target, response.text)
|
|
|
|
|
|
LOGIN_GATED_PATHS = [
|
|
("POST", "/api/flows/{flow_id}/run", {"input": {}}),
|
|
("GET", "/api/flows/{flow_id}/runs", None),
|
|
("GET", "/api/flows/runs/{run_id}", None),
|
|
("GET", "/api/approvals", None),
|
|
("POST", "/api/approvals/{task_id}/decision", {"decision": "approve"}),
|
|
]
|
|
|
|
|
|
@pytest.mark.parametrize(("method", "path", "body"), LOGIN_GATED_PATHS)
|
|
async def test_runs_and_reads_require_login(client, login, anon_client, method, path, body):
|
|
"""Run, run reads and approval reads are login-gated (parametrized)."""
|
|
await login()
|
|
flow = await _create_flow(client, "gated")
|
|
target = path.format(flow_id=flow["id"], run_id=uuid4(), task_id=uuid4())
|
|
async for anon in anon_client():
|
|
if method == "POST":
|
|
response = await anon.post(target, json=body or {})
|
|
else:
|
|
response = await anon.get(target)
|
|
assert response.status_code == 401, (method, target, response.text)
|
|
|
|
|
|
async def test_version_validation_and_numbering(client, login):
|
|
await login()
|
|
flow = await _create_flow(client, "versioned")
|
|
|
|
v1 = await _create_version(client, flow["id"])
|
|
assert v1["version_no"] == 1
|
|
v2 = await _create_version(client, flow["id"])
|
|
assert v2["version_no"] == 2
|
|
assert v1["deployed_at"] is None
|
|
|
|
response = await client.post(
|
|
f"/api/flows/{flow['id']}/versions", json={"langflow_json": {"nope": 1}}
|
|
)
|
|
assert response.status_code == 422
|
|
|
|
response = await client.post(
|
|
f"/api/flows/{uuid4()}/versions",
|
|
json={"langflow_json": load_fixture("simple_rule.json")},
|
|
)
|
|
assert response.status_code == 404
|
|
|
|
response = await client.get(f"/api/flows/{flow['id']}/versions")
|
|
assert [v["version_no"] for v in response.json()] == [1, 2]
|
|
|
|
|
|
async def test_deploy_and_run_standard_and_sonderfall(client, lfx_server, login):
|
|
await login()
|
|
flow = await _create_flow(client, "dachsanierung")
|
|
await _create_version(client, flow["id"])
|
|
deployed = await _deploy(client, flow["id"])
|
|
assert deployed["lfx_flow_id"] == lfx_id_for(UUID(flow["id"]), 1)
|
|
assert deployed["deployed_at"] is not None
|
|
|
|
response = await client.post(
|
|
f"/api/flows/{flow['id']}/run",
|
|
json={"input": {"betrag": 120000, "schwelle": 200000}},
|
|
)
|
|
assert response.status_code == 200, response.text
|
|
run = response.json()
|
|
assert run["status"] == "completed"
|
|
assert "Standardfall" in run["output"]["result"]
|
|
assert run["finished_at"] is not None
|
|
|
|
response = await client.post(
|
|
f"/api/flows/{flow['id']}/run",
|
|
json={"input": {"betrag": 250000, "schwelle": 200000}},
|
|
)
|
|
assert response.status_code == 200, response.text
|
|
run = response.json()
|
|
assert run["status"] == "completed"
|
|
assert "Sonderfall" in run["output"]["result"]
|
|
|
|
response = await client.get(f"/api/flows/runs/{run['id']}")
|
|
assert response.status_code == 200
|
|
assert response.json()["id"] == run["id"]
|
|
|
|
|
|
async def test_run_undeployed_version_fails(client, login):
|
|
await login()
|
|
flow = await _create_flow(client, "undeployed")
|
|
await _create_version(client, flow["id"])
|
|
response = await client.post(f"/api/flows/{flow['id']}/run", json={"input": {"betrag": 1}})
|
|
assert response.status_code == 422
|
|
|
|
|
|
async def test_run_explicit_version_no(client, lfx_server, login):
|
|
await login()
|
|
flow = await _create_flow(client, "explicit-version")
|
|
await _create_version(client, flow["id"])
|
|
await _create_version(client, flow["id"])
|
|
await _deploy(client, flow["id"], version_no=2)
|
|
response = await client.post(
|
|
f"/api/flows/{flow['id']}/run",
|
|
json={"input": {"betrag": 1}, "version_no": 2},
|
|
)
|
|
assert response.status_code == 200, response.text
|
|
assert response.json()["status"] == "completed"
|
|
|
|
|
|
async def test_run_stream_events(client, lfx_server, login):
|
|
await login()
|
|
flow = await _create_flow(client, "streamed")
|
|
await _create_version(client, flow["id"])
|
|
await _deploy(client, flow["id"])
|
|
|
|
events: list[tuple[str, str]] = []
|
|
async with client.stream(
|
|
"POST",
|
|
f"/api/flows/{flow['id']}/run?stream=true",
|
|
json={"input": {"betrag": 1}},
|
|
) as response:
|
|
assert response.status_code == 200, await response.aread()
|
|
current_event = "message"
|
|
async for line in response.aiter_lines():
|
|
if line.startswith("event:"):
|
|
current_event = line.split(":", 1)[1].strip()
|
|
elif line.startswith("data:"):
|
|
events.append((current_event, line.split(":", 1)[1].strip()))
|
|
current_event = "message"
|
|
|
|
assert events, "expected SSE events"
|
|
by_type = {}
|
|
for event, data in events:
|
|
by_type.setdefault(event, []).append(json.loads(data))
|
|
assert "run_id" in by_type["run"][0]
|
|
finished = [p for p in by_type.get("run_finished", []) if "status" in p]
|
|
assert finished and finished[0]["status"] == "completed"
|
|
first_payload = by_type["run"][0]
|
|
|
|
response = await client.get(f"/api/flows/runs/{first_payload['run_id']}")
|
|
assert response.status_code == 200
|
|
assert response.json()["status"] == "completed"
|
|
|
|
|
|
async def test_approval_handoff_end_to_end(client, lfx_server, live_server, login, monkeypatch):
|
|
from app.settings import settings
|
|
|
|
await login()
|
|
monkeypatch.setattr(settings, "callback_base_url", live_server)
|
|
flow = await _create_flow(client, "freigabe")
|
|
await _create_version(client, flow["id"], fixture="approval_handoff.json")
|
|
await _deploy(client, flow["id"])
|
|
|
|
response = await client.post(
|
|
f"/api/flows/{flow['id']}/run",
|
|
json={"input": {"betrag": 250000, "schwelle": 200000}},
|
|
)
|
|
assert response.status_code == 200, response.text
|
|
run = response.json()
|
|
assert run["status"] == "pending_approval"
|
|
assert "PENDING_HUMAN_APPROVAL" in run["output"]["result"]
|
|
|
|
response = await client.get("/api/approvals")
|
|
assert response.status_code == 200
|
|
tasks = response.json()
|
|
assert len(tasks) == 1
|
|
task = tasks[0]
|
|
assert task["run_id"] == run["id"]
|
|
assert task["status"] == "open"
|
|
assert task["payload"]["facts"]["betrag"] == 250000
|
|
|
|
|
|
async def test_approval_handoff_auto_creates_no_task(
|
|
client, lfx_server, live_server, login, monkeypatch
|
|
):
|
|
from app.settings import settings
|
|
|
|
await login()
|
|
monkeypatch.setattr(settings, "callback_base_url", live_server)
|
|
flow = await _create_flow(client, "freigabe-auto")
|
|
await _create_version(client, flow["id"], fixture="approval_handoff.json")
|
|
await _deploy(client, flow["id"])
|
|
|
|
response = await client.post(
|
|
f"/api/flows/{flow['id']}/run",
|
|
json={"input": {"betrag": 50000, "schwelle": 200000}},
|
|
)
|
|
assert response.status_code == 200, response.text
|
|
assert response.json()["status"] == "completed"
|
|
|
|
response = await client.get("/api/approvals")
|
|
assert response.status_code == 200
|
|
assert response.json() == []
|
|
|
|
|
|
async def test_approval_callback_contract(client, session, login):
|
|
from uuid import UUID
|
|
|
|
from app.flows import store as flow_store
|
|
|
|
await login()
|
|
flow = await _create_flow(client, "callback-target")
|
|
version = await _create_version(client, flow["id"])
|
|
version_obj = flow_store.get_version(session, UUID(version["id"]))
|
|
|
|
run = flow_store.create_run(session, version_obj, input={}, tenant_id="default")
|
|
|
|
response = await client.post(
|
|
"/api/approvals",
|
|
json={"run_id": str(run.id), "title": "bitte pruefen", "payload": {"x": 1}},
|
|
headers=CALLBACK_HEADERS,
|
|
)
|
|
assert response.status_code == 201, response.text
|
|
task = response.json()
|
|
assert task["status"] == "open"
|
|
assert task["tenant_id"] == "default"
|
|
assert task["version_id"] == version["id"]
|
|
|
|
response = await client.get(f"/api/flows/runs/{run.id}", headers=CALLBACK_HEADERS)
|
|
assert response.json()["status"] == "pending_approval"
|
|
|
|
response = await client.post(
|
|
"/api/approvals", json={"run_id": str(run.id)}, headers=CALLBACK_HEADERS
|
|
)
|
|
assert response.status_code == 409
|
|
|
|
run2 = flow_store.create_run(session, version_obj, input={}, tenant_id="default")
|
|
response = await client.post(
|
|
"/api/approvals",
|
|
json={"run_id": str(run2.id), "continuation_flow_id": str(uuid4())},
|
|
headers=CALLBACK_HEADERS,
|
|
)
|
|
assert response.status_code == 422
|
|
|
|
response = await client.post(
|
|
"/api/approvals", json={"run_id": str(uuid4())}, headers=CALLBACK_HEADERS
|
|
)
|
|
assert response.status_code == 404
|
|
|
|
|
|
async def test_approval_callback_token_required(client, session, login):
|
|
"""Forged callbacks without the shared token are rejected (C)."""
|
|
from uuid import UUID
|
|
|
|
from app.flows import store as flow_store
|
|
|
|
await login()
|
|
flow = await _create_flow(client, "callback-gated")
|
|
version = await _create_version(client, flow["id"])
|
|
version_obj = flow_store.get_version(session, UUID(version["id"]))
|
|
run = flow_store.create_run(session, version_obj, input={}, tenant_id="default")
|
|
|
|
response = await client.post("/api/approvals", json={"run_id": str(run.id)})
|
|
assert response.status_code == 401
|
|
|
|
response = await client.post(
|
|
"/api/approvals",
|
|
json={"run_id": str(run.id)},
|
|
headers={"X-Callback-Token": "wrong"},
|
|
)
|
|
assert response.status_code == 401
|