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