diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d543bb..9f0e593 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/backend/app/auth/deps.py b/backend/app/auth/deps.py index 88747f3..37dc5cb 100644 --- a/backend/app/auth/deps.py +++ b/backend/app/auth/deps.py @@ -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 diff --git a/backend/app/auth/service.py b/backend/app/auth/service.py index e8a0f71..371fe7b 100644 --- a/backend/app/auth/service.py +++ b/backend/app/auth/service.py @@ -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 diff --git a/backend/app/main.py b/backend/app/main.py index 3859851..bdf4680 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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() diff --git a/backend/scripts/check_prod.py b/backend/scripts/check_prod.py index e4901ae..2425f5d 100644 --- a/backend/scripts/check_prod.py +++ b/backend/scripts/check_prod.py @@ -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) diff --git a/backend/scripts/deploy_flow.py b/backend/scripts/deploy_flow.py index 8df10cb..db5c88c 100644 --- a/backend/scripts/deploy_flow.py +++ b/backend/scripts/deploy_flow.py @@ -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) diff --git a/backend/scripts/sync_flows.py b/backend/scripts/sync_flows.py index 5807384..e2782de 100644 --- a/backend/scripts/sync_flows.py +++ b/backend/scripts/sync_flows.py @@ -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) diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index cc90151..6490122 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -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, } diff --git a/backend/tests/test_approvals.py b/backend/tests/test_approvals.py index 911de66..59e7daf 100644 --- a/backend/tests/test_approvals.py +++ b/backend/tests/test_approvals.py @@ -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": { diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py index 7790b57..5c0f1e1 100644 --- a/backend/tests/test_auth.py +++ b/backend/tests/test_auth.py @@ -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 diff --git a/backend/tests/test_flows_api.py b/backend/tests/test_flows_api.py index 852e5f2..3668753 100644 --- a/backend/tests/test_flows_api.py +++ b/backend/tests/test_flows_api.py @@ -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") diff --git a/backend/tests/test_input_schema.py b/backend/tests/test_input_schema.py index 20f142b..3591baa 100644 --- a/backend/tests/test_input_schema.py +++ b/backend/tests/test_input_schema.py @@ -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 diff --git a/backend/tests/test_sync.py b/backend/tests/test_sync.py index b83d37d..3d833af 100644 --- a/backend/tests/test_sync.py +++ b/backend/tests/test_sync.py @@ -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 diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 95972d6..333ebf8 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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" diff --git a/frontend/package.json b/frontend/package.json index 50da3fe..74e5568 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/src/components/AdminTab.tsx b/frontend/src/components/AdminTab.tsx index 0237f0a..30e7b30 100644 --- a/frontend/src/components/AdminTab.tsx +++ b/frontend/src/components/AdminTab.tsx @@ -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> { + 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([]); - const [versions, setVersions] = useState>({}); + 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)); }