fix: typer exit codes, JWT 7d expiry, shared test fixtures, AdminTab query, mantine 9.6.3

This commit is contained in:
Jonas Miederer 2026-09-30 22:27:58 +02:00
parent 03af41e837
commit 696db34c90
16 changed files with 210 additions and 251 deletions

View file

@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Auth: stateless JWT sessions (HS256, 30d, HttpOnly cookie unchanged) replace the `user_sessions` table (Alembic revision drops it; all sessions invalidated at deploy); slowapi run throttle replaces the hand-rolled sliding window; mail via jinja template + fastapi-mail (same copy/fallback contract); CLIs on typer; `JWT_SECRET` enforced by `check_prod.py`.
- Forms: thin variant components over shared `components/kirche/_form_core.py` (codegen-inlined by `kirche_node()` for LFX; all 5 flows regenerated + level-4 validated), structured `visible_if` key alongside legacy markers, `LangflowClient.upsert_flow` + `scripts/push_flows_to_langflow.py` (6 canonical flows pushed to Langflow UI).
- Store/sync: single `next_version_no` query per version, validation only at the `create_version` choke point, mtime-cached `.flowsignore`.
- Fix batch: typer CLIs exit non-zero via `typer.Exit` (`check_prod` gate works again); JWT expiry 30d→7d with startup warning and new expiry/deleted-user tests; deleted dead `resolve_tenant`, test alias helpers now delegate to `conftest` (flows/approvals/sync/deploy suites migrated to shared fixtures); `AdminTab` reads via react-query; Mantine aligned to 9.6.3 (consistent lockfile, `npm ci` green).
- Tests (full real LFX kept): shared `conftest` helpers + `tests/_harness.py` adopted by fixtures and smoke script, parametrized matrices (export stems, check_prod env, deploy failures, sync listing failures, API auth probes, input_schema types), relaxed brittle asserts (ui:order sets, per-flag cookie asserts, SSE any-match).
- Frontend: react-query reads + notifications, `useForm`+zod static forms, shared `DecisionButtons`/`ErrorAlert`/`JsonView`/`FormattedDate`/`RunsTable`/`Landing` blocks, new aggregate `GET /api/runs` kills the Tasks N+1, `Promise.all` version fetches, `sharp` icons, selector-based screenshots, dead photo removed.
### Added

View file

@ -36,11 +36,3 @@ def require_admin(user: Annotated[User, Depends(get_current_user)]) -> User:
if user.role != "admin":
raise HTTPException(status_code=403, detail="admin only")
return user
def resolve_tenant(user: User | None, explicit: str | None, default: str = "default") -> str:
"""Tenant comes from the session; the body/query param is a deprecated
fallback for unauthenticated callers."""
if user is not None:
return user.tenant_id
return explicit or default

View file

@ -4,9 +4,10 @@ Keep API handlers thin: all persistence lives here. Tokens and session
JWTs are bearer secrets — log user/request ids only, never secret values.
Sessions are stateless HS256 JWTs (sub=user.id, email, role, tenant_id,
iat, exp=30d, jti) in the HttpOnly session cookie. No session table:
logout clears the cookie; role/tenant changes apply at next login (or
immediately for deleted users — the user row is reloaded per request).
iat, exp=7d, jti) in the HttpOnly session cookie. No session table and no
revocation list: logout clears the cookie client-side, and a stolen token
is valid until its short expiry. Role/tenant changes apply at next login
(deleted users fail closed — the user row is reloaded per request).
"""
from __future__ import annotations
@ -28,7 +29,10 @@ from app.settings import settings
logger = logging.getLogger(__name__)
MAGIC_LINK_TTL = timedelta(minutes=15)
SESSION_TTL = timedelta(days=30)
# Session window is deliberately short: sessions are stateless JWTs with
# no server-side revocation (logout only clears the cookie), so a stolen
# token stays valid until expiry. 7 days bounds that window.
SESSION_TTL = timedelta(days=7)
# Public request endpoint: at most this many requests per email per hour.
REQUESTS_PER_EMAIL_PER_HOUR = 5

View file

@ -36,6 +36,10 @@ def _blocking_startup_sync() -> None:
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
task: asyncio.Task | None = None
stop: asyncio.Event | None = None
if not settings.jwt_secret:
# Fail-closed sessions need a signing secret; without one every
# login 500s. Warn loudly instead of crashing the whole API.
logger.error("JWT_SECRET is empty — logins will fail until it is set")
if settings.flows_watch_enabled:
await asyncio.to_thread(_blocking_startup_sync)
stop = asyncio.Event()

View file

@ -11,7 +11,6 @@ from app (stdlib only), prints no secret values.
from __future__ import annotations
import sys
from pathlib import Path
import typer
@ -89,12 +88,12 @@ def check(values: dict[str, str]) -> tuple[list[str], list[str]]:
return failures, warnings
def main(env: str = typer.Option(str(REPO_ROOT / ".env"), "--env")) -> int:
def main(env: str = typer.Option(str(REPO_ROOT / ".env"), "--env")) -> None:
"""Validate a prod .env file (typer CLI; check/parse_env stay importable)."""
path = Path(env)
if not path.is_file():
print(f"no env file at {path}")
return 1
raise typer.Exit(1)
failures, warnings = check(parse_env(path))
for message in warnings:
print(f"WARN: {message}")
@ -102,10 +101,9 @@ def main(env: str = typer.Option(str(REPO_ROOT / ".env"), "--env")) -> int:
print(f"FAIL: {message}")
if failures:
print(f"{len(failures)} blocking issue(s) — fix before deploying")
return 1
raise typer.Exit(1)
print("prod env OK" + (f" ({len(warnings)} warning(s))" if warnings else ""))
return 0
if __name__ == "__main__":
sys.exit(typer.run(main))
typer.run(main)

View file

@ -8,7 +8,6 @@ Usage:
from __future__ import annotations
import json
import sys
import httpx
import typer
@ -32,7 +31,7 @@ def main(
name: str | None = typer.Option(None, "--name"),
author: str = typer.Option("", "--author"),
no_deploy: bool = typer.Option(False, "--no-deploy"),
) -> int:
) -> None:
"""Import a flow from Langflow via the running API (post() stays importable)."""
result = post(
api,
@ -45,8 +44,9 @@ def main(
},
)
print(json.dumps(result, indent=2, ensure_ascii=False))
return 0 if result["status"] in (200, 201) else 1
if result["status"] not in (200, 201):
raise typer.Exit(1)
if __name__ == "__main__":
sys.exit(typer.run(main))
typer.run(main)

View file

@ -84,13 +84,13 @@ def main(
pull: bool = typer.Option(False, "--pull", help="export all Langflow flows first"),
sync: bool = typer.Option(False, "--sync", help="reconcile backend/flows into DB + LFX"),
dir: str | None = typer.Option(None, "--dir", help="flows directory"),
) -> int:
) -> None:
"""Reconcile flows (typer CLI; run_sync/run_full_pass stay importable)."""
if not pull and not sync:
sync = True
args = type("Args", (), {"pull": pull, "sync": sync, "dir": dir})()
return asyncio.run(main_async(args))
raise typer.Exit(asyncio.run(main_async(args)))
if __name__ == "__main__":
sys.exit(typer.run(main))
typer.run(main)

View file

@ -298,11 +298,16 @@ def flow_helpers(client, login):
deployed = await _deploy(flow["id"], version["version_no"])
return flow, deployed
async def _version_and_deploy(flow_id: str, fixture: str = "simple_rule.json") -> dict:
await _create_version(flow_id, fixture)
return await _deploy(flow_id)
return {
"create_flow": _create_flow,
"create_version": _create_version,
"deploy": _deploy,
"deployed_flow": _deployed_flow,
"version_and_deploy": _version_and_deploy,
}

View file

@ -7,35 +7,13 @@ from app.flows.schemas import ApprovalCreate
from .conftest import load_flow_fixture
def load_fixture(name: str) -> dict:
"""Alias of the shared conftest helper (kept for call-site stability)."""
return load_flow_fixture(name)
async def _create_flow(client, name):
response = await client.post("/api/flows", json={"name": name})
assert response.status_code == 201, response.text
return response.json()
async def _version_and_deploy(client, flow_id, fixture="simple_rule.json"):
response = await client.post(
f"/api/flows/{flow_id}/versions",
json={"langflow_json": load_fixture(fixture)},
)
assert response.status_code == 201, response.text
response = await client.post(f"/api/flows/{flow_id}/versions/1/deploy")
assert response.status_code == 200, response.text
return response.json()
async def _handoff_task(client, live_server, monkeypatch):
async def _handoff_task(client, live_server, monkeypatch, flow_helpers):
"""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")
flow = await flow_helpers["create_flow"](f"handoff-{uuid4().hex[:8]}")
await flow_helpers["version_and_deploy"](flow["id"], fixture="approval_handoff.json")
body = {"input": {"betrag": 250000, "schwelle": 200000}}
run = (await client.post(f"/api/flows/{flow['id']}/run", json=body)).json()
assert run["status"] == "pending_approval"
@ -44,9 +22,11 @@ async def _handoff_task(client, live_server, monkeypatch):
return run, task
async def test_approve_without_continuation(client, lfx_server, live_server, login, monkeypatch):
async def test_approve_without_continuation(
client, lfx_server, live_server, login, monkeypatch, flow_helpers
):
await login()
_, task = await _handoff_task(client, live_server, monkeypatch)
_, task = await _handoff_task(client, live_server, monkeypatch, flow_helpers)
response = await client.post(
f"/api/approvals/{task['id']}/decision",
json={"decision": "approve", "note": "ok"},
@ -60,11 +40,11 @@ async def test_approve_without_continuation(client, lfx_server, live_server, log
async def test_approve_with_continuation_runs_facts(
client, lfx_server, live_server, login, monkeypatch
client, lfx_server, live_server, login, monkeypatch, flow_helpers
):
await login()
cont = await _create_flow(client, f"cont-{uuid4().hex[:8]}")
await _version_and_deploy(client, cont["id"], fixture="simple_rule.json")
cont = await flow_helpers["create_flow"](f"cont-{uuid4().hex[:8]}")
await flow_helpers["version_and_deploy"](cont["id"], fixture="simple_rule.json")
# Continuation is authoring-time wiring on the definition (admin-only);
# run bodies cannot set it.
@ -74,7 +54,7 @@ async def test_approve_with_continuation_runs_facts(
)
assert response.status_code == 201, response.text
flow = response.json()
await _version_and_deploy(client, flow["id"], fixture="approval_handoff.json")
await flow_helpers["version_and_deploy"](flow["id"], fixture="approval_handoff.json")
from app.settings import settings
@ -105,16 +85,18 @@ async def test_approve_with_continuation_runs_facts(
assert "Sonderfall" in cont_run["output"]["result"]
async def test_client_continuation_is_ignored(client, lfx_server, live_server, login, monkeypatch):
async def test_client_continuation_is_ignored(
client, lfx_server, live_server, login, monkeypatch, flow_helpers
):
"""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")
cont = await flow_helpers["create_flow"](f"cont-{uuid4().hex[:8]}")
await flow_helpers["version_and_deploy"](cont["id"], fixture="simple_rule.json")
flow = await flow_helpers["create_flow"](f"handoff-{uuid4().hex[:8]}")
await flow_helpers["version_and_deploy"](flow["id"], fixture="approval_handoff.json")
run = (
await client.post(
f"/api/flows/{flow['id']}/run",
@ -130,9 +112,9 @@ async def test_client_continuation_is_ignored(client, lfx_server, live_server, l
assert task["continuation_flow_id"] is None
async def test_reject_task(client, lfx_server, live_server, login, monkeypatch):
async def test_reject_task(client, lfx_server, live_server, login, monkeypatch, flow_helpers):
await login()
_, task = await _handoff_task(client, live_server, monkeypatch)
_, task = await _handoff_task(client, live_server, monkeypatch, flow_helpers)
# 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(
@ -147,9 +129,9 @@ async def test_reject_task(client, lfx_server, live_server, login, monkeypatch):
assert body["continuation_run"] is None
async def test_decision_guards(client, lfx_server, live_server, login, monkeypatch):
async def test_decision_guards(client, lfx_server, live_server, login, monkeypatch, flow_helpers):
await login()
_, task = await _handoff_task(client, live_server, monkeypatch)
_, task = await _handoff_task(client, live_server, monkeypatch, flow_helpers)
url = f"/api/approvals/{task['id']}/decision"
response = await client.post(url, json={"decision": "approve"})
@ -160,7 +142,7 @@ async def test_decision_guards(client, lfx_server, live_server, login, monkeypat
response = await client.post(f"/api/approvals/{uuid4()}/decision", json={"decision": "approve"})
assert response.status_code == 404
_, task2 = await _handoff_task(client, live_server, monkeypatch)
_, task2 = await _handoff_task(client, live_server, monkeypatch, flow_helpers)
response = await client.post(
f"/api/approvals/{task2['id']}/decision", json={"decision": "maybe"}
)
@ -174,15 +156,15 @@ async def test_decision_guards(client, lfx_server, live_server, login, monkeypat
assert response.status_code == 200
async def test_decision_bad_continuation_reference(client, session, login):
async def test_decision_bad_continuation_reference(client, session, login, flow_helpers):
from app.flows import store as flow_store
await login()
flow = await _create_flow(client, f"badcont-{uuid4().hex[:8]}")
flow = await flow_helpers["create_flow"](f"badcont-{uuid4().hex[:8]}")
version = (
await client.post(
f"/api/flows/{flow['id']}/versions",
json={"langflow_json": load_fixture("simple_rule.json")},
json={"langflow_json": load_flow_fixture("simple_rule.json")},
)
).json()
run = flow_store.create_run(
@ -200,11 +182,11 @@ async def test_decision_bad_continuation_reference(client, session, login):
assert response.status_code == 422
async def test_tenant_isolation(client, lfx_server, login):
async def test_tenant_isolation(client, lfx_server, login, flow_helpers):
"""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"])
flow = await flow_helpers["create_flow"](f"tenant-{uuid4().hex[:8]}")
await flow_helpers["version_and_deploy"](flow["id"])
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}})
@ -235,14 +217,14 @@ async def test_tenant_isolation(client, lfx_server, login):
assert response.status_code == 200
async def test_lfx_down_maps_to_503(client, lfx_server, monkeypatch, login):
async def test_lfx_down_maps_to_503(client, lfx_server, monkeypatch, login, flow_helpers):
from app.settings import settings
await login()
flow = await _create_flow(client, f"down-{uuid4().hex[:8]}")
flow = await flow_helpers["create_flow"](f"down-{uuid4().hex[:8]}")
await client.post(
f"/api/flows/{flow['id']}/versions",
json={"langflow_json": load_fixture("simple_rule.json")},
json={"langflow_json": load_flow_fixture("simple_rule.json")},
)
monkeypatch.setattr(settings, "lfx_url", "http://127.0.0.1:9")
response = await client.post(f"/api/flows/{flow['id']}/versions/1/deploy")
@ -257,13 +239,13 @@ async def test_lfx_down_maps_to_503(client, lfx_server, monkeypatch, login):
assert response.status_code == 503
async def test_lfx_drift_maps_to_502(client, lfx_server, login):
async def test_lfx_drift_maps_to_502(client, lfx_server, login, flow_helpers):
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"])
flow = await flow_helpers["create_flow"](f"drift-{uuid4().hex[:8]}")
deployed = await flow_helpers["version_and_deploy"](flow["id"])
await LfxClient(settings.lfx_url, api_key=settings.lfx_api_key or None).delete_flow(
deployed["lfx_flow_id"]
)
@ -271,9 +253,9 @@ async def test_lfx_drift_maps_to_502(client, lfx_server, login):
assert response.status_code == 502
async def test_corrupt_flow_json_deploy_maps_to_422(client, lfx_server, login):
async def test_corrupt_flow_json_deploy_maps_to_422(client, lfx_server, login, flow_helpers):
await login()
flow = await _create_flow(client, f"corrupt-{uuid4().hex[:8]}")
flow = await flow_helpers["create_flow"](f"corrupt-{uuid4().hex[:8]}")
bogus = {
"name": "bogus",
"data": {

View file

@ -8,7 +8,7 @@ import pytest
from sqlmodel import select
from app.auth import service
from app.auth.deps import SESSION_COOKIE, resolve_tenant
from app.auth.deps import SESSION_COOKIE
from app.models import MagicLinkToken, User
from app.settings import settings
@ -200,6 +200,28 @@ async def test_no_token_or_session_in_logs(client, session, caplog):
assert service.hash_token(token) not in caplog.text
def test_session_jwt_short_expiry_and_claims(session):
"""Stateless sessions expire in days (no revocation — bound the window)."""
user = _get_or_make_user(session, MEMBER)
claims = service.decode_session_token(service.mint_session_token(user))
assert claims["sub"] == str(user.id)
assert claims["tenant_id"] == user.tenant_id
assert claims["exp"] - claims["iat"] == 7 * 24 * 3600
async def test_deleted_user_token_fails_closed(client, session):
"""Tokens of deleted users stop working (row reloaded per request)."""
user = _get_or_make_user(session, MEMBER)
token = service.issue_magic_token(session, user)
response = await client.post("/api/auth/magic/consume", json={"token": token})
assert response.status_code == 200, response.text
jwt_cookie = response.cookies[SESSION_COOKIE]
session.delete(user)
session.commit()
client.cookies.set(SESSION_COOKIE, jwt_cookie)
assert (await client.get("/api/auth/me")).status_code == 401
async def test_access_request_rate_limit(client):
email = f"impatient-{uuid4()}@example.com"
for _ in range(service.REQUESTS_PER_EMAIL_PER_HOUR):
@ -214,14 +236,6 @@ async def test_access_request_rate_limit(client):
assert limited.status_code == 429
def test_resolve_tenant():
assert resolve_tenant(None, None) == "default"
assert resolve_tenant(None, "acme") == "acme"
user = User(email="a@b.c", tenant_id="parish-7")
assert resolve_tenant(user, None) == "parish-7"
assert resolve_tenant(user, "other") == "parish-7"
def _broken_smtp(monkeypatch):
from app.auth import mail

View file

@ -12,35 +12,9 @@ from .conftest import load_flow_fixture
CALLBACK_HEADERS = {"X-Callback-Token": "test-callback-token"}
def load_fixture(name: str) -> dict:
"""Alias of the shared conftest helper (kept for call-site stability)."""
return load_flow_fixture(name)
async def _create_flow(client, name="regeltest"):
response = await client.post("/api/flows", json={"name": name, "description": "test flow"})
assert response.status_code == 201, response.text
return response.json()
async def _create_version(client, flow_id, fixture="simple_rule.json"):
response = await client.post(
f"/api/flows/{flow_id}/versions",
json={"langflow_json": load_fixture(fixture), "author": "test"},
)
assert response.status_code == 201, response.text
return response.json()
async def _deploy(client, flow_id, version_no=1):
response = await client.post(f"/api/flows/{flow_id}/versions/{version_no}/deploy")
assert response.status_code == 200, response.text
return response.json()
async def test_flow_crud(client, login):
async def test_flow_crud(client, login, flow_helpers):
await login()
created = await _create_flow(client, "crud-flow")
created = await flow_helpers["create_flow"]("crud-flow")
assert created["status"] == "active"
response = await client.get(f"/api/flows/{created['id']}")
@ -98,10 +72,12 @@ LOGIN_GATED_PATHS = [
@pytest.mark.parametrize(("method", "path", "body"), LOGIN_GATED_PATHS)
async def test_runs_and_reads_require_login(client, login, anon_client, method, path, body):
async def test_runs_and_reads_require_login(
client, login, anon_client, flow_helpers, method, path, body
):
"""Run, run reads and approval reads are login-gated (parametrized)."""
await login()
flow = await _create_flow(client, "gated")
flow = await flow_helpers["create_flow"]("gated")
target = path.format(flow_id=flow["id"], run_id=uuid4(), task_id=uuid4())
async for anon in anon_client():
if method == "POST":
@ -111,13 +87,13 @@ async def test_runs_and_reads_require_login(client, login, anon_client, method,
assert response.status_code == 401, (method, target, response.text)
async def test_version_validation_and_numbering(client, login):
async def test_version_validation_and_numbering(client, login, flow_helpers):
await login()
flow = await _create_flow(client, "versioned")
flow = await flow_helpers["create_flow"]("versioned")
v1 = await _create_version(client, flow["id"])
v1 = await flow_helpers["create_version"](flow["id"])
assert v1["version_no"] == 1
v2 = await _create_version(client, flow["id"])
v2 = await flow_helpers["create_version"](flow["id"])
assert v2["version_no"] == 2
assert v1["deployed_at"] is None
@ -128,7 +104,7 @@ async def test_version_validation_and_numbering(client, login):
response = await client.post(
f"/api/flows/{uuid4()}/versions",
json={"langflow_json": load_fixture("simple_rule.json")},
json={"langflow_json": load_flow_fixture("simple_rule.json")},
)
assert response.status_code == 404
@ -136,11 +112,11 @@ async def test_version_validation_and_numbering(client, login):
assert [v["version_no"] for v in response.json()] == [1, 2]
async def test_deploy_and_run_standard_and_sonderfall(client, lfx_server, login):
async def test_deploy_and_run_standard_and_sonderfall(client, lfx_server, login, flow_helpers):
await login()
flow = await _create_flow(client, "dachsanierung")
await _create_version(client, flow["id"])
deployed = await _deploy(client, flow["id"])
flow = await flow_helpers["create_flow"]("dachsanierung")
await flow_helpers["create_version"](flow["id"])
deployed = await flow_helpers["deploy"](flow["id"])
assert deployed["lfx_flow_id"] == lfx_id_for(UUID(flow["id"]), 1)
assert deployed["deployed_at"] is not None
@ -168,20 +144,20 @@ async def test_deploy_and_run_standard_and_sonderfall(client, lfx_server, login)
assert response.json()["id"] == run["id"]
async def test_run_undeployed_version_fails(client, login):
async def test_run_undeployed_version_fails(client, login, flow_helpers):
await login()
flow = await _create_flow(client, "undeployed")
await _create_version(client, flow["id"])
flow = await flow_helpers["create_flow"]("undeployed")
await flow_helpers["create_version"](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, login):
async def test_run_explicit_version_no(client, lfx_server, login, flow_helpers):
await login()
flow = await _create_flow(client, "explicit-version")
await _create_version(client, flow["id"])
await _create_version(client, flow["id"])
await _deploy(client, flow["id"], version_no=2)
flow = await flow_helpers["create_flow"]("explicit-version")
await flow_helpers["create_version"](flow["id"])
await flow_helpers["create_version"](flow["id"])
await flow_helpers["deploy"](flow["id"], version_no=2)
response = await client.post(
f"/api/flows/{flow['id']}/run",
json={"input": {"betrag": 1}, "version_no": 2},
@ -190,11 +166,11 @@ async def test_run_explicit_version_no(client, lfx_server, login):
assert response.json()["status"] == "completed"
async def test_run_stream_events(client, lfx_server, login):
async def test_run_stream_events(client, lfx_server, login, flow_helpers):
await login()
flow = await _create_flow(client, "streamed")
await _create_version(client, flow["id"])
await _deploy(client, flow["id"])
flow = await flow_helpers["create_flow"]("streamed")
await flow_helpers["create_version"](flow["id"])
await flow_helpers["deploy"](flow["id"])
events: list[tuple[str, str]] = []
async with client.stream(
@ -225,14 +201,16 @@ async def test_run_stream_events(client, lfx_server, login):
assert response.json()["status"] == "completed"
async def test_approval_handoff_end_to_end(client, lfx_server, live_server, login, monkeypatch):
async def test_approval_handoff_end_to_end(
client, lfx_server, live_server, login, monkeypatch, flow_helpers
):
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"])
flow = await flow_helpers["create_flow"]("freigabe")
await flow_helpers["create_version"](flow["id"], fixture="approval_handoff.json")
await flow_helpers["deploy"](flow["id"])
response = await client.post(
f"/api/flows/{flow['id']}/run",
@ -254,15 +232,15 @@ async def test_approval_handoff_end_to_end(client, lfx_server, live_server, logi
async def test_approval_handoff_auto_creates_no_task(
client, lfx_server, live_server, login, monkeypatch
client, lfx_server, live_server, login, monkeypatch, flow_helpers
):
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"])
flow = await flow_helpers["create_flow"]("freigabe-auto")
await flow_helpers["create_version"](flow["id"], fixture="approval_handoff.json")
await flow_helpers["deploy"](flow["id"])
response = await client.post(
f"/api/flows/{flow['id']}/run",
@ -276,14 +254,14 @@ async def test_approval_handoff_auto_creates_no_task(
assert response.json() == []
async def test_approval_callback_contract(client, session, login):
async def test_approval_callback_contract(client, session, login, flow_helpers):
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"])
flow = await flow_helpers["create_flow"]("callback-target")
version = await flow_helpers["create_version"](flow["id"])
version_obj = flow_store.get_version(session, UUID(version["id"]))
run = flow_store.create_run(session, version_obj, input={}, tenant_id="default")
@ -321,15 +299,15 @@ async def test_approval_callback_contract(client, session, login):
assert response.status_code == 404
async def test_approval_callback_token_required(client, session, login):
async def test_approval_callback_token_required(client, session, login, flow_helpers):
"""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"])
flow = await flow_helpers["create_flow"]("callback-gated")
version = await flow_helpers["create_version"](flow["id"])
version_obj = flow_store.get_version(session, UUID(version["id"]))
run = flow_store.create_run(session, version_obj, input={}, tenant_id="default")

View file

@ -8,8 +8,6 @@ import pytest
from app.flows import input_schema as parser
from app.flows import sync
from app.flows.lfx_client import LfxClient
from app.settings import settings
from .conftest import FIXTURES_DIR as FIXTURES
@ -195,13 +193,9 @@ def test_real_intake_fixture_parses():
assert schema["ui_schema"]["notes"] == {"ui:widget": "textarea"}
def _lfx() -> LfxClient:
return LfxClient(settings.lfx_url, api_key=settings.lfx_api_key or None)
async def test_sync_caches_schema_per_version(session, lfx_server, tmp_path):
async def test_sync_caches_schema_per_version(session, lfx_server, lfx_client, tmp_path):
(tmp_path / "intake_form.json").write_text((FIXTURES / "intake_form.json").read_text())
report = await sync.sync_from_directory(session, _lfx(), tmp_path)
report = await sync.sync_from_directory(session, lfx_client, tmp_path)
assert not report.errors, report.errors
from app.flows import store

View file

@ -9,7 +9,7 @@ from pathlib import Path
import pytest
from app.flows import store, sync
from app.flows.lfx_client import LfxClient, LfxNotFound
from app.flows.lfx_client import LfxNotFound
from app.flows.schemas import SyncReport
from app.flows.sync import lfx_id_for
from app.settings import settings
@ -22,19 +22,9 @@ def _seed_dir(tmp_path: Path, name: str = "simple_rule.json") -> Path:
return tmp_path
def load_fixture(name: str) -> dict:
"""Alias of the shared conftest helper (kept for call-site stability)."""
return load_flow_fixture(name)
def _lfx() -> LfxClient:
"""Alias of the shared lfx_client fixture logic (call-site stability)."""
return LfxClient(settings.lfx_url, api_key=settings.lfx_api_key or None)
async def test_sync_adds_new_flow(session, lfx_server, tmp_path):
async def test_sync_adds_new_flow(session, lfx_server, lfx_client, tmp_path):
_seed_dir(tmp_path)
report = await sync.sync_from_directory(session, _lfx(), tmp_path)
report = await sync.sync_from_directory(session, lfx_client, tmp_path)
assert report.added == ["simple_rule"]
assert report.errors == {}
definition = store.get_definition_by_source_path(session, "simple_rule.json")
@ -46,83 +36,83 @@ async def test_sync_adds_new_flow(session, lfx_server, tmp_path):
assert versions[0].deployed_at is not None
async def test_sync_skips_ignored_files(session, lfx_server, tmp_path):
async def test_sync_skips_ignored_files(session, lfx_server, lfx_client, tmp_path):
_seed_dir(tmp_path)
(tmp_path / ".flowsignore").write_text("simple_rule\n", encoding="utf-8")
report = await sync.sync_from_directory(session, _lfx(), tmp_path)
report = await sync.sync_from_directory(session, lfx_client, tmp_path)
assert report.added == [] and report.errors == {}
assert store.get_definition_by_source_path(session, "simple_rule.json") is None
async def test_sync_skips_unchanged(session, lfx_server, tmp_path):
async def test_sync_skips_unchanged(session, lfx_server, lfx_client, tmp_path):
_seed_dir(tmp_path)
first = await sync.sync_from_directory(session, _lfx(), tmp_path)
first = await sync.sync_from_directory(session, lfx_client, tmp_path)
assert first.added == ["simple_rule"]
second = await sync.sync_from_directory(session, _lfx(), tmp_path)
second = await sync.sync_from_directory(session, lfx_client, tmp_path)
assert second.skipped == ["simple_rule"]
assert second.added == second.updated == []
definition = store.get_definition_by_source_path(session, "simple_rule.json")
assert len(store.list_versions(session, definition.id)) == 1
async def test_sync_updates_on_change_and_replaces_lfx(session, lfx_server, tmp_path):
async def test_sync_updates_on_change_and_replaces_lfx(session, lfx_server, lfx_client, tmp_path):
_seed_dir(tmp_path)
await sync.sync_from_directory(session, _lfx(), tmp_path)
await sync.sync_from_directory(session, lfx_client, tmp_path)
definition = store.get_definition_by_source_path(session, "simple_rule.json")
old_lfx_id = lfx_id_for(definition.id, 1)
doc = load_fixture("simple_rule.json")
doc = load_flow_fixture("simple_rule.json")
doc["description"] = "geaendert"
(tmp_path / "simple_rule.json").write_text(json.dumps(doc))
report = await sync.sync_from_directory(session, _lfx(), tmp_path)
report = await sync.sync_from_directory(session, lfx_client, tmp_path)
assert report.updated == ["simple_rule"]
versions = store.list_versions(session, definition.id)
assert [v.version_no for v in versions] == [1, 2]
assert versions[1].lfx_flow_id == lfx_id_for(definition.id, 2)
# Superseded LFX registration is gone.
with pytest.raises(LfxNotFound):
await _lfx().flow_info(old_lfx_id)
await lfx_client.flow_info(old_lfx_id)
async def test_sync_deletes_missing_file_with_cascade(session, lfx_server, tmp_path):
async def test_sync_deletes_missing_file_with_cascade(session, lfx_server, lfx_client, tmp_path):
_seed_dir(tmp_path)
await sync.sync_from_directory(session, _lfx(), tmp_path)
await sync.sync_from_directory(session, lfx_client, tmp_path)
definition = store.get_definition_by_source_path(session, "simple_rule.json")
version = store.list_versions(session, definition.id)[0]
run = store.create_run(session, version, input={"a": 1}, tenant_id="default")
lfx_id = lfx_id_for(definition.id, 1)
(tmp_path / "simple_rule.json").unlink()
report = await sync.sync_from_directory(session, _lfx(), tmp_path)
report = await sync.sync_from_directory(session, lfx_client, tmp_path)
assert report.deleted == ["simple_rule"]
assert store.get_definition_by_source_path(session, "simple_rule.json") is None
with pytest.raises(store.NotFoundError):
store.get_run(session, run.id)
with pytest.raises(LfxNotFound):
await _lfx().flow_info(lfx_id)
await lfx_client.flow_info(lfx_id)
async def test_sync_reports_invalid_json(session, lfx_server, tmp_path):
async def test_sync_reports_invalid_json(session, lfx_server, lfx_client, tmp_path):
(tmp_path / "kaputt.json").write_text("{kein json")
report = await sync.sync_from_directory(session, _lfx(), tmp_path)
report = await sync.sync_from_directory(session, lfx_client, tmp_path)
assert "kaputt.json" in report.errors
assert report.added == []
assert store.list_definitions(session) == []
async def test_sync_missing_dir_raises(session, lfx_server, tmp_path):
async def test_sync_missing_dir_raises(session, lfx_server, lfx_client, tmp_path):
with pytest.raises(FileNotFoundError):
await sync.sync_from_directory(session, _lfx(), tmp_path / "gibts-nicht")
await sync.sync_from_directory(session, lfx_client, tmp_path / "gibts-nicht")
async def test_sync_rename_updates_definition_name(session, lfx_server, tmp_path):
async def test_sync_rename_updates_definition_name(session, lfx_server, lfx_client, tmp_path):
_seed_dir(tmp_path)
await sync.sync_from_directory(session, _lfx(), tmp_path)
doc = load_fixture("simple_rule.json")
await sync.sync_from_directory(session, lfx_client, tmp_path)
doc = load_flow_fixture("simple_rule.json")
doc["name"] = "umbenannt"
(tmp_path / "simple_rule.json").write_text(json.dumps(doc))
report = await sync.sync_from_directory(session, _lfx(), tmp_path)
report = await sync.sync_from_directory(session, lfx_client, tmp_path)
assert report.updated == ["simple_rule"]
definition = store.get_definition_by_source_path(session, "simple_rule.json")
assert definition.name == "umbenannt"
@ -146,11 +136,6 @@ async def test_sync_endpoint_returns_report(
assert response.json()["skipped"] == ["simple_rule"]
def _langflow_doc(name: str, langflow_id: str) -> dict:
"""Alias of the shared conftest helper (kept for call-site stability)."""
return langflow_export_doc(name, langflow_id)
async def test_sync_endpoint_pulls_from_langflow_then_syncs(
client, lfx_server, tmp_path, monkeypatch, login, override_langflow
):
@ -159,7 +144,7 @@ async def test_sync_endpoint_pulls_from_langflow_then_syncs(
langflow_id = "22222222-3333-4444-8555-666666666666"
override_langflow(
flows=[{"id": langflow_id, "name": "Gehaltserhöhung"}],
docs={langflow_id: _langflow_doc("Gehaltserhöhung", langflow_id)},
docs={langflow_id: langflow_export_doc("Gehaltserhöhung", langflow_id)},
)
response = await client.post("/api/flows/sync")
assert response.status_code == 200, response.text
@ -188,7 +173,7 @@ async def test_sync_endpoint_pull_reuses_same_named_file(
langflow_id = "44444444-5555-4666-8777-888888888888"
override_langflow(
flows=[{"id": langflow_id, "name": "simple_rule"}],
docs={langflow_id: _langflow_doc("simple_rule", langflow_id)},
docs={langflow_id: langflow_export_doc("simple_rule", langflow_id)},
)
response = await client.post("/api/flows/sync")
assert response.status_code == 200, response.text
@ -210,7 +195,7 @@ async def test_sync_endpoint_pull_error_continues(
good_id = "33333333-4444-4555-8666-777777777777"
override_langflow(
flows=[{"id": good_id, "name": "Gut"}, {"id": "kaputt-id", "name": "Kaputt"}],
docs={good_id: _langflow_doc("Gut", good_id)},
docs={good_id: langflow_export_doc("Gut", good_id)},
)
response = await client.post("/api/flows/sync")
assert response.status_code == 200, response.text

View file

@ -8,11 +8,11 @@
"name": "kirche-einfach-frontend",
"version": "0.1.0",
"dependencies": {
"@mantine/core": "^9.6.1",
"@mantine/dates": "^9.6.1",
"@mantine/form": "^9.6.1",
"@mantine/hooks": "^9.6.1",
"@mantine/notifications": "^9.6.1",
"@mantine/core": "^9.6.3",
"@mantine/dates": "^9.6.3",
"@mantine/form": "^9.6.3",
"@mantine/hooks": "^9.6.3",
"@mantine/notifications": "^9.6.3",
"@rjsf/core": "^6.10.0",
"@rjsf/mantine": "^6.10.0",
"@rjsf/utils": "^6.10.0",
@ -1373,9 +1373,9 @@
}
},
"node_modules/@mantine/core": {
"version": "9.6.1",
"resolved": "https://registry.npmjs.org/@mantine/core/-/core-9.6.1.tgz",
"integrity": "sha512-UkcPo8Zq0uSdodvyXV62HVmfIfHCcrjFrFrH0TggTy6Wtp8a78znGP8RJOz8zC9XSPpJmKjBQjg0M+nOvIzftA==",
"version": "9.6.3",
"resolved": "https://registry.npmjs.org/@mantine/core/-/core-9.6.3.tgz",
"integrity": "sha512-o9xzpkma6RiLbENNU4VoxKnCU4VaFlB6Yh6wAjAUiU329ypPV6tBY7VkVeQQ5Z+e12D3I+NTPrUw43onlYCqTg==",
"license": "MIT",
"dependencies": {
"@floating-ui/react": "^0.27.20",
@ -1385,7 +1385,7 @@
"type-fest": "^5.8.0"
},
"peerDependencies": {
"@mantine/hooks": "9.6.1",
"@mantine/hooks": "9.6.3",
"react": "^19.2.0",
"react-dom": "^19.2.0"
}
@ -1421,9 +1421,9 @@
}
},
"node_modules/@mantine/hooks": {
"version": "9.6.1",
"resolved": "https://registry.npmjs.org/@mantine/hooks/-/hooks-9.6.1.tgz",
"integrity": "sha512-pfxM95WozngB0ZALw6h99QV1TQ0qniOkFhzi0+dmSnKpxkVSR0xeNsvPpGDj9Teu+M69fRoE4IkzyMnu5Xld3w==",
"version": "9.6.3",
"resolved": "https://registry.npmjs.org/@mantine/hooks/-/hooks-9.6.3.tgz",
"integrity": "sha512-b3uJx1p5vlXOmLEgp4OqzC3FUxSIc5bmqPPxPLHsgR6S0aBiqn9psaihJP7u/v//mr3qeCmyMeIwSw9ROu4pXg==",
"license": "MIT",
"peerDependencies": {
"react": "^19.2.0"

View file

@ -11,11 +11,11 @@
"icons": "node scripts/icons.mjs"
},
"dependencies": {
"@mantine/core": "^9.6.1",
"@mantine/dates": "^9.6.1",
"@mantine/form": "^9.6.1",
"@mantine/hooks": "^9.6.1",
"@mantine/notifications": "^9.6.1",
"@mantine/core": "^9.6.3",
"@mantine/dates": "^9.6.3",
"@mantine/form": "^9.6.3",
"@mantine/hooks": "^9.6.3",
"@mantine/notifications": "^9.6.3",
"@rjsf/core": "^6.10.0",
"@rjsf/mantine": "^6.10.0",
"@rjsf/utils": "^6.10.0",

View file

@ -16,6 +16,7 @@ import {
IconRefresh,
IconUpload,
} from "@tabler/icons-react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { z } from "zod";
import {
LANGFLOW_URL,
@ -33,9 +34,17 @@ const importSchema = z.object({
importName: z.string(),
});
async function fetchVersions(
flows: FlowDefinition[],
): Promise<Record<string, FlowVersion[]>> {
const entries = await Promise.all(
flows.map(async (f) => [f.id, await api.listVersions(f.id)] as const),
);
return Object.fromEntries(entries);
}
export default function AdminTab() {
const [flows, setFlows] = useState<FlowDefinition[]>([]);
const [versions, setVersions] = useState<Record<string, FlowVersion[]>>({});
const queryClient = useQueryClient();
const importForm = useForm({
initialValues: { langflowId: "", importName: "" },
validate: (values) => {
@ -53,32 +62,25 @@ export default function AdminTab() {
notifications.show({ message, color: "green" });
}
const flowsQuery = useQuery({
queryKey: ["flows"],
queryFn: api.listFlows,
});
const flows = flowsQuery.data ?? [];
const versionsQuery = useQuery({
queryKey: ["versions", flows.map((f) => f.id)],
queryFn: () => fetchVersions(flows),
enabled: flows.length > 0,
});
const versions = versionsQuery.data ?? {};
useEffect(() => {
void (async () => {
try {
const list = await api.listFlows();
setFlows(list);
const entries = await Promise.all(
list.map(async (f) => [f.id, await api.listVersions(f.id)] as const),
);
setVersions(Object.fromEntries(entries));
} catch (e) {
setError(formatApiError(e));
}
})();
}, []);
const e = flowsQuery.error ?? versionsQuery.error;
if (e) setError(formatApiError(e));
}, [flowsQuery.error, versionsQuery.error]);
async function load() {
try {
const list = await api.listFlows();
setFlows(list);
const entries = await Promise.all(
list.map(async (f) => [f.id, await api.listVersions(f.id)] as const),
);
setVersions(Object.fromEntries(entries));
} catch (e) {
setError(formatApiError(e));
}
async function reload() {
await queryClient.invalidateQueries({ queryKey: ["flows"] });
await queryClient.invalidateQueries({ queryKey: ["versions"] });
}
async function onImport() {
@ -95,7 +97,7 @@ export default function AdminTab() {
`Importiert: ${result.definition.name} (Version ${result.version.version_no})`,
);
importForm.reset();
await load();
await reload();
} catch (e) {
setError(formatApiError(e));
}
@ -106,7 +108,7 @@ export default function AdminTab() {
try {
const version = await api.deployVersion(flowId, versionNo);
notifyInfo(`Deployed: Version ${version.version_no} (${version.lfx_flow_id})`);
await load();
await reload();
} catch (e) {
setError(formatApiError(e));
}
@ -126,7 +128,7 @@ export default function AdminTab() {
.map(([f, e]) => `${f}: ${e}`)
.join("; ");
notifyInfo(`Sync ${parts}${errs ? ` — Fehler: ${errs}` : ""}`);
await load();
await reload();
} catch (e) {
setError(formatApiError(e));
}