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
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue