fix: authorize flow management, gate runs, token-gate callbacks, strict tenants
This commit is contained in:
parent
cfcf0e8bd6
commit
94e5e53092
21 changed files with 662 additions and 160 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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": "",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"}]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue