fix: authorize flow management, gate runs, token-gate callbacks, strict tenants

This commit is contained in:
Jonas Miederer 2026-09-14 20:12:17 +02:00
parent cfcf0e8bd6
commit 94e5e53092
21 changed files with 662 additions and 160 deletions

View file

@ -7,6 +7,7 @@ from uuid import UUID, uuid4
from app.flows.sync import lfx_id_for
FIXTURES = Path(__file__).parent / "fixtures" / "flows"
CALLBACK_HEADERS = {"X-Callback-Token": "test-callback-token"}
def load_fixture(name: str) -> dict:
@ -34,7 +35,8 @@ async def _deploy(client, flow_id, version_no=1):
return response.json()
async def test_flow_crud(client):
async def test_flow_crud(client, login):
await login()
created = await _create_flow(client, "crud-flow")
assert created["status"] == "active"
@ -53,7 +55,63 @@ async def test_flow_crud(client):
assert response.status_code == 404
async def test_version_validation_and_numbering(client):
async def test_management_endpoints_require_admin(client, login):
"""Anonymous -> 401, member -> 403, admin -> through (A)."""
flow_id = uuid4()
probes = [
("POST", "/api/flows", {"name": "x"}),
("POST", "/api/flows/sync", None),
("POST", f"/api/flows/{flow_id}/versions", {"langflow_json": {}}),
("GET", f"/api/flows/{flow_id}/versions", None),
("POST", f"/api/flows/{flow_id}/versions/1/deploy", None),
("POST", "/api/flows/import", {"langflow_flow_id": "x"}),
("GET", "/api/flows/langflow/available", None),
]
for method, path, body in probes:
if method == "POST":
response = await client.post(path, json=body or {})
else:
response = await client.get(path)
assert response.status_code == 401, (method, path, response.text)
await login(email="member@example.com", role="member")
for method, path, body in probes:
if method == "POST":
response = await client.post(path, json=body or {})
else:
response = await client.get(path)
assert response.status_code == 403, (method, path, response.text)
async def test_runs_and_reads_require_login(client, login):
"""Run, run reads and approval reads are login-gated (A)."""
await login()
flow = await _create_flow(client, "gated")
run_id = uuid4()
authed_paths = [
("POST", f"/api/flows/{flow['id']}/run", {"input": {}}),
("GET", f"/api/flows/{flow['id']}/runs", None),
("GET", f"/api/flows/runs/{run_id}", None),
("GET", "/api/approvals", None),
("POST", f"/api/approvals/{uuid4()}/decision", {"decision": "approve"}),
]
# Fresh client without a session cookie.
from httpx import ASGITransport, AsyncClient
from app.main import app
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as anon:
for method, path, body in authed_paths:
if method == "POST":
response = await anon.post(path, json=body or {})
else:
response = await anon.get(path)
assert response.status_code == 401, (method, path, 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"])
@ -77,7 +135,8 @@ async def test_version_validation_and_numbering(client):
assert [v["version_no"] for v in response.json()] == [1, 2]
async def test_deploy_and_run_standard_and_sonderfall(client, lfx_server):
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"])
@ -108,14 +167,16 @@ async def test_deploy_and_run_standard_and_sonderfall(client, lfx_server):
assert response.json()["id"] == run["id"]
async def test_run_undeployed_version_fails(client):
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):
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"])
@ -128,7 +189,8 @@ async def test_run_explicit_version_no(client, lfx_server):
assert response.json()["status"] == "completed"
async def test_run_stream_events(client, lfx_server):
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"])
@ -161,17 +223,18 @@ async def test_run_stream_events(client, lfx_server):
assert response.json()["status"] == "completed"
async def test_approval_handoff_end_to_end(client, lfx_server, live_server):
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},
"callback_base_url": live_server,
},
json={"input": {"betrag": 250000, "schwelle": 200000}},
)
assert response.status_code == 200, response.text
run = response.json()
@ -188,17 +251,20 @@ async def test_approval_handoff_end_to_end(client, lfx_server, live_server):
assert task["payload"]["facts"]["betrag"] == 250000
async def test_approval_handoff_auto_creates_no_task(client, lfx_server, live_server):
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},
"callback_base_url": live_server,
},
json={"input": {"betrag": 50000, "schwelle": 200000}},
)
assert response.status_code == 200, response.text
assert response.json()["status"] == "completed"
@ -208,11 +274,12 @@ async def test_approval_handoff_auto_creates_no_task(client, lfx_server, live_se
assert response.json() == []
async def test_approval_callback_contract(client, session):
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"]))
@ -222,6 +289,7 @@ async def test_approval_callback_contract(client, session):
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()
@ -229,18 +297,46 @@ async def test_approval_callback_contract(client, session):
assert task["tenant_id"] == "default"
assert task["version_id"] == version["id"]
response = await client.get(f"/api/flows/runs/{run.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)})
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())})
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