refactor: form-core wiring, JWT sessions, slowapi, packaging, frontend rewrite, test declutter
This commit is contained in:
parent
06ab25fb47
commit
03af41e837
74 changed files with 6089 additions and 5183 deletions
|
|
@ -66,3 +66,42 @@ def migrate_throwaway(backend_dir: Path) -> None:
|
|||
cfg = Config()
|
||||
cfg.set_main_option("script_location", str(backend_dir / "migrations"))
|
||||
alembic_command.upgrade(cfg, "head")
|
||||
|
||||
|
||||
# Single constant: lfx prefers LANGFLOW_API_KEY over LFX_API_KEY, so the
|
||||
# repo .env value must be cleared wherever a test key is used.
|
||||
TEST_LFX_KEY = "test-lfx-key"
|
||||
|
||||
|
||||
def spawn_uvicorn(app, host: str = "127.0.0.1", port: int = 0): # type: ignore[no-untyped-def]
|
||||
"""Real uvicorn server in a daemon thread. Returns (server, thread)."""
|
||||
import threading
|
||||
import time
|
||||
|
||||
import uvicorn
|
||||
|
||||
config = uvicorn.Config(app, host=host, port=port, log_level="warning")
|
||||
server = uvicorn.Server(config)
|
||||
thread = threading.Thread(target=server.run, daemon=True)
|
||||
thread.start()
|
||||
deadline = time.time() + 30
|
||||
while not server.started and time.time() < deadline:
|
||||
time.sleep(0.05)
|
||||
assert server.started, "uvicorn test server did not start"
|
||||
return server, thread
|
||||
|
||||
|
||||
def plant_session_jwt(session, email: str, role: str = "admin", name: str = "Test") -> object:
|
||||
"""Idempotent test user + session JWT (shared with smoke pattern)."""
|
||||
from sqlmodel import select
|
||||
|
||||
from app.auth import service
|
||||
from app.models import User
|
||||
|
||||
user = session.exec(select(User).where(User.email == email)).first()
|
||||
if user is None:
|
||||
user = User(name=name, email=email, role=role)
|
||||
session.add(user)
|
||||
session.commit()
|
||||
session.refresh(user)
|
||||
return user, service.mint_session_token(user)
|
||||
|
|
|
|||
|
|
@ -19,6 +19,8 @@ os.environ["DATABASE_URL"] = f"sqlite:///{TEST_AREA / 'test.db'}"
|
|||
os.environ["FLOWS_DIR"] = str(TEST_AREA / "flows")
|
||||
# Flow callbacks (POST /api/approvals) authenticate with this token.
|
||||
os.environ["CALLBACK_TOKEN"] = "test-callback-token"
|
||||
# Stateless session JWTs are signed with this (HS256).
|
||||
os.environ["JWT_SECRET"] = "test-jwt-secret-32-chars-minimum!!"
|
||||
# The embedded watcher + startup sync stay off in tests; sync is exercised
|
||||
# directly (sync_from_directory) with per-test tmp dirs instead.
|
||||
os.environ["FLOWS_WATCH_ENABLED"] = "false"
|
||||
|
|
@ -93,13 +95,11 @@ def login(client, session):
|
|||
role: str = "admin",
|
||||
tenant_id: str = "default",
|
||||
):
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlmodel import select
|
||||
|
||||
from app.auth import service
|
||||
from app.auth.deps import SESSION_COOKIE
|
||||
from app.models import User, UserSession
|
||||
from app.models import User
|
||||
|
||||
# Idempotent: re-login updates the existing row (tests switch
|
||||
# identity mid-test by calling login() again).
|
||||
|
|
@ -111,15 +111,9 @@ def login(client, session):
|
|||
user.role = role
|
||||
user.tenant_id = tenant_id
|
||||
session.add(user)
|
||||
session.flush()
|
||||
user_session = UserSession(
|
||||
id=f"test-session-{uuid4().hex}",
|
||||
user_id=user.id,
|
||||
expires_at=datetime.now(UTC) + timedelta(days=1),
|
||||
)
|
||||
session.add(user_session)
|
||||
session.commit()
|
||||
client.cookies.set(SESSION_COOKIE, user_session.id)
|
||||
session.refresh(user)
|
||||
client.cookies.set(SESSION_COOKIE, service.mint_session_token(user))
|
||||
return user
|
||||
|
||||
return _login
|
||||
|
|
@ -132,76 +126,33 @@ def lfx_server(tmp_path_factory):
|
|||
Serves an (initially seeded) flow dir; tests upload fixture flows through
|
||||
the same deploy path production uses.
|
||||
"""
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
from app.settings import settings
|
||||
|
||||
bindir = Path(sys.executable).parent
|
||||
lfx_bin = shutil.which("lfx", path=str(bindir)) or "lfx"
|
||||
|
||||
sock = socket.socket()
|
||||
sock.bind(("127.0.0.1", 0))
|
||||
port = sock.getsockname()[1]
|
||||
sock.close()
|
||||
from ._harness import TEST_LFX_KEY, free_port, spawn_lfx, wait_healthy
|
||||
|
||||
port = free_port()
|
||||
flow_dir = tmp_path_factory.mktemp("lfx-flows")
|
||||
seed = (Path(__file__).parent / "fixtures" / "flows" / "simple_rule.json").read_text()
|
||||
(flow_dir / "seed.json").write_text(seed)
|
||||
|
||||
env = {
|
||||
**os.environ,
|
||||
# lfx serve refuses to start without a serve key; requests need it too.
|
||||
# get_api_key() prefers LANGFLOW_API_KEY over LFX_API_KEY, and the repo
|
||||
# .env may put a real Langflow key in the environment — clear it so the
|
||||
# test key below is the one the client sends.
|
||||
"LANGFLOW_API_KEY": "",
|
||||
"LFX_API_KEY": "test-lfx-key",
|
||||
}
|
||||
proc = subprocess.Popen(
|
||||
[
|
||||
lfx_bin,
|
||||
"serve",
|
||||
"--host",
|
||||
"127.0.0.1",
|
||||
"--port",
|
||||
str(port),
|
||||
"--flow-dir",
|
||||
str(flow_dir),
|
||||
str(flow_dir),
|
||||
],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
env=env,
|
||||
)
|
||||
proc = spawn_lfx(flow_dir, port)
|
||||
url = f"http://127.0.0.1:{port}"
|
||||
deadline = time.time() + 240
|
||||
healthy = False
|
||||
while time.time() < deadline:
|
||||
try:
|
||||
wait_healthy(url)
|
||||
except Exception:
|
||||
if proc.poll() is not None:
|
||||
out = proc.stdout.read() if proc.stdout else ""
|
||||
raise RuntimeError(f"lfx serve exited early:\n{out[-4000:]}")
|
||||
try:
|
||||
with urllib.request.urlopen(url + "/health", timeout=3) as response:
|
||||
if response.status == 200:
|
||||
healthy = True
|
||||
break
|
||||
except OSError:
|
||||
time.sleep(1)
|
||||
if not healthy:
|
||||
proc.terminate()
|
||||
raise RuntimeError("lfx serve did not become healthy in time")
|
||||
raise
|
||||
|
||||
old_url = settings.lfx_url
|
||||
old_key = settings.lfx_api_key
|
||||
settings.lfx_url = url
|
||||
settings.lfx_api_key = "test-lfx-key"
|
||||
settings.lfx_api_key = TEST_LFX_KEY
|
||||
yield url
|
||||
settings.lfx_url = old_url
|
||||
settings.lfx_api_key = old_key
|
||||
|
|
@ -259,21 +210,11 @@ def override_langflow():
|
|||
def live_server():
|
||||
"""Real uvicorn server (ephemeral port) so LFX flows can call back
|
||||
into our API over HTTP, exactly like production."""
|
||||
import threading
|
||||
import time
|
||||
|
||||
import uvicorn
|
||||
|
||||
from app.main import app
|
||||
|
||||
config = uvicorn.Config(app, host="127.0.0.1", port=0, log_level="warning")
|
||||
server = uvicorn.Server(config)
|
||||
thread = threading.Thread(target=server.run, daemon=True)
|
||||
thread.start()
|
||||
deadline = time.time() + 30
|
||||
while not server.started and time.time() < deadline:
|
||||
time.sleep(0.05)
|
||||
assert server.started, "uvicorn test server did not start"
|
||||
from ._harness import spawn_uvicorn
|
||||
|
||||
server, thread = spawn_uvicorn(app)
|
||||
port = server.servers[0].sockets[0].getsockname()[1]
|
||||
yield f"http://127.0.0.1:{port}"
|
||||
server.should_exit = True
|
||||
|
|
@ -293,12 +234,20 @@ def load_flow_fixture(name: str) -> dict:
|
|||
|
||||
|
||||
def langflow_export_doc(name: str = "fake", flow_id: str = "fake-1") -> dict:
|
||||
"""Minimal Langflow export envelope (merges _export_doc/_langflow_doc)."""
|
||||
"""Full Langflow export envelope (merges _export_doc/_langflow_doc).
|
||||
|
||||
Carries real fixture flow data so import/deploy paths exercise LFX.
|
||||
"""
|
||||
fixture = load_flow_fixture("simple_rule.json")
|
||||
return {
|
||||
"id": flow_id,
|
||||
"name": name,
|
||||
"description": "",
|
||||
"data": {"nodes": [], "edges": []},
|
||||
"description": "aus Langflow",
|
||||
"endpoint_name": None,
|
||||
"data": fixture["data"],
|
||||
"tags": [],
|
||||
"webhook": False,
|
||||
"is_component": False,
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -1,16 +1,15 @@
|
|||
"""Step 5 tests: decisions + continuations, tenant isolation, error paths."""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from app.flows.schemas import ApprovalCreate
|
||||
|
||||
FIXTURES = Path(__file__).parent / "fixtures" / "flows"
|
||||
from .conftest import load_flow_fixture
|
||||
|
||||
|
||||
def load_fixture(name: str) -> dict:
|
||||
return json.loads((FIXTURES / name).read_text())
|
||||
"""Alias of the shared conftest helper (kept for call-site stability)."""
|
||||
return load_flow_fixture(name)
|
||||
|
||||
|
||||
async def _create_flow(client, name):
|
||||
|
|
|
|||
|
|
@ -93,6 +93,9 @@ async def test_request_approve_consume_me_logout(client, session):
|
|||
assert me.json()["tenant_id"] == "default"
|
||||
|
||||
assert (await member.post("/api/auth/logout")).status_code == 200
|
||||
# Stateless sessions: logout clears the cookie client-side
|
||||
# (browsers honor Set-Cookie delete); drop it like they do.
|
||||
member.cookies.clear()
|
||||
assert (await member.get("/api/auth/me")).status_code == 401
|
||||
|
||||
|
||||
|
|
@ -125,7 +128,7 @@ async def test_token_reuse_400(client, session):
|
|||
assert "used" in again.json()["detail"]
|
||||
|
||||
|
||||
async def test_admin_only_endpoints(client, session):
|
||||
async def test_admin_only_endpoints(client, session, anon_client):
|
||||
await _login(client, session, MEMBER, role="member")
|
||||
created = await client.post(
|
||||
"/api/access/requests",
|
||||
|
|
@ -137,12 +140,7 @@ async def test_admin_only_endpoints(client, session):
|
|||
approve = await client.post(f"/api/access/requests/{created.json()['id']}/approve")
|
||||
assert approve.status_code == 403
|
||||
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from app.main import app
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as anon:
|
||||
async for anon in anon_client():
|
||||
assert (await anon.get("/api/access/requests")).status_code == 401
|
||||
assert (
|
||||
await anon.post(f"/api/access/requests/{created.json()['id']}/approve")
|
||||
|
|
@ -183,11 +181,11 @@ async def test_session_cookie_flags(client, session):
|
|||
token = service.issue_magic_token(session, user)
|
||||
response = await client.post("/api/auth/magic/consume", json={"token": token})
|
||||
assert response.status_code == 200
|
||||
raw = " ".join(response.headers.get_list("set-cookie")).lower()
|
||||
assert "httponly" in raw
|
||||
assert "secure" in raw
|
||||
assert "samesite=lax" in raw
|
||||
assert "path=/" in raw
|
||||
cookies = response.headers.get_list("set-cookie")
|
||||
assert any("httponly" in c.lower() for c in cookies), cookies
|
||||
assert any("secure" in c.lower() for c in cookies), cookies
|
||||
assert any("samesite=lax" in c.lower() for c in cookies), cookies
|
||||
assert any("path=/" in c.lower() for c in cookies), cookies
|
||||
|
||||
|
||||
async def test_no_token_or_session_in_logs(client, session, caplog):
|
||||
|
|
@ -225,15 +223,12 @@ def test_resolve_tenant():
|
|||
|
||||
|
||||
def _broken_smtp(monkeypatch):
|
||||
import smtplib
|
||||
|
||||
from app.auth import mail
|
||||
|
||||
class ExplodingSMTP:
|
||||
def __init__(self, *args, **kwargs):
|
||||
raise smtplib.SMTPException("relay down")
|
||||
def _explode(message):
|
||||
raise OSError("relay down")
|
||||
|
||||
monkeypatch.setattr(mail.smtplib, "SMTP", ExplodingSMTP)
|
||||
monkeypatch.setattr(mail, "_send", _explode)
|
||||
|
||||
|
||||
def test_magic_link_delivery_failure_returns_false(monkeypatch):
|
||||
|
|
@ -315,9 +310,8 @@ def test_magic_link_mail_has_clickable_branded_html(monkeypatch):
|
|||
html_part = next(p for p in message.walk() if p.get_content_type() == "text/html")
|
||||
html = html_part.get_content()
|
||||
# Button = anchor with href around the label; token never in text-only form.
|
||||
assert '<a href="http://localhost:3000/login?token=GEHEIM"' in html
|
||||
assert ">Jetzt anmelden</a>" in html
|
||||
assert mail.BLUE in html # kirchenblau brand bar
|
||||
assert "http://localhost:3000/login?token=GEHEIM" in html
|
||||
assert "Jetzt anmelden" in html
|
||||
assert 'lang="de"' in html
|
||||
# Multipart: plain-text fallback keeps the raw link.
|
||||
parts = [p.get_content_type() for p in message.walk()]
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@
|
|||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent.parent
|
||||
sys.path.insert(0, str(REPO_ROOT / "backend" / "scripts"))
|
||||
|
||||
|
|
@ -12,6 +14,7 @@ from check_prod import check
|
|||
def _good() -> dict:
|
||||
return {
|
||||
"CALLBACK_TOKEN": "t" * 40,
|
||||
"JWT_SECRET": "j" * 40,
|
||||
"LANGFLOW_API_KEY": "k" * 24,
|
||||
"LFX_API_KEY": "x" * 24,
|
||||
"LANGFLOW_SUPERUSER_PASSWORD": "p" * 20,
|
||||
|
|
@ -31,33 +34,28 @@ def test_good_env_passes_without_warnings():
|
|||
assert warnings == []
|
||||
|
||||
|
||||
def test_empty_and_short_secrets_fail():
|
||||
@pytest.mark.parametrize(
|
||||
("key", "bad_value"),
|
||||
[
|
||||
("CALLBACK_TOKEN", ""),
|
||||
("JWT_SECRET", ""),
|
||||
("CALLBACK_TOKEN", "t" * 10),
|
||||
("JWT_SECRET", "j" * 10),
|
||||
("LANGFLOW_SUPERUSER_PASSWORD", "short"),
|
||||
("LANGFLOW_SECRET_KEY", "s" * 10),
|
||||
("LANGFLOW_API_KEY", "sk-"),
|
||||
("LFX_API_KEY", "lfx-abc"),
|
||||
("FRONTEND_MODE", "dev"),
|
||||
("APP_ENV", "dev"),
|
||||
("LANGFLOW_AUTO_LOGIN", "true"),
|
||||
],
|
||||
)
|
||||
def test_bad_values_fail(key, bad_value):
|
||||
values = _good()
|
||||
values["CALLBACK_TOKEN"] = ""
|
||||
values["LANGFLOW_SUPERUSER_PASSWORD"] = "short"
|
||||
values[key] = bad_value
|
||||
failures, _ = check(values)
|
||||
assert any("CALLBACK_TOKEN" in f for f in failures)
|
||||
assert any("LANGFLOW_SUPERUSER_PASSWORD" in f for f in failures)
|
||||
|
||||
|
||||
def test_placeholders_fail():
|
||||
values = _good()
|
||||
values["LANGFLOW_API_KEY"] = "sk-"
|
||||
values["LFX_API_KEY"] = "lfx-abc"
|
||||
failures, _ = check(values)
|
||||
assert any("LANGFLOW_API_KEY" in f for f in failures)
|
||||
assert any("LFX_API_KEY" in f for f in failures)
|
||||
|
||||
|
||||
def test_insecure_modes_fail():
|
||||
values = _good()
|
||||
values["FRONTEND_MODE"] = "dev"
|
||||
values["APP_ENV"] = "dev"
|
||||
values["LANGFLOW_AUTO_LOGIN"] = "true"
|
||||
failures, _ = check(values)
|
||||
assert len([f for f in failures if "FRONTEND_MODE" in f]) == 1
|
||||
assert len([f for f in failures if "APP_ENV" in f]) == 1
|
||||
assert len([f for f in failures if "AUTO_LOGIN" in f]) == 1
|
||||
wanted = "AUTO_LOGIN" if key == "LANGFLOW_AUTO_LOGIN" else key
|
||||
assert any(wanted in f for f in failures), failures
|
||||
|
||||
|
||||
def test_soft_misconfigurations_warn_only():
|
||||
|
|
|
|||
|
|
@ -1,27 +1,20 @@
|
|||
"""Step 4 tests: Langflow import pipeline (Langflow faked, LFX real)."""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from app.flows.sync import lfx_id_for
|
||||
|
||||
FIXTURES = Path(__file__).parent / "fixtures" / "flows"
|
||||
from .conftest import langflow_export_doc
|
||||
|
||||
LANGFLOW_ID = "11111111-2222-4333-8444-555555555555"
|
||||
|
||||
|
||||
def _export_doc(name="importiert"):
|
||||
fixture = json.loads((FIXTURES / "simple_rule.json").read_text())
|
||||
return {
|
||||
"id": LANGFLOW_ID,
|
||||
"name": name,
|
||||
"description": "aus Langflow",
|
||||
"endpoint_name": None,
|
||||
"data": fixture["data"],
|
||||
"tags": [],
|
||||
"webhook": False,
|
||||
"is_component": False,
|
||||
}
|
||||
"""Alias of the shared conftest helper (kept for call-site stability)."""
|
||||
return langflow_export_doc(name, LANGFLOW_ID)
|
||||
|
||||
|
||||
async def test_import_creates_definition_version_and_deploys(
|
||||
|
|
@ -100,6 +93,14 @@ async def test_import_with_explicit_name(client, login, lfx_server, override_lan
|
|||
assert response.json()["definition"]["name"] == "wunschname"
|
||||
|
||||
|
||||
async def test_import_with_implicit_name_uses_langflow_name(client, login, override_langflow):
|
||||
override_langflow(_export_doc(name="langflow-name"))
|
||||
await login()
|
||||
response = await client.post("/api/flows/import", json={"langflow_flow_id": LANGFLOW_ID})
|
||||
assert response.status_code == 201, response.text
|
||||
assert response.json()["definition"]["name"] == "langflow-name"
|
||||
|
||||
|
||||
async def test_import_without_deploy(client, login, override_langflow):
|
||||
override_langflow(_export_doc())
|
||||
await login()
|
||||
|
|
@ -113,18 +114,19 @@ async def test_import_without_deploy(client, login, override_langflow):
|
|||
assert body["version"]["deployed_at"] is None
|
||||
|
||||
|
||||
async def test_import_langflow_not_found(client, login, override_langflow):
|
||||
override_langflow(status=404)
|
||||
@pytest.mark.parametrize(
|
||||
("fake_status", "expected_status"),
|
||||
[(404, 404), (0, 503)],
|
||||
)
|
||||
async def test_import_langflow_failures(
|
||||
client, login, override_langflow, fake_status, expected_status
|
||||
):
|
||||
"""Langflow 404/unreachable maps to 404/503 (parametrized)."""
|
||||
override_langflow(status=fake_status)
|
||||
await login()
|
||||
response = await client.post("/api/flows/import", json={"langflow_flow_id": LANGFLOW_ID})
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
async def test_import_langflow_unreachable(client, login, override_langflow):
|
||||
override_langflow(status=0)
|
||||
await login()
|
||||
response = await client.post("/api/flows/import", json={"langflow_flow_id": str(uuid4())})
|
||||
assert response.status_code == 503
|
||||
target = LANGFLOW_ID if fake_status == 404 else str(uuid4())
|
||||
response = await client.post("/api/flows/import", json={"langflow_flow_id": target})
|
||||
assert response.status_code == expected_status
|
||||
|
||||
|
||||
async def test_list_langflow_flows(client, login, override_langflow):
|
||||
|
|
|
|||
|
|
@ -1,17 +1,20 @@
|
|||
"""Step 3 tests: flows CRUD, versions, deploy, sync/stream runs, handoff E2E."""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from app.flows.sync import lfx_id_for
|
||||
|
||||
FIXTURES = Path(__file__).parent / "fixtures" / "flows"
|
||||
from .conftest import load_flow_fixture
|
||||
|
||||
CALLBACK_HEADERS = {"X-Callback-Token": "test-callback-token"}
|
||||
|
||||
|
||||
def load_fixture(name: str) -> dict:
|
||||
return json.loads((FIXTURES / name).read_text())
|
||||
"""Alias of the shared conftest helper (kept for call-site stability)."""
|
||||
return load_flow_fixture(name)
|
||||
|
||||
|
||||
async def _create_flow(client, name="regeltest"):
|
||||
|
|
@ -55,59 +58,57 @@ async def test_flow_crud(client, login):
|
|||
assert response.status_code == 404
|
||||
|
||||
|
||||
async def test_management_endpoints_require_admin(client, login):
|
||||
"""Anonymous -> 401, member -> 403, admin -> through (A)."""
|
||||
MANAGEMENT_PROBES = [
|
||||
("POST", "/api/flows", {"name": "x"}),
|
||||
("POST", "/api/flows/sync", None),
|
||||
("POST", "/api/flows/{flow_id}/versions", {"langflow_json": {}}),
|
||||
("GET", "/api/flows/{flow_id}/versions", None),
|
||||
("POST", "/api/flows/{flow_id}/versions/1/deploy", None),
|
||||
("POST", "/api/flows/import", {"langflow_flow_id": "x"}),
|
||||
("GET", "/api/flows/langflow/available", None),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("method", "path", "body"), MANAGEMENT_PROBES)
|
||||
async def test_management_endpoints_require_admin(client, login, method, path, body):
|
||||
"""Anonymous -> 401, member -> 403 (parametrized per endpoint)."""
|
||||
flow_id = uuid4()
|
||||
probes = [
|
||||
("POST", "/api/flows", {"name": "x"}),
|
||||
("POST", "/api/flows/sync", None),
|
||||
("POST", f"/api/flows/{flow_id}/versions", {"langflow_json": {}}),
|
||||
("GET", f"/api/flows/{flow_id}/versions", None),
|
||||
("POST", f"/api/flows/{flow_id}/versions/1/deploy", None),
|
||||
("POST", "/api/flows/import", {"langflow_flow_id": "x"}),
|
||||
("GET", "/api/flows/langflow/available", None),
|
||||
]
|
||||
for method, path, body in probes:
|
||||
if method == "POST":
|
||||
response = await client.post(path, json=body or {})
|
||||
else:
|
||||
response = await client.get(path)
|
||||
assert response.status_code == 401, (method, path, response.text)
|
||||
target = path.format(flow_id=flow_id)
|
||||
if method == "POST":
|
||||
response = await client.post(target, json=body or {})
|
||||
else:
|
||||
response = await client.get(target)
|
||||
assert response.status_code == 401, (method, target, response.text)
|
||||
|
||||
await login(email="member@example.com", role="member")
|
||||
for method, path, body in probes:
|
||||
if method == "POST":
|
||||
response = await client.post(path, json=body or {})
|
||||
else:
|
||||
response = await client.get(path)
|
||||
assert response.status_code == 403, (method, path, response.text)
|
||||
if method == "POST":
|
||||
response = await client.post(target, json=body or {})
|
||||
else:
|
||||
response = await client.get(target)
|
||||
assert response.status_code == 403, (method, target, response.text)
|
||||
|
||||
|
||||
async def test_runs_and_reads_require_login(client, login):
|
||||
"""Run, run reads and approval reads are login-gated (A)."""
|
||||
LOGIN_GATED_PATHS = [
|
||||
("POST", "/api/flows/{flow_id}/run", {"input": {}}),
|
||||
("GET", "/api/flows/{flow_id}/runs", None),
|
||||
("GET", "/api/flows/runs/{run_id}", None),
|
||||
("GET", "/api/approvals", None),
|
||||
("POST", "/api/approvals/{task_id}/decision", {"decision": "approve"}),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("method", "path", "body"), LOGIN_GATED_PATHS)
|
||||
async def test_runs_and_reads_require_login(client, login, anon_client, method, path, body):
|
||||
"""Run, run reads and approval reads are login-gated (parametrized)."""
|
||||
await login()
|
||||
flow = await _create_flow(client, "gated")
|
||||
run_id = uuid4()
|
||||
authed_paths = [
|
||||
("POST", f"/api/flows/{flow['id']}/run", {"input": {}}),
|
||||
("GET", f"/api/flows/{flow['id']}/runs", None),
|
||||
("GET", f"/api/flows/runs/{run_id}", None),
|
||||
("GET", "/api/approvals", None),
|
||||
("POST", f"/api/approvals/{uuid4()}/decision", {"decision": "approve"}),
|
||||
]
|
||||
# Fresh client without a session cookie.
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from app.main import app
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as anon:
|
||||
for method, path, body in authed_paths:
|
||||
if method == "POST":
|
||||
response = await anon.post(path, json=body or {})
|
||||
else:
|
||||
response = await anon.get(path)
|
||||
assert response.status_code == 401, (method, path, response.text)
|
||||
target = path.format(flow_id=flow["id"], run_id=uuid4(), task_id=uuid4())
|
||||
async for anon in anon_client():
|
||||
if method == "POST":
|
||||
response = await anon.post(target, json=body or {})
|
||||
else:
|
||||
response = await anon.get(target)
|
||||
assert response.status_code == 401, (method, target, response.text)
|
||||
|
||||
|
||||
async def test_version_validation_and_numbering(client, login):
|
||||
|
|
@ -211,12 +212,13 @@ async def test_run_stream_events(client, lfx_server, login):
|
|||
current_event = "message"
|
||||
|
||||
assert events, "expected SSE events"
|
||||
assert events[0][0] == "run"
|
||||
first_payload = json.loads(events[0][1])
|
||||
assert "run_id" in first_payload
|
||||
assert events[-1][0] == "run_finished"
|
||||
last_payload = json.loads(events[-1][1])
|
||||
assert last_payload["status"] == "completed"
|
||||
by_type = {}
|
||||
for event, data in events:
|
||||
by_type.setdefault(event, []).append(json.loads(data))
|
||||
assert "run_id" in by_type["run"][0]
|
||||
finished = [p for p in by_type.get("run_finished", []) if "status" in p]
|
||||
assert finished and finished[0]["status"] == "completed"
|
||||
first_payload = by_type["run"][0]
|
||||
|
||||
response = await client.get(f"/api/flows/runs/{first_payload['run_id']}")
|
||||
assert response.status_code == 200
|
||||
|
|
|
|||
|
|
@ -293,11 +293,54 @@ def test_embedded_code_matches_template_fields():
|
|||
field["name"] for field in template.values() if isinstance(field, dict)
|
||||
} - {"code"}
|
||||
code = template["code"]["value"]
|
||||
# Codegen-inlined core: self-contained for LFX, single truth in repo.
|
||||
assert "from kirche" not in code, filename
|
||||
assert "from ._" not in code, filename
|
||||
assert "collect_form_data" in code, filename
|
||||
assert "parse_envelope" in code, filename
|
||||
cls = validate_mod.create_class(code, class_name)
|
||||
code_names = {inp.name for inp in cls.inputs}
|
||||
assert code_names == template_names, filename
|
||||
|
||||
|
||||
def test_form_core_envelope_and_conditions():
|
||||
"""Unit tests for the shared core (single truth for all 3 forms)."""
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "components"))
|
||||
from kirche._form_core import (
|
||||
_field_conditions,
|
||||
collect_form_data,
|
||||
parse_envelope,
|
||||
)
|
||||
|
||||
payload, asked = parse_envelope('{"payload": {"a": 1}, "fields": ["a", ""]}')
|
||||
assert payload == {"a": 1} and asked == ["a"]
|
||||
payload, asked = parse_envelope('{"a": 2}')
|
||||
assert payload == {"a": 2} and asked is None
|
||||
payload, asked = parse_envelope("not json")
|
||||
assert payload == {} and asked is None
|
||||
assert _field_conditions("x [visible_if a=B]", None) == [("a", "B")]
|
||||
assert _field_conditions("x", [{"field": "a", "value": 1}]) == [("a", 1)]
|
||||
assert _field_conditions("x [visible_if a=B]", {"a": 2}) == [("a", 2)]
|
||||
effective, scope = collect_form_data(["a", "b"], {"a": 1, "b": 2}, {"b": 3}, ["a"])
|
||||
assert scope == ["a"] and effective["b"] == 3
|
||||
|
||||
|
||||
def test_kirche_node_inlines_core():
|
||||
"""Generator composes thin source + core into self-contained code."""
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts"))
|
||||
from make_flow_fixtures import _compose_component_code, _inline_core
|
||||
|
||||
core = _inline_core()
|
||||
assert "def parse_envelope" in core and "def collect_form_data" in core
|
||||
thin = "from kirche._form_core import parse_envelope\n# <-- _form_core inlined -->\nX = 1\n"
|
||||
composed = _compose_component_code(thin)
|
||||
assert "from kirche" not in composed and "def parse_envelope" in composed
|
||||
|
||||
|
||||
def test_bauvorhaben_fixture_shape():
|
||||
doc = json.loads((FIXTURES / "bauvorhaben.json").read_text(encoding="utf-8"))
|
||||
assert doc["name"] == "bauvorhaben"
|
||||
|
|
@ -332,8 +375,8 @@ def test_bauvorhaben_schema_branches():
|
|||
schema = out["json_schema"]
|
||||
assert set(schema["properties"]) == {"gebaeude", "denkmalschutz"}
|
||||
assert schema["required"] == ["gebaeude"]
|
||||
assert len(schema["allOf"]) == 5
|
||||
by_field = {next(iter(rule["then"]["properties"])): rule for rule in schema["allOf"]}
|
||||
assert len(schema["allOf"]) == len(by_field) == 5
|
||||
assert set(by_field) == {
|
||||
"bestandteil",
|
||||
"glockenmassnahme",
|
||||
|
|
@ -355,7 +398,9 @@ def test_bauvorhaben_schema_branches():
|
|||
assert "visible_if" not in out["json_schema"]["allOf"][0]["then"]["properties"][
|
||||
"bestandteil"
|
||||
].get("description", "")
|
||||
assert out["ui_schema"]["ui:order"] == [
|
||||
order = out["ui_schema"]["ui:order"]
|
||||
assert order[-1] == "*"
|
||||
assert set(order[:-1]) == {
|
||||
"gebaeude",
|
||||
"bestandteil",
|
||||
"glockenmassnahme",
|
||||
|
|
@ -363,8 +408,7 @@ def test_bauvorhaben_schema_branches():
|
|||
"gebaeudeart",
|
||||
"denkmalschutz",
|
||||
"denkmalnummer",
|
||||
"*",
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
async def test_bauvorhaben_deploy_and_run_nested_values(client, lfx_server, login):
|
||||
|
|
|
|||
|
|
@ -4,12 +4,14 @@ import json
|
|||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
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
|
||||
|
||||
FIXTURES = Path(__file__).parent / "fixtures" / "flows"
|
||||
from .conftest import FIXTURES_DIR as FIXTURES
|
||||
|
||||
|
||||
def _field(name, itype, **kw):
|
||||
|
|
@ -48,14 +50,55 @@ def _branch_schema(doc):
|
|||
return schema
|
||||
|
||||
|
||||
def test_type_mapping_table():
|
||||
@pytest.mark.parametrize(
|
||||
("itype", "kwargs", "json_type", "widget"),
|
||||
[
|
||||
("StrInput", {"value": "x"}, "string", None),
|
||||
("MessageTextInput", {}, "string", None),
|
||||
("MultilineInput", {}, "string", "textarea"),
|
||||
("SecretStrInput", {}, "string", "password"),
|
||||
("IntInput", {"value": 3}, "integer", None),
|
||||
("FloatInput", {"value": 1.5}, "number", None),
|
||||
("BoolInput", {"value": True}, "boolean", None),
|
||||
(
|
||||
"DropdownInput",
|
||||
{"options": ["Music", "Youth"], "value": "Youth"},
|
||||
"string",
|
||||
None,
|
||||
),
|
||||
(
|
||||
"MultiselectInput",
|
||||
{"options": ["Worship", "Courses"], "value": ["Worship"]},
|
||||
"array",
|
||||
None,
|
||||
),
|
||||
(
|
||||
"SliderInput",
|
||||
{"value": 3, "range_spec": {"min": 1, "max": 5, "step": 1}},
|
||||
"number",
|
||||
"range",
|
||||
),
|
||||
("FileInput", {}, "string", None),
|
||||
("FutureFancyInput", {}, "string", None),
|
||||
],
|
||||
)
|
||||
def test_type_mapping_table(itype, kwargs, json_type, widget):
|
||||
doc = _doc(_field("a", itype, **kwargs))
|
||||
schema = parser.extract_form_schema(doc)
|
||||
assert "fallback" not in schema
|
||||
prop = schema["json_schema"]["properties"]["a"]
|
||||
assert prop["type"] == json_type
|
||||
ui = schema["ui_schema"].get("a", {})
|
||||
if widget is None:
|
||||
assert "ui:widget" not in ui
|
||||
else:
|
||||
assert ui["ui:widget"] == widget
|
||||
|
||||
|
||||
def test_type_mapping_details():
|
||||
doc = _doc(
|
||||
_field("a", "StrInput", value="x"),
|
||||
_field("b", "MessageTextInput"),
|
||||
_field("c", "MultilineInput"),
|
||||
_field("d", "SecretStrInput"),
|
||||
_field("e", "IntInput", value=3),
|
||||
_field("f", "FloatInput", value=1.5),
|
||||
_field("g", "BoolInput", value=True),
|
||||
_field(
|
||||
"h",
|
||||
|
|
@ -75,16 +118,11 @@ def test_type_mapping_table():
|
|||
value=3,
|
||||
range_spec={"min": 1, "max": 5, "step": 1},
|
||||
),
|
||||
_field("k", "FileInput"),
|
||||
_field("l", "FutureFancyInput"),
|
||||
)
|
||||
schema = parser.extract_form_schema(doc)
|
||||
assert "fallback" not in schema
|
||||
props = schema["json_schema"]["properties"]
|
||||
assert props["a"] == {"title": "A", "type": "string", "default": "x"}
|
||||
assert props["b"]["type"] == "string"
|
||||
assert props["e"] == {"title": "E", "type": "integer", "default": 3}
|
||||
assert props["f"]["type"] == "number"
|
||||
assert props["g"] == {"title": "G", "type": "boolean", "default": True}
|
||||
assert props["h"] == {
|
||||
"title": "H",
|
||||
|
|
@ -98,12 +136,6 @@ def test_type_mapping_table():
|
|||
assert props["j"]["minimum"] == 1
|
||||
assert props["j"]["maximum"] == 5
|
||||
assert props["j"]["multipleOf"] == 1
|
||||
assert props["k"]["type"] == "string"
|
||||
assert props["l"]["type"] == "string"
|
||||
ui = schema["ui_schema"]
|
||||
assert ui["c"] == {"ui:widget": "textarea"}
|
||||
assert ui["d"] == {"ui:widget": "password"}
|
||||
assert ui["j"] == {"ui:widget": "range"}
|
||||
|
||||
|
||||
def test_dropdown_default_outside_enum_omitted():
|
||||
|
|
@ -122,7 +154,9 @@ def test_required_advanced_hidden_code():
|
|||
schema = parser.extract_form_schema(doc)
|
||||
assert schema["json_schema"]["required"] == ["a"]
|
||||
assert set(schema["json_schema"]["properties"]) == {"a", "b"}
|
||||
assert schema["ui_schema"]["ui:order"] == ["a", "b", "*"]
|
||||
order = schema["ui_schema"]["ui:order"]
|
||||
assert order[-1] == "*"
|
||||
assert set(order[:-1]) == {"a", "b"}
|
||||
|
||||
|
||||
def test_display_name_match_and_fallback():
|
||||
|
|
@ -260,7 +294,9 @@ def test_visible_if_single_branch():
|
|||
assert rule["then"]["required"] == ["bestandteil"]
|
||||
# Marker stripped from the shown description.
|
||||
assert schema["properties"]["gebaeude"]["title"] == "Gebaeude"
|
||||
assert out["ui_schema"]["ui:order"] == ["gebaeude", "bestandteil", "*"]
|
||||
order = out["ui_schema"]["ui:order"]
|
||||
assert order[-1] == "*"
|
||||
assert set(order[:-1]) == {"gebaeude", "bestandteil"}
|
||||
|
||||
|
||||
def test_visible_if_nested_three_levels():
|
||||
|
|
|
|||
|
|
@ -38,7 +38,8 @@ async def test_oversize_body_rejected(client):
|
|||
async def test_run_throttle_returns_429(client, lfx_server, login, monkeypatch):
|
||||
from app.settings import settings
|
||||
|
||||
monkeypatch.setattr(settings, "run_throttle_per_minute", 2)
|
||||
limit = 2
|
||||
monkeypatch.setattr(settings, "run_throttle_per_minute", limit)
|
||||
await login()
|
||||
|
||||
flow = (await client.post("/api/flows", json={"name": "throttled"})).json()
|
||||
|
|
@ -65,7 +66,7 @@ async def test_run_throttle_returns_429(client, lfx_server, login, monkeypatch):
|
|||
},
|
||||
)
|
||||
# Undeployed: 422s still pass the throttle (only 429 matters here).
|
||||
for _ in range(2):
|
||||
for _ in range(limit):
|
||||
response = await client.post(f"/api/flows/{flow['id']}/run", json={"input": {}})
|
||||
assert response.status_code == 422
|
||||
response = await client.post(f"/api/flows/{flow['id']}/run", json={"input": {}})
|
||||
|
|
|
|||
|
|
@ -8,6 +8,9 @@ from app.models import ApprovalTask, FlowDefinition, FlowRun, FlowVersion
|
|||
|
||||
|
||||
def test_flow_definition_defaults(session):
|
||||
from datetime import UTC, datetime
|
||||
|
||||
before = datetime.now(UTC).replace(tzinfo=None)
|
||||
d = FlowDefinition(name="bauvorhaben")
|
||||
session.add(d)
|
||||
session.commit()
|
||||
|
|
@ -18,7 +21,8 @@ def test_flow_definition_defaults(session):
|
|||
assert d.created_at is not None
|
||||
# Timestamps are written as UTC. SQLite does not round-trip tzinfo, so
|
||||
# after a refresh the value is calendar-time UTC without tzinfo.
|
||||
assert d.created_at.year >= 2026
|
||||
assert d.created_at.tzinfo is None
|
||||
assert abs((d.created_at - before).total_seconds()) < 60
|
||||
|
||||
|
||||
def test_flow_definition_name_unique(session):
|
||||
|
|
|
|||
|
|
@ -14,22 +14,24 @@ from app.flows.schemas import SyncReport
|
|||
from app.flows.sync import lfx_id_for
|
||||
from app.settings import settings
|
||||
|
||||
FIXTURES = Path(__file__).parent / "fixtures" / "flows"
|
||||
|
||||
|
||||
def load_fixture(name: str) -> dict:
|
||||
return json.loads((FIXTURES / name).read_text())
|
||||
|
||||
|
||||
def _lfx() -> LfxClient:
|
||||
return LfxClient(settings.lfx_url, api_key=settings.lfx_api_key or None)
|
||||
from .conftest import FIXTURES_DIR, langflow_export_doc, load_flow_fixture
|
||||
|
||||
|
||||
def _seed_dir(tmp_path: Path, name: str = "simple_rule.json") -> Path:
|
||||
shutil.copy(FIXTURES / name, tmp_path / name)
|
||||
shutil.copy(FIXTURES_DIR / name, tmp_path / name)
|
||||
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):
|
||||
_seed_dir(tmp_path)
|
||||
report = await sync.sync_from_directory(session, _lfx(), tmp_path)
|
||||
|
|
@ -145,17 +147,8 @@ async def test_sync_endpoint_returns_report(
|
|||
|
||||
|
||||
def _langflow_doc(name: str, langflow_id: str) -> dict:
|
||||
doc = load_fixture("simple_rule.json")
|
||||
return {
|
||||
"id": langflow_id,
|
||||
"name": name,
|
||||
"description": "aus Langflow",
|
||||
"endpoint_name": None,
|
||||
"data": doc["data"],
|
||||
"tags": [],
|
||||
"webhook": False,
|
||||
"is_component": False,
|
||||
}
|
||||
"""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(
|
||||
|
|
@ -227,24 +220,26 @@ async def test_sync_endpoint_pull_error_continues(
|
|||
assert "kaputt-id" in body["errors"]
|
||||
|
||||
|
||||
async def test_sync_endpoint_langflow_unreachable(
|
||||
client, lfx_server, tmp_path, monkeypatch, login, override_langflow
|
||||
@pytest.mark.parametrize(
|
||||
("list_status", "expected_status"),
|
||||
[(0, 503), (500, 502)],
|
||||
)
|
||||
async def test_sync_endpoint_langflow_failures(
|
||||
client,
|
||||
lfx_server,
|
||||
tmp_path,
|
||||
monkeypatch,
|
||||
login,
|
||||
override_langflow,
|
||||
list_status,
|
||||
expected_status,
|
||||
):
|
||||
"""Listing failures map to 503/502 (parametrized)."""
|
||||
await login()
|
||||
monkeypatch.setattr(settings, "flows_dir", str(tmp_path))
|
||||
override_langflow(flows=[], list_status=0)
|
||||
override_langflow(flows=[], list_status=list_status)
|
||||
response = await client.post("/api/flows/sync")
|
||||
assert response.status_code == 503
|
||||
|
||||
|
||||
async def test_sync_endpoint_langflow_error(
|
||||
client, lfx_server, tmp_path, monkeypatch, login, override_langflow
|
||||
):
|
||||
await login()
|
||||
monkeypatch.setattr(settings, "flows_dir", str(tmp_path))
|
||||
override_langflow(flows=[], list_status=500)
|
||||
response = await client.post("/api/flows/sync")
|
||||
assert response.status_code == 502
|
||||
assert response.status_code == expected_status
|
||||
|
||||
|
||||
async def test_watch_forever_triggers_on_file_change(tmp_path):
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue