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`. - 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). - 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`. - 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). - 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. - 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 ### Added

View file

@ -36,11 +36,3 @@ def require_admin(user: Annotated[User, Depends(get_current_user)]) -> User:
if user.role != "admin": if user.role != "admin":
raise HTTPException(status_code=403, detail="admin only") raise HTTPException(status_code=403, detail="admin only")
return user 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. JWTs are bearer secrets — log user/request ids only, never secret values.
Sessions are stateless HS256 JWTs (sub=user.id, email, role, tenant_id, Sessions are stateless HS256 JWTs (sub=user.id, email, role, tenant_id,
iat, exp=30d, jti) in the HttpOnly session cookie. No session table: iat, exp=7d, jti) in the HttpOnly session cookie. No session table and no
logout clears the cookie; role/tenant changes apply at next login (or revocation list: logout clears the cookie client-side, and a stolen token
immediately for deleted users — the user row is reloaded per request). 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 from __future__ import annotations
@ -28,7 +29,10 @@ from app.settings import settings
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
MAGIC_LINK_TTL = timedelta(minutes=15) 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. # Public request endpoint: at most this many requests per email per hour.
REQUESTS_PER_EMAIL_PER_HOUR = 5 REQUESTS_PER_EMAIL_PER_HOUR = 5

View file

@ -36,6 +36,10 @@ def _blocking_startup_sync() -> None:
async def lifespan(app: FastAPI) -> AsyncIterator[None]: async def lifespan(app: FastAPI) -> AsyncIterator[None]:
task: asyncio.Task | None = None task: asyncio.Task | None = None
stop: asyncio.Event | 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: if settings.flows_watch_enabled:
await asyncio.to_thread(_blocking_startup_sync) await asyncio.to_thread(_blocking_startup_sync)
stop = asyncio.Event() stop = asyncio.Event()

View file

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

View file

@ -8,7 +8,6 @@ Usage:
from __future__ import annotations from __future__ import annotations
import json import json
import sys
import httpx import httpx
import typer import typer
@ -32,7 +31,7 @@ def main(
name: str | None = typer.Option(None, "--name"), name: str | None = typer.Option(None, "--name"),
author: str = typer.Option("", "--author"), author: str = typer.Option("", "--author"),
no_deploy: bool = typer.Option(False, "--no-deploy"), no_deploy: bool = typer.Option(False, "--no-deploy"),
) -> int: ) -> None:
"""Import a flow from Langflow via the running API (post() stays importable).""" """Import a flow from Langflow via the running API (post() stays importable)."""
result = post( result = post(
api, api,
@ -45,8 +44,9 @@ def main(
}, },
) )
print(json.dumps(result, indent=2, ensure_ascii=False)) 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__": 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"), 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"), sync: bool = typer.Option(False, "--sync", help="reconcile backend/flows into DB + LFX"),
dir: str | None = typer.Option(None, "--dir", help="flows directory"), dir: str | None = typer.Option(None, "--dir", help="flows directory"),
) -> int: ) -> None:
"""Reconcile flows (typer CLI; run_sync/run_full_pass stay importable).""" """Reconcile flows (typer CLI; run_sync/run_full_pass stay importable)."""
if not pull and not sync: if not pull and not sync:
sync = True sync = True
args = type("Args", (), {"pull": pull, "sync": sync, "dir": dir})() 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__": 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"]) deployed = await _deploy(flow["id"], version["version_no"])
return flow, deployed 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 { return {
"create_flow": _create_flow, "create_flow": _create_flow,
"create_version": _create_version, "create_version": _create_version,
"deploy": _deploy, "deploy": _deploy,
"deployed_flow": _deployed_flow, "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 from .conftest import load_flow_fixture
def load_fixture(name: str) -> dict: async def _handoff_task(client, live_server, monkeypatch, flow_helpers):
"""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):
"""Run approval_handoff over threshold; returns (run, task).""" """Run approval_handoff over threshold; returns (run, task)."""
from app.settings import settings from app.settings import settings
monkeypatch.setattr(settings, "callback_base_url", live_server) monkeypatch.setattr(settings, "callback_base_url", live_server)
flow = await _create_flow(client, f"handoff-{uuid4().hex[:8]}") flow = await flow_helpers["create_flow"](f"handoff-{uuid4().hex[:8]}")
await _version_and_deploy(client, flow["id"], fixture="approval_handoff.json") await flow_helpers["version_and_deploy"](flow["id"], fixture="approval_handoff.json")
body = {"input": {"betrag": 250000, "schwelle": 200000}} body = {"input": {"betrag": 250000, "schwelle": 200000}}
run = (await client.post(f"/api/flows/{flow['id']}/run", json=body)).json() run = (await client.post(f"/api/flows/{flow['id']}/run", json=body)).json()
assert run["status"] == "pending_approval" assert run["status"] == "pending_approval"
@ -44,9 +22,11 @@ async def _handoff_task(client, live_server, monkeypatch):
return run, task 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() await login()
_, task = await _handoff_task(client, live_server, monkeypatch) _, task = await _handoff_task(client, live_server, monkeypatch, flow_helpers)
response = await client.post( response = await client.post(
f"/api/approvals/{task['id']}/decision", f"/api/approvals/{task['id']}/decision",
json={"decision": "approve", "note": "ok"}, 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( 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() await login()
cont = await _create_flow(client, f"cont-{uuid4().hex[:8]}") cont = await flow_helpers["create_flow"](f"cont-{uuid4().hex[:8]}")
await _version_and_deploy(client, cont["id"], fixture="simple_rule.json") await flow_helpers["version_and_deploy"](cont["id"], fixture="simple_rule.json")
# Continuation is authoring-time wiring on the definition (admin-only); # Continuation is authoring-time wiring on the definition (admin-only);
# run bodies cannot set it. # run bodies cannot set it.
@ -74,7 +54,7 @@ async def test_approve_with_continuation_runs_facts(
) )
assert response.status_code == 201, response.text assert response.status_code == 201, response.text
flow = response.json() 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 from app.settings import settings
@ -105,16 +85,18 @@ async def test_approve_with_continuation_runs_facts(
assert "Sonderfall" in cont_run["output"]["result"] 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).""" """A run body cannot chain arbitrary flows (G)."""
from app.settings import settings from app.settings import settings
await login() await login()
monkeypatch.setattr(settings, "callback_base_url", live_server) monkeypatch.setattr(settings, "callback_base_url", live_server)
cont = await _create_flow(client, f"cont-{uuid4().hex[:8]}") cont = await flow_helpers["create_flow"](f"cont-{uuid4().hex[:8]}")
await _version_and_deploy(client, cont["id"], fixture="simple_rule.json") await flow_helpers["version_and_deploy"](cont["id"], fixture="simple_rule.json")
flow = await _create_flow(client, f"handoff-{uuid4().hex[:8]}") flow = await flow_helpers["create_flow"](f"handoff-{uuid4().hex[:8]}")
await _version_and_deploy(client, flow["id"], fixture="approval_handoff.json") await flow_helpers["version_and_deploy"](flow["id"], fixture="approval_handoff.json")
run = ( run = (
await client.post( await client.post(
f"/api/flows/{flow['id']}/run", 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 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() 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. # Any logged-in user (not just admins) can decide; identity is the session.
await login(email="chef@example.com", role="member") await login(email="chef@example.com", role="member")
response = await client.post( 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 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() 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" url = f"/api/approvals/{task['id']}/decision"
response = await client.post(url, json={"decision": "approve"}) 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"}) response = await client.post(f"/api/approvals/{uuid4()}/decision", json={"decision": "approve"})
assert response.status_code == 404 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( response = await client.post(
f"/api/approvals/{task2['id']}/decision", json={"decision": "maybe"} 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 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 from app.flows import store as flow_store
await login() await login()
flow = await _create_flow(client, f"badcont-{uuid4().hex[:8]}") flow = await flow_helpers["create_flow"](f"badcont-{uuid4().hex[:8]}")
version = ( version = (
await client.post( await client.post(
f"/api/flows/{flow['id']}/versions", f"/api/flows/{flow['id']}/versions",
json={"langflow_json": load_fixture("simple_rule.json")}, json={"langflow_json": load_flow_fixture("simple_rule.json")},
) )
).json() ).json()
run = flow_store.create_run( run = flow_store.create_run(
@ -200,11 +182,11 @@ async def test_decision_bad_continuation_reference(client, session, login):
assert response.status_code == 422 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).""" """Strict session scoping: explicit ?tenant_id= cannot widen reads (E)."""
await login() await login()
flow = await _create_flow(client, f"tenant-{uuid4().hex[:8]}") flow = await flow_helpers["create_flow"](f"tenant-{uuid4().hex[:8]}")
await _version_and_deploy(client, flow["id"]) await flow_helpers["version_and_deploy"](flow["id"])
await login(email="a@example.com", role="member", tenant_id="pfarramt-a") 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}}) 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 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 from app.settings import settings
await login() 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( await client.post(
f"/api/flows/{flow['id']}/versions", 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") monkeypatch.setattr(settings, "lfx_url", "http://127.0.0.1:9")
response = await client.post(f"/api/flows/{flow['id']}/versions/1/deploy") 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 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.flows.lfx_client import LfxClient
from app.settings import settings from app.settings import settings
await login() await login()
flow = await _create_flow(client, f"drift-{uuid4().hex[:8]}") flow = await flow_helpers["create_flow"](f"drift-{uuid4().hex[:8]}")
deployed = await _version_and_deploy(client, flow["id"]) deployed = await flow_helpers["version_and_deploy"](flow["id"])
await LfxClient(settings.lfx_url, api_key=settings.lfx_api_key or None).delete_flow( await LfxClient(settings.lfx_url, api_key=settings.lfx_api_key or None).delete_flow(
deployed["lfx_flow_id"] 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 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() await login()
flow = await _create_flow(client, f"corrupt-{uuid4().hex[:8]}") flow = await flow_helpers["create_flow"](f"corrupt-{uuid4().hex[:8]}")
bogus = { bogus = {
"name": "bogus", "name": "bogus",
"data": { "data": {

View file

@ -8,7 +8,7 @@ import pytest
from sqlmodel import select from sqlmodel import select
from app.auth import service 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.models import MagicLinkToken, User
from app.settings import settings 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 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): async def test_access_request_rate_limit(client):
email = f"impatient-{uuid4()}@example.com" email = f"impatient-{uuid4()}@example.com"
for _ in range(service.REQUESTS_PER_EMAIL_PER_HOUR): 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 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): def _broken_smtp(monkeypatch):
from app.auth import mail 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"} CALLBACK_HEADERS = {"X-Callback-Token": "test-callback-token"}
def load_fixture(name: str) -> dict: async def test_flow_crud(client, login, flow_helpers):
"""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):
await login() await login()
created = await _create_flow(client, "crud-flow") created = await flow_helpers["create_flow"]("crud-flow")
assert created["status"] == "active" assert created["status"] == "active"
response = await client.get(f"/api/flows/{created['id']}") 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) @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).""" """Run, run reads and approval reads are login-gated (parametrized)."""
await login() 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()) target = path.format(flow_id=flow["id"], run_id=uuid4(), task_id=uuid4())
async for anon in anon_client(): async for anon in anon_client():
if method == "POST": 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) 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() 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 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 v2["version_no"] == 2
assert v1["deployed_at"] is None assert v1["deployed_at"] is None
@ -128,7 +104,7 @@ async def test_version_validation_and_numbering(client, login):
response = await client.post( response = await client.post(
f"/api/flows/{uuid4()}/versions", 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 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] 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() await login()
flow = await _create_flow(client, "dachsanierung") flow = await flow_helpers["create_flow"]("dachsanierung")
await _create_version(client, flow["id"]) await flow_helpers["create_version"](flow["id"])
deployed = await _deploy(client, flow["id"]) deployed = await flow_helpers["deploy"](flow["id"])
assert deployed["lfx_flow_id"] == lfx_id_for(UUID(flow["id"]), 1) assert deployed["lfx_flow_id"] == lfx_id_for(UUID(flow["id"]), 1)
assert deployed["deployed_at"] is not None 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"] 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() await login()
flow = await _create_flow(client, "undeployed") flow = await flow_helpers["create_flow"]("undeployed")
await _create_version(client, flow["id"]) await flow_helpers["create_version"](flow["id"])
response = await client.post(f"/api/flows/{flow['id']}/run", json={"input": {"betrag": 1}}) response = await client.post(f"/api/flows/{flow['id']}/run", json={"input": {"betrag": 1}})
assert response.status_code == 422 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() await login()
flow = await _create_flow(client, "explicit-version") flow = await flow_helpers["create_flow"]("explicit-version")
await _create_version(client, flow["id"]) await flow_helpers["create_version"](flow["id"])
await _create_version(client, flow["id"]) await flow_helpers["create_version"](flow["id"])
await _deploy(client, flow["id"], version_no=2) await flow_helpers["deploy"](flow["id"], version_no=2)
response = await client.post( response = await client.post(
f"/api/flows/{flow['id']}/run", f"/api/flows/{flow['id']}/run",
json={"input": {"betrag": 1}, "version_no": 2}, 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" 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() await login()
flow = await _create_flow(client, "streamed") flow = await flow_helpers["create_flow"]("streamed")
await _create_version(client, flow["id"]) await flow_helpers["create_version"](flow["id"])
await _deploy(client, flow["id"]) await flow_helpers["deploy"](flow["id"])
events: list[tuple[str, str]] = [] events: list[tuple[str, str]] = []
async with client.stream( async with client.stream(
@ -225,14 +201,16 @@ async def test_run_stream_events(client, lfx_server, login):
assert response.json()["status"] == "completed" 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 from app.settings import settings
await login() await login()
monkeypatch.setattr(settings, "callback_base_url", live_server) monkeypatch.setattr(settings, "callback_base_url", live_server)
flow = await _create_flow(client, "freigabe") flow = await flow_helpers["create_flow"]("freigabe")
await _create_version(client, flow["id"], fixture="approval_handoff.json") await flow_helpers["create_version"](flow["id"], fixture="approval_handoff.json")
await _deploy(client, flow["id"]) await flow_helpers["deploy"](flow["id"])
response = await client.post( response = await client.post(
f"/api/flows/{flow['id']}/run", 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( 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 from app.settings import settings
await login() await login()
monkeypatch.setattr(settings, "callback_base_url", live_server) monkeypatch.setattr(settings, "callback_base_url", live_server)
flow = await _create_flow(client, "freigabe-auto") flow = await flow_helpers["create_flow"]("freigabe-auto")
await _create_version(client, flow["id"], fixture="approval_handoff.json") await flow_helpers["create_version"](flow["id"], fixture="approval_handoff.json")
await _deploy(client, flow["id"]) await flow_helpers["deploy"](flow["id"])
response = await client.post( response = await client.post(
f"/api/flows/{flow['id']}/run", f"/api/flows/{flow['id']}/run",
@ -276,14 +254,14 @@ async def test_approval_handoff_auto_creates_no_task(
assert response.json() == [] 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 uuid import UUID
from app.flows import store as flow_store from app.flows import store as flow_store
await login() await login()
flow = await _create_flow(client, "callback-target") flow = await flow_helpers["create_flow"]("callback-target")
version = await _create_version(client, flow["id"]) version = await flow_helpers["create_version"](flow["id"])
version_obj = flow_store.get_version(session, UUID(version["id"])) version_obj = flow_store.get_version(session, UUID(version["id"]))
run = flow_store.create_run(session, version_obj, input={}, tenant_id="default") 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 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).""" """Forged callbacks without the shared token are rejected (C)."""
from uuid import UUID from uuid import UUID
from app.flows import store as flow_store from app.flows import store as flow_store
await login() await login()
flow = await _create_flow(client, "callback-gated") flow = await flow_helpers["create_flow"]("callback-gated")
version = await _create_version(client, flow["id"]) version = await flow_helpers["create_version"](flow["id"])
version_obj = flow_store.get_version(session, UUID(version["id"])) version_obj = flow_store.get_version(session, UUID(version["id"]))
run = flow_store.create_run(session, version_obj, input={}, tenant_id="default") 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 input_schema as parser
from app.flows import sync from app.flows import sync
from app.flows.lfx_client import LfxClient
from app.settings import settings
from .conftest import FIXTURES_DIR as FIXTURES 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"} assert schema["ui_schema"]["notes"] == {"ui:widget": "textarea"}
def _lfx() -> LfxClient: async def test_sync_caches_schema_per_version(session, lfx_server, lfx_client, tmp_path):
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):
(tmp_path / "intake_form.json").write_text((FIXTURES / "intake_form.json").read_text()) (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 assert not report.errors, report.errors
from app.flows import store from app.flows import store

View file

@ -9,7 +9,7 @@ from pathlib import Path
import pytest import pytest
from app.flows import store, sync 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.schemas import SyncReport
from app.flows.sync import lfx_id_for from app.flows.sync import lfx_id_for
from app.settings import settings from app.settings import settings
@ -22,19 +22,9 @@ def _seed_dir(tmp_path: Path, name: str = "simple_rule.json") -> Path:
return tmp_path return tmp_path
def load_fixture(name: str) -> dict: async def test_sync_adds_new_flow(session, lfx_server, lfx_client, tmp_path):
"""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):
_seed_dir(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.added == ["simple_rule"]
assert report.errors == {} assert report.errors == {}
definition = store.get_definition_by_source_path(session, "simple_rule.json") 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 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) _seed_dir(tmp_path)
(tmp_path / ".flowsignore").write_text("simple_rule\n", encoding="utf-8") (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 report.added == [] and report.errors == {}
assert store.get_definition_by_source_path(session, "simple_rule.json") is None 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) _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"] 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.skipped == ["simple_rule"]
assert second.added == second.updated == [] assert second.added == second.updated == []
definition = store.get_definition_by_source_path(session, "simple_rule.json") definition = store.get_definition_by_source_path(session, "simple_rule.json")
assert len(store.list_versions(session, definition.id)) == 1 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) _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") definition = store.get_definition_by_source_path(session, "simple_rule.json")
old_lfx_id = lfx_id_for(definition.id, 1) 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" doc["description"] = "geaendert"
(tmp_path / "simple_rule.json").write_text(json.dumps(doc)) (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"] assert report.updated == ["simple_rule"]
versions = store.list_versions(session, definition.id) versions = store.list_versions(session, definition.id)
assert [v.version_no for v in versions] == [1, 2] assert [v.version_no for v in versions] == [1, 2]
assert versions[1].lfx_flow_id == lfx_id_for(definition.id, 2) assert versions[1].lfx_flow_id == lfx_id_for(definition.id, 2)
# Superseded LFX registration is gone. # Superseded LFX registration is gone.
with pytest.raises(LfxNotFound): 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) _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") definition = store.get_definition_by_source_path(session, "simple_rule.json")
version = store.list_versions(session, definition.id)[0] version = store.list_versions(session, definition.id)[0]
run = store.create_run(session, version, input={"a": 1}, tenant_id="default") run = store.create_run(session, version, input={"a": 1}, tenant_id="default")
lfx_id = lfx_id_for(definition.id, 1) lfx_id = lfx_id_for(definition.id, 1)
(tmp_path / "simple_rule.json").unlink() (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 report.deleted == ["simple_rule"]
assert store.get_definition_by_source_path(session, "simple_rule.json") is None assert store.get_definition_by_source_path(session, "simple_rule.json") is None
with pytest.raises(store.NotFoundError): with pytest.raises(store.NotFoundError):
store.get_run(session, run.id) store.get_run(session, run.id)
with pytest.raises(LfxNotFound): 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") (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 "kaputt.json" in report.errors
assert report.added == [] assert report.added == []
assert store.list_definitions(session) == [] 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): 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) _seed_dir(tmp_path)
await sync.sync_from_directory(session, _lfx(), tmp_path) await sync.sync_from_directory(session, lfx_client, tmp_path)
doc = load_fixture("simple_rule.json") doc = load_flow_fixture("simple_rule.json")
doc["name"] = "umbenannt" doc["name"] = "umbenannt"
(tmp_path / "simple_rule.json").write_text(json.dumps(doc)) (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"] assert report.updated == ["simple_rule"]
definition = store.get_definition_by_source_path(session, "simple_rule.json") definition = store.get_definition_by_source_path(session, "simple_rule.json")
assert definition.name == "umbenannt" assert definition.name == "umbenannt"
@ -146,11 +136,6 @@ async def test_sync_endpoint_returns_report(
assert response.json()["skipped"] == ["simple_rule"] 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( async def test_sync_endpoint_pulls_from_langflow_then_syncs(
client, lfx_server, tmp_path, monkeypatch, login, override_langflow 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" langflow_id = "22222222-3333-4444-8555-666666666666"
override_langflow( override_langflow(
flows=[{"id": langflow_id, "name": "Gehaltserhöhung"}], 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") response = await client.post("/api/flows/sync")
assert response.status_code == 200, response.text 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" langflow_id = "44444444-5555-4666-8777-888888888888"
override_langflow( override_langflow(
flows=[{"id": langflow_id, "name": "simple_rule"}], 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") response = await client.post("/api/flows/sync")
assert response.status_code == 200, response.text 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" good_id = "33333333-4444-4555-8666-777777777777"
override_langflow( override_langflow(
flows=[{"id": good_id, "name": "Gut"}, {"id": "kaputt-id", "name": "Kaputt"}], 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") response = await client.post("/api/flows/sync")
assert response.status_code == 200, response.text assert response.status_code == 200, response.text

View file

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

View file

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

View file

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