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

@ -17,6 +17,8 @@ TEST_AREA = Path(tempfile.mkdtemp(prefix="kirche-tests-"))
# Must precede all `app.*` imports: Settings is instantiated at import time.
os.environ["DATABASE_URL"] = f"sqlite:///{TEST_AREA / 'test.db'}"
os.environ["FLOWS_DIR"] = str(TEST_AREA / "flows")
# Flow callbacks (POST /api/approvals) authenticate with this token.
os.environ["CALLBACK_TOKEN"] = "test-callback-token"
# The embedded watcher + startup sync stay off in tests; sync is exercised
# directly (sync_from_directory) with per-test tmp dirs instead.
os.environ["FLOWS_WATCH_ENABLED"] = "false"
@ -81,6 +83,48 @@ async def client():
yield c
@pytest.fixture()
def login(client, session):
"""Log the test client in as a fresh user: ``await login()`` (admin)
or ``await login(role="member", tenant_id="pfarramt-a")``."""
async def _login(
email: str = "admin@example.com",
role: str = "admin",
tenant_id: str = "default",
):
from datetime import UTC, datetime, timedelta
from uuid import uuid4
from sqlmodel import select
from app.auth.deps import SESSION_COOKIE
from app.models import User, UserSession
# Idempotent: re-login updates the existing row (tests switch
# identity mid-test by calling login() again).
user = session.exec(select(User).where(User.email == email)).first()
if user is None:
user = User(name="Test", email=email, role=role, tenant_id=tenant_id)
session.add(user)
else:
user.role = role
user.tenant_id = tenant_id
session.add(user)
session.flush()
user_session = UserSession(
id=f"test-session-{uuid4().hex}",
user_id=user.id,
expires_at=datetime.now(UTC) + timedelta(days=1),
)
session.add(user_session)
session.commit()
client.cookies.set(SESSION_COOKIE, user_session.id)
return user
return _login
@pytest.fixture(scope="session")
def lfx_server(tmp_path_factory):
"""Real `lfx serve` subprocess on a free port; points settings at it.

View file

@ -292,7 +292,7 @@
"list": false,
"show": true,
"multiline": true,
"value": "from lfx.custom import Component\nfrom lfx.io import MessageTextInput, Output\nfrom lfx.schema.message import Message\nimport json\n\n\nclass EnvelopeComponent(Component):\n display_name = \"Envelope\"\n description = \"Zerlegt die Eingabe-Huelle in Payload und Laufzeit-Kontext.\"\n\n inputs = [\n MessageTextInput(name=\"raw\", display_name=\"Raw Input\"),\n ]\n outputs = [\n Output(display_name=\"Payload\", name=\"payload\", method=\"get_payload\"),\n Output(display_name=\"Run ID\", name=\"run_id\", method=\"get_run_id\"),\n Output(\n display_name=\"Callback URL\",\n name=\"callback_url\",\n method=\"get_callback_url\",\n ),\n Output(\n display_name=\"Continuation\",\n name=\"continuation\",\n method=\"get_continuation\",\n ),\n ]\n\n def _text(self, value) -> str:\n if value is None:\n return \"\"\n if hasattr(value, \"text\"):\n return value.text or \"\"\n return str(value)\n\n def _env(self) -> dict:\n if not hasattr(self, \"_env_cache\"):\n try:\n self._env_cache = json.loads(self._text(self.raw) or \"{}\")\n except Exception:\n self._env_cache = {}\n if not isinstance(self._env_cache, dict):\n self._env_cache = {}\n return self._env_cache\n\n def get_payload(self) -> Message:\n return Message(text=json.dumps(self._env().get(\"payload\", {})))\n\n def get_run_id(self) -> Message:\n return Message(text=str(self._env().get(\"run_id\", \"\")))\n\n def get_callback_url(self) -> Message:\n return Message(text=str(self._env().get(\"callback_url\", \"\")))\n\n def get_continuation(self) -> Message:\n value = self._env().get(\"continuation_flow_id\")\n return Message(text=str(value) if value else \"\")\n",
"value": "from lfx.custom import Component\nfrom lfx.io import MessageTextInput, Output\nfrom lfx.schema.message import Message\nimport json\n\n\nclass EnvelopeComponent(Component):\n display_name = \"Envelope\"\n description = \"Zerlegt die Eingabe-Huelle in Payload und Laufzeit-Kontext.\"\n\n inputs = [\n MessageTextInput(name=\"raw\", display_name=\"Raw Input\"),\n ]\n outputs = [\n Output(display_name=\"Payload\", name=\"payload\", method=\"get_payload\"),\n Output(display_name=\"Run ID\", name=\"run_id\", method=\"get_run_id\"),\n Output(\n display_name=\"Callback URL\",\n name=\"callback_url\",\n method=\"get_callback_url\",\n ),\n Output(\n display_name=\"Continuation\",\n name=\"continuation\",\n method=\"get_continuation\",\n ),\n Output(\n display_name=\"Callback Token\",\n name=\"callback_token\",\n method=\"get_callback_token\",\n ),\n ]\n\n def _text(self, value) -> str:\n if value is None:\n return \"\"\n if hasattr(value, \"text\"):\n return value.text or \"\"\n return str(value)\n\n def _env(self) -> dict:\n if not hasattr(self, \"_env_cache\"):\n try:\n self._env_cache = json.loads(self._text(self.raw) or \"{}\")\n except Exception:\n self._env_cache = {}\n if not isinstance(self._env_cache, dict):\n self._env_cache = {}\n return self._env_cache\n\n def get_payload(self) -> Message:\n return Message(text=json.dumps(self._env().get(\"payload\", {})))\n\n def get_run_id(self) -> Message:\n return Message(text=str(self._env().get(\"run_id\", \"\")))\n\n def get_callback_url(self) -> Message:\n return Message(text=str(self._env().get(\"callback_url\", \"\")))\n\n def get_continuation(self) -> Message:\n value = self._env().get(\"continuation_flow_id\")\n return Message(text=str(value) if value else \"\")\n\n def get_callback_token(self) -> Message:\n return Message(text=str(self._env().get(\"callback_token\", \"\")))\n",
"fileTypes": [],
"file_path": "",
"password": false,
@ -399,6 +399,20 @@
"allows_loop": false,
"group_outputs": false,
"tool_mode": true
},
{
"types": [
"Message"
],
"selected": "Message",
"name": "callback_token",
"display_name": "Callback Token",
"method": "get_callback_token",
"value": "__UNDEFINED__",
"cache": true,
"allows_loop": false,
"group_outputs": false,
"tool_mode": true
}
],
"field_order": [
@ -409,7 +423,7 @@
"edited": false,
"metadata": {
"module": "custom_components.envelope",
"code_hash": "2b0bd3c3d529",
"code_hash": "02e69613e210",
"dependencies": {
"total_dependencies": 1,
"dependencies": [
@ -858,6 +872,32 @@
"node": {
"template": {
"_type": "Component",
"callback_token": {
"tool_mode": false,
"trace_as_input": true,
"trace_as_metadata": true,
"load_from_db": false,
"list": false,
"list_add_label": "Add More",
"override_skip": false,
"required": false,
"placeholder": "",
"show": true,
"name": "callback_token",
"value": "",
"display_name": "Callback Token",
"advanced": false,
"api_editable": false,
"input_types": [
"Message"
],
"dynamic": false,
"info": "",
"title_case": false,
"track_in_telemetry": false,
"type": "str",
"_input_type": "MessageTextInput"
},
"callback_url": {
"tool_mode": false,
"trace_as_input": true,
@ -891,7 +931,7 @@
"list": false,
"show": true,
"multiline": true,
"value": "from lfx.custom import Component\nfrom lfx.io import MessageTextInput, Output\nfrom lfx.schema.message import Message\n\n\nclass ApprovalCallbackComponent(Component):\n display_name = \"Approval Callback\"\n description = \"Meldet einen Sonderfall per HTTP an unsere API und terminiert.\"\n\n inputs = [\n MessageTextInput(name=\"trigger\", display_name=\"Trigger\"),\n MessageTextInput(name=\"run_id\", display_name=\"Run ID\"),\n MessageTextInput(name=\"callback_url\", display_name=\"Callback URL\"),\n MessageTextInput(\n name=\"continuation_flow_id\",\n display_name=\"Continuation Flow ID\",\n ),\n MessageTextInput(name=\"facts\", display_name=\"Facts\"),\n ]\n outputs = [\n Output(display_name=\"Result\", name=\"result\", method=\"notify\"),\n ]\n\n def _text(self, value) -> str:\n if value is None:\n return \"\"\n if hasattr(value, \"text\"):\n return value.text or \"\"\n return str(value)\n\n def notify(self) -> Message:\n import httpx\n import json\n\n trigger = self._text(self.trigger).strip()\n if not trigger:\n # Zweig wurde nicht genommen: kein Seiteneffekt.\n return Message(text=\"\")\n try:\n facts = json.loads(self._text(self.facts) or \"{}\")\n except Exception:\n facts = {}\n url = self._text(self.callback_url).rstrip(\"/\") + \"/api/approvals\"\n body = {\n \"run_id\": self._text(self.run_id),\n \"title\": f\"Freigabe erforderlich ({trigger})\",\n \"payload\": {\"verdict\": trigger, \"facts\": facts},\n \"continuation_flow_id\": self._text(self.continuation_flow_id) or None,\n }\n try:\n response = httpx.post(url, json=body, timeout=10.0)\n response.raise_for_status()\n except Exception as exc:\n return Message(text=f\"CALLBACK_FAILED: {exc}\")\n return Message(text=\"PENDING_HUMAN_APPROVAL\")\n",
"value": "from lfx.custom import Component\nfrom lfx.io import MessageTextInput, Output\nfrom lfx.schema.message import Message\n\n\nclass ApprovalCallbackComponent(Component):\n display_name = \"Approval Callback\"\n description = \"Meldet einen Sonderfall per HTTP an unsere API und terminiert.\"\n\n inputs = [\n MessageTextInput(name=\"trigger\", display_name=\"Trigger\"),\n MessageTextInput(name=\"run_id\", display_name=\"Run ID\"),\n MessageTextInput(name=\"callback_url\", display_name=\"Callback URL\"),\n MessageTextInput(\n name=\"callback_token\",\n display_name=\"Callback Token\",\n ),\n MessageTextInput(\n name=\"continuation_flow_id\",\n display_name=\"Continuation Flow ID\",\n ),\n MessageTextInput(name=\"facts\", display_name=\"Facts\"),\n ]\n outputs = [\n Output(display_name=\"Result\", name=\"result\", method=\"notify\"),\n ]\n\n def _text(self, value) -> str:\n if value is None:\n return \"\"\n if hasattr(value, \"text\"):\n return value.text or \"\"\n return str(value)\n\n def notify(self) -> Message:\n import httpx\n import json\n\n trigger = self._text(self.trigger).strip()\n if not trigger:\n # Zweig wurde nicht genommen: kein Seiteneffekt.\n return Message(text=\"\")\n try:\n facts = json.loads(self._text(self.facts) or \"{}\")\n except Exception:\n facts = {}\n url = self._text(self.callback_url).rstrip(\"/\") + \"/api/approvals\"\n body = {\n \"run_id\": self._text(self.run_id),\n \"title\": f\"Freigabe erforderlich ({trigger})\",\n \"payload\": {\"verdict\": trigger, \"facts\": facts},\n \"continuation_flow_id\": self._text(self.continuation_flow_id) or None,\n }\n headers = {\"X-Callback-Token\": self._text(self.callback_token)}\n try:\n response = httpx.post(url, json=body, headers=headers, timeout=10.0)\n response.raise_for_status()\n except Exception as exc:\n return Message(text=f\"CALLBACK_FAILED: {exc}\")\n return Message(text=\"PENDING_HUMAN_APPROVAL\")\n",
"fileTypes": [],
"file_path": "",
"password": false,
@ -1040,6 +1080,7 @@
"trigger",
"run_id",
"callback_url",
"callback_token",
"continuation_flow_id",
"facts"
],
@ -1048,7 +1089,7 @@
"edited": false,
"metadata": {
"module": "custom_components.approval_callback",
"code_hash": "a334376410f7",
"code_hash": "28b2f5894566",
"dependencies": {
"total_dependencies": 2,
"dependencies": [
@ -1804,6 +1845,34 @@
"target": "CustomComponent-callback",
"targetHandle": "{œfieldNameœ: œcallback_urlœ, œidœ: œCustomComponent-callbackœ, œinputTypesœ: [œMessageœ], œtypeœ: œstrœ}"
},
{
"animated": false,
"className": "",
"data": {
"sourceHandle": {
"dataType": "CustomComponent",
"id": "CustomComponent-envelope",
"name": "callback_token",
"output_types": [
"Message"
]
},
"targetHandle": {
"fieldName": "callback_token",
"id": "CustomComponent-callback",
"inputTypes": [
"Message"
],
"type": "str"
}
},
"id": "xy-edge__{œdataTypeœ: œCustomComponentœ, œidœ: œCustomComponent-envelopeœ, œnameœ: œcallback_tokenœ, œoutput_typesœ: [œMessageœ]}-{œfieldNameœ: œcallback_tokenœ, œidœ: œCustomComponent-callbackœ, œinputTypesœ: [œMessageœ], œtypeœ: œstrœ}",
"selected": false,
"source": "CustomComponent-envelope",
"sourceHandle": "{œdataTypeœ: œCustomComponentœ, œidœ: œCustomComponent-envelopeœ, œnameœ: œcallback_tokenœ, œoutput_typesœ: [œMessageœ]}",
"target": "CustomComponent-callback",
"targetHandle": "{œfieldNameœ: œcallback_tokenœ, œidœ: œCustomComponent-callbackœ, œinputTypesœ: [œMessageœ], œtypeœ: œstrœ}"
},
{
"animated": false,
"className": "",

View file

@ -30,16 +30,14 @@ async def _version_and_deploy(client, flow_id, fixture="simple_rule.json"):
return response.json()
async def _handoff_task(client, live_server, continuation_flow_id=None):
async def _handoff_task(client, live_server, monkeypatch):
"""Run approval_handoff over threshold; returns (run, task)."""
from app.settings import settings
monkeypatch.setattr(settings, "callback_base_url", live_server)
flow = await _create_flow(client, f"handoff-{uuid4().hex[:8]}")
await _version_and_deploy(client, flow["id"], fixture="approval_handoff.json")
body = {
"input": {"betrag": 250000, "schwelle": 200000},
"callback_base_url": live_server,
}
if continuation_flow_id is not None:
body["continuation_flow_id"] = continuation_flow_id
body = {"input": {"betrag": 250000, "schwelle": 200000}}
run = (await client.post(f"/api/flows/{flow['id']}/run", json=body)).json()
assert run["status"] == "pending_approval"
tasks = (await client.get("/api/approvals")).json()
@ -47,30 +45,55 @@ async def _handoff_task(client, live_server, continuation_flow_id=None):
return run, task
async def test_approve_without_continuation(client, lfx_server, live_server):
_, task = await _handoff_task(client, live_server)
async def test_approve_without_continuation(client, lfx_server, live_server, login, monkeypatch):
await login()
_, task = await _handoff_task(client, live_server, monkeypatch)
response = await client.post(
f"/api/approvals/{task['id']}/decision",
json={"decision": "approve", "approver": "sachbearbeiter", "note": "ok"},
json={"decision": "approve", "note": "ok"},
)
assert response.status_code == 200, response.text
body = response.json()
assert body["task"]["status"] == "approved"
assert body["task"]["approver"] == "sachbearbeiter"
assert body["task"]["approver"] == "admin@example.com"
assert body["task"]["decided_at"] is not None
assert body["continuation_run"] is None
async def test_approve_with_continuation_runs_facts(client, lfx_server, live_server):
async def test_approve_with_continuation_runs_facts(
client, lfx_server, live_server, login, monkeypatch
):
await login()
cont = await _create_flow(client, f"cont-{uuid4().hex[:8]}")
await _version_and_deploy(client, cont["id"], fixture="simple_rule.json")
_, task = await _handoff_task(client, live_server, continuation_flow_id=cont["id"])
# Continuation is authoring-time wiring on the definition (admin-only);
# run bodies cannot set it.
response = await client.post(
"/api/flows",
json={"name": f"handoff-{uuid4().hex[:8]}", "continuation_flow_id": cont["id"]},
)
assert response.status_code == 201, response.text
flow = response.json()
await _version_and_deploy(client, flow["id"], fixture="approval_handoff.json")
from app.settings import settings
monkeypatch.setattr(settings, "callback_base_url", live_server)
run = (
await client.post(
f"/api/flows/{flow['id']}/run",
json={"input": {"betrag": 250000, "schwelle": 200000}},
)
).json()
assert run["status"] == "pending_approval"
tasks = (await client.get("/api/approvals")).json()
task = next(t for t in tasks if t["run_id"] == run["id"])
assert task["continuation_flow_id"] == cont["id"]
response = await client.post(
f"/api/approvals/{task['id']}/decision",
json={"decision": "approve", "approver": "chef"},
json={"decision": "approve"},
)
assert response.status_code == 200, response.text
body = response.json()
@ -83,21 +106,51 @@ async def test_approve_with_continuation_runs_facts(client, lfx_server, live_ser
assert "Sonderfall" in cont_run["output"]["result"]
async def test_reject_task(client, lfx_server, live_server):
_, task = await _handoff_task(client, live_server)
async def test_client_continuation_is_ignored(client, lfx_server, live_server, login, monkeypatch):
"""A run body cannot chain arbitrary flows (G)."""
from app.settings import settings
await login()
monkeypatch.setattr(settings, "callback_base_url", live_server)
cont = await _create_flow(client, f"cont-{uuid4().hex[:8]}")
await _version_and_deploy(client, cont["id"], fixture="simple_rule.json")
flow = await _create_flow(client, f"handoff-{uuid4().hex[:8]}")
await _version_and_deploy(client, flow["id"], fixture="approval_handoff.json")
run = (
await client.post(
f"/api/flows/{flow['id']}/run",
json={
"input": {"betrag": 250000, "schwelle": 200000},
"continuation_flow_id": cont["id"],
},
)
).json()
assert run["status"] == "pending_approval"
tasks = (await client.get("/api/approvals")).json()
task = next(t for t in tasks if t["run_id"] == run["id"])
assert task["continuation_flow_id"] is None
async def test_reject_task(client, lfx_server, live_server, login, monkeypatch):
await login()
_, task = await _handoff_task(client, live_server, monkeypatch)
# Any logged-in user (not just admins) can decide; identity is the session.
await login(email="chef@example.com", role="member")
response = await client.post(
f"/api/approvals/{task['id']}/decision",
json={"decision": "reject", "approver": "chef", "note": "zu teuer"},
json={"decision": "reject", "note": "zu teuer"},
)
assert response.status_code == 200, response.text
body = response.json()
assert body["task"]["status"] == "rejected"
assert body["task"]["approver"] == "chef@example.com"
assert body["task"]["decision_note"] == "zu teuer"
assert body["continuation_run"] is None
async def test_decision_guards(client, lfx_server, live_server):
_, task = await _handoff_task(client, live_server)
async def test_decision_guards(client, lfx_server, live_server, login, monkeypatch):
await login()
_, task = await _handoff_task(client, live_server, monkeypatch)
url = f"/api/approvals/{task['id']}/decision"
response = await client.post(url, json={"decision": "approve"})
@ -108,7 +161,7 @@ async def test_decision_guards(client, lfx_server, live_server):
response = await client.post(f"/api/approvals/{uuid4()}/decision", json={"decision": "approve"})
assert response.status_code == 404
_, task2 = await _handoff_task(client, live_server)
_, task2 = await _handoff_task(client, live_server, monkeypatch)
response = await client.post(
f"/api/approvals/{task2['id']}/decision", json={"decision": "maybe"}
)
@ -122,9 +175,10 @@ async def test_decision_guards(client, lfx_server, live_server):
assert response.status_code == 200
async def test_decision_bad_continuation_reference(client, session):
async def test_decision_bad_continuation_reference(client, session, login):
from app.flows import store as flow_store
await login()
flow = await _create_flow(client, f"badcont-{uuid4().hex[:8]}")
version = (
await client.post(
@ -147,39 +201,45 @@ async def test_decision_bad_continuation_reference(client, session):
assert response.status_code == 422
async def test_tenant_isolation(client, lfx_server):
async def test_tenant_isolation(client, lfx_server, login):
"""Strict session scoping: explicit ?tenant_id= cannot widen reads (E)."""
await login()
flow = await _create_flow(client, f"tenant-{uuid4().hex[:8]}")
await _version_and_deploy(client, flow["id"])
for tenant in ("pfarramt-a", "pfarramt-b"):
response = await client.post(
f"/api/flows/{flow['id']}/run",
json={"input": {"betrag": 1}, "tenant_id": tenant},
)
assert response.status_code == 200
await login(email="a@example.com", role="member", tenant_id="pfarramt-a")
response = await client.post(f"/api/flows/{flow['id']}/run", json={"input": {"betrag": 1}})
assert response.status_code == 200
run_a = response.json()
assert run_a["tenant_id"] == "pfarramt-a"
await login(email="b@example.com", role="member", tenant_id="pfarramt-b")
response = await client.post(f"/api/flows/{flow['id']}/run", json={"input": {"betrag": 1}})
assert response.status_code == 200
# A forged tenant_id query param is ignored: b sees only b's runs.
response = await client.get(f"/api/flows/{flow['id']}/runs", params={"tenant_id": "pfarramt-a"})
runs_a = response.json()
assert len(runs_a) == 1
assert runs_a[0]["tenant_id"] == "pfarramt-a"
runs_b = response.json()
assert len(runs_b) == 1
assert runs_b[0]["tenant_id"] == "pfarramt-b"
response = await client.get(f"/api/flows/{flow['id']}/runs")
assert len(response.json()) == 2
# Cross-tenant read with scoping 404s.
response = await client.get(
f"/api/flows/runs/{runs_a[0]['id']}", params={"tenant_id": "pfarramt-b"}
)
# Cross-tenant run read 404s with or without the forged param.
response = await client.get(f"/api/flows/runs/{run_a['id']}")
assert response.status_code == 404
response = await client.get(
f"/api/flows/runs/{runs_a[0]['id']}", params={"tenant_id": "pfarramt-a"}
f"/api/flows/runs/{run_a['id']}", params={"tenant_id": "pfarramt-a"}
)
assert response.status_code == 404
await login(email="a@example.com", role="member", tenant_id="pfarramt-a")
response = await client.get(f"/api/flows/runs/{run_a['id']}")
assert response.status_code == 200
async def test_lfx_down_maps_to_503(client, lfx_server, monkeypatch):
async def test_lfx_down_maps_to_503(client, lfx_server, monkeypatch, login):
from app.settings import settings
await login()
flow = await _create_flow(client, f"down-{uuid4().hex[:8]}")
await client.post(
f"/api/flows/{flow['id']}/versions",
@ -198,10 +258,11 @@ async def test_lfx_down_maps_to_503(client, lfx_server, monkeypatch):
assert response.status_code == 503
async def test_lfx_drift_maps_to_502(client, lfx_server):
async def test_lfx_drift_maps_to_502(client, lfx_server, login):
from app.flows.lfx_client import LfxClient
from app.settings import settings
await login()
flow = await _create_flow(client, f"drift-{uuid4().hex[:8]}")
deployed = await _version_and_deploy(client, flow["id"])
await LfxClient(settings.lfx_url, api_key=settings.lfx_api_key or None).delete_flow(
@ -211,7 +272,8 @@ async def test_lfx_drift_maps_to_502(client, lfx_server):
assert response.status_code == 502
async def test_corrupt_flow_json_deploy_maps_to_422(client, lfx_server):
async def test_corrupt_flow_json_deploy_maps_to_422(client, lfx_server, login):
await login()
flow = await _create_flow(client, f"corrupt-{uuid4().hex[:8]}")
bogus = {
"name": "bogus",

View file

@ -24,8 +24,11 @@ def _export_doc(name="importiert"):
}
async def test_import_creates_definition_version_and_deploys(client, lfx_server, override_langflow):
async def test_import_creates_definition_version_and_deploys(
client, login, lfx_server, override_langflow
):
override_langflow(_export_doc())
await login()
response = await client.post(
"/api/flows/import",
json={"langflow_flow_id": LANGFLOW_ID, "author": "admin"},
@ -46,8 +49,9 @@ async def test_import_creates_definition_version_and_deploys(client, lfx_server,
assert response.json()["status"] == "completed"
async def test_import_same_content_is_idempotent(client, lfx_server, override_langflow):
async def test_import_same_content_is_idempotent(client, login, lfx_server, override_langflow):
override_langflow(_export_doc())
await login()
first = await client.post("/api/flows/import", json={"langflow_flow_id": LANGFLOW_ID})
second = await client.post("/api/flows/import", json={"langflow_flow_id": LANGFLOW_ID})
assert first.status_code == 201
@ -57,8 +61,9 @@ async def test_import_same_content_is_idempotent(client, lfx_server, override_la
assert second.json()["version"]["version_no"] == 1
async def test_import_changed_content_bumps_version(client, lfx_server, override_langflow):
async def test_import_changed_content_bumps_version(client, login, lfx_server, override_langflow):
override_langflow(_export_doc())
await login()
first = await client.post("/api/flows/import", json={"langflow_flow_id": LANGFLOW_ID})
assert first.status_code == 201
changed = _export_doc()
@ -70,12 +75,13 @@ async def test_import_changed_content_bumps_version(client, lfx_server, override
async def test_import_writes_canonical_file(
client, lfx_server, override_langflow, tmp_path, monkeypatch
client, login, lfx_server, override_langflow, tmp_path, monkeypatch
):
from app.settings import settings
monkeypatch.setattr(settings, "flows_dir", str(tmp_path))
override_langflow(_export_doc())
await login()
response = await client.post("/api/flows/import", json={"langflow_flow_id": LANGFLOW_ID})
assert response.status_code == 201, response.text
written = tmp_path / "importiert.json"
@ -83,8 +89,9 @@ async def test_import_writes_canonical_file(
assert json.loads(written.read_text())["name"] == "importiert"
async def test_import_with_explicit_name(client, lfx_server, override_langflow):
async def test_import_with_explicit_name(client, login, lfx_server, override_langflow):
override_langflow(_export_doc(name="langflow-name"))
await login()
response = await client.post(
"/api/flows/import",
json={"langflow_flow_id": LANGFLOW_ID, "name": "wunschname"},
@ -93,8 +100,9 @@ async def test_import_with_explicit_name(client, lfx_server, override_langflow):
assert response.json()["definition"]["name"] == "wunschname"
async def test_import_without_deploy(client, override_langflow):
async def test_import_without_deploy(client, login, override_langflow):
override_langflow(_export_doc())
await login()
response = await client.post(
"/api/flows/import",
json={"langflow_flow_id": LANGFLOW_ID, "deploy": False},
@ -105,20 +113,23 @@ async def test_import_without_deploy(client, override_langflow):
assert body["version"]["deployed_at"] is None
async def test_import_langflow_not_found(client, override_langflow):
async def test_import_langflow_not_found(client, login, override_langflow):
override_langflow(status=404)
await login()
response = await client.post("/api/flows/import", json={"langflow_flow_id": LANGFLOW_ID})
assert response.status_code == 404
async def test_import_langflow_unreachable(client, override_langflow):
async def test_import_langflow_unreachable(client, login, override_langflow):
override_langflow(status=0)
await login()
response = await client.post("/api/flows/import", json={"langflow_flow_id": str(uuid4())})
assert response.status_code == 503
async def test_list_langflow_flows(client, override_langflow):
async def test_list_langflow_flows(client, login, override_langflow):
override_langflow(_export_doc())
await login()
response = await client.get("/api/flows/langflow/available")
assert response.status_code == 200
assert response.json() == [{"id": "fake-1", "name": "fake"}]

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

View file

@ -77,7 +77,8 @@ def test_intake_fixture_shape():
assert ("CustomInputForm-intake", "ChatOutput-collected") in edges
async def test_intake_deploy_and_run_with_values(client, lfx_server):
async def test_intake_deploy_and_run_with_values(client, lfx_server, login):
await login()
doc = json.loads((FIXTURES / "intake_form.json").read_text())
created = await client.post("/api/flows", json={"name": "intake-e2e"})
assert created.status_code == 201, created.text

View file

@ -170,9 +170,10 @@ async def test_sync_caches_schema_per_version(session, lfx_server, tmp_path):
assert "form_values" not in cached["json_schema"]["properties"]
async def test_input_schema_endpoint(client, lfx_server):
async def test_input_schema_endpoint(client, lfx_server, login):
assert (await client.get(f"/api/flows/{uuid4()}/input-schema")).status_code == 404
await login()
doc = json.loads((FIXTURES / "intake_form.json").read_text())
created = await client.post("/api/flows", json={"name": "schema-e2e"})
flow_id = created.json()["id"]

View file

@ -118,7 +118,8 @@ async def test_sync_rename_updates_definition_name(session, lfx_server, tmp_path
assert definition.name == "umbenannt"
async def test_sync_endpoint_returns_report(client, lfx_server, tmp_path, monkeypatch):
async def test_sync_endpoint_returns_report(client, lfx_server, tmp_path, monkeypatch, login):
await login()
monkeypatch.setattr(settings, "flows_dir", str(tmp_path))
_seed_dir(tmp_path)
response = await client.post("/api/flows/sync")