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
|
|
@ -126,17 +126,14 @@ def consume_magic(
|
|||
session: Annotated[Session, Depends(get_db)],
|
||||
):
|
||||
try:
|
||||
user_session = service.consume_magic_token(session, data.token)
|
||||
user, session_token = service.consume_magic_token(session, data.token)
|
||||
except (
|
||||
service.InvalidTokenError,
|
||||
service.ExpiredTokenError,
|
||||
service.UsedTokenError,
|
||||
) as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
user = service.get_user(session, user_session.user_id)
|
||||
if user is None: # pragma: no cover - consume already validated the user
|
||||
raise HTTPException(status_code=400, detail="unknown token")
|
||||
_set_session_cookie(response, user_session.id)
|
||||
_set_session_cookie(response, session_token)
|
||||
return _read_me(user)
|
||||
|
||||
|
||||
|
|
@ -151,11 +148,8 @@ def logout(
|
|||
response: Response,
|
||||
session: Annotated[Session, Depends(get_db)],
|
||||
):
|
||||
# Revoke the server-side row when the cookie maps to a live session;
|
||||
# always clear the client cookie.
|
||||
session_id = request.cookies.get(SESSION_COOKIE)
|
||||
if session_id:
|
||||
service.revoke_session(session, session_id)
|
||||
# Stateless sessions: nothing server-side to revoke — always clear
|
||||
# the client cookie.
|
||||
response.delete_cookie(SESSION_COOKIE, path="/")
|
||||
return MessageRead(message="logged out")
|
||||
|
||||
|
|
|
|||
|
|
@ -19,13 +19,10 @@ SESSION_MAX_AGE = int(service.SESSION_TTL.total_seconds())
|
|||
def get_optional_user(
|
||||
request: Request, session: Annotated[Session, Depends(get_db)]
|
||||
) -> User | None:
|
||||
session_id = request.cookies.get(SESSION_COOKIE)
|
||||
if not session_id:
|
||||
token = request.cookies.get(SESSION_COOKIE)
|
||||
if not token:
|
||||
return None
|
||||
record = service.get_valid_session(session, session_id)
|
||||
if record is None:
|
||||
return None
|
||||
return service.get_user(session, record.user_id)
|
||||
return service.get_token_user(session, token)
|
||||
|
||||
|
||||
def get_current_user(request: Request, session: Annotated[Session, Depends(get_db)]) -> User:
|
||||
|
|
|
|||
|
|
@ -9,8 +9,10 @@ fonts, no external images (many clients block them / track opens).
|
|||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import smtplib
|
||||
from email.message import EmailMessage
|
||||
from pathlib import Path
|
||||
|
||||
from jinja2 import Template
|
||||
|
||||
from app.settings import settings
|
||||
|
||||
|
|
@ -22,67 +24,62 @@ BLUE_LIGHT = "#eef4fb"
|
|||
INK = "#162f4c"
|
||||
MUTED = "#5b6b7c"
|
||||
|
||||
_TEMPLATE_PATH = Path(__file__).resolve().parent / "templates" / "magic_link.html"
|
||||
_template_cache: Template | None = None
|
||||
|
||||
|
||||
def _template() -> Template:
|
||||
global _template_cache
|
||||
if _template_cache is None:
|
||||
_template_cache = Template(_TEMPLATE_PATH.read_text(encoding="utf-8"))
|
||||
return _template_cache
|
||||
|
||||
|
||||
def magic_link(token: str) -> str:
|
||||
return f"{settings.frontend_url.rstrip('/')}/login?token={token}"
|
||||
|
||||
|
||||
def _page(title: str, intro: str, button_label: str, button_url: str | None, note: str) -> str:
|
||||
"""Branded HTML mail body (table layout, inline styles, no images)."""
|
||||
button = ""
|
||||
if button_url:
|
||||
button = (
|
||||
'<table role="presentation" cellpadding="0" cellspacing="0" '
|
||||
'style="margin:24px auto 8px auto;"><tr>'
|
||||
f'<td bgcolor="{BLUE}" style="border-radius:8px;">'
|
||||
f'<a href="{button_url}" '
|
||||
'style="display:inline-block;padding:12px 28px;font-family:-apple-system,'
|
||||
"Segoe UI,Roboto,Helvetica,Arial,sans-serif;font-size:15px;"
|
||||
"font-weight:600;color:#ffffff;text-decoration:none;"
|
||||
f'border-radius:8px;">{button_label}</a></td></tr></table>'
|
||||
)
|
||||
return f"""<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<body style="margin:0;padding:0;background-color:{BLUE_LIGHT};">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0"
|
||||
style="background-color:{BLUE_LIGHT};padding:24px 12px;">
|
||||
<tr><td align="center">
|
||||
<table role="presentation" width="520" cellpadding="0" cellspacing="0"
|
||||
style="max-width:520px;width:100%;font-family:Georgia,'Times New Roman',serif;
|
||||
border-radius:12px;overflow:hidden;border:1px solid #d9e6f5;">
|
||||
<tr>
|
||||
<td bgcolor="{BLUE}" style="padding:20px 28px;">
|
||||
<span style="color:#ffffff;font-size:18px;font-weight:700;
|
||||
letter-spacing:0.02em;">Kirche Einfach</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td bgcolor="#ffffff" style="padding:28px 28px 24px 28px;">
|
||||
<h1 style="margin:0 0 12px 0;font-size:22px;line-height:1.3;
|
||||
color:{INK};">{title}</h1>
|
||||
<p style="margin:0;font-family:-apple-system,Segoe UI,Roboto,
|
||||
Helvetica,Arial,sans-serif;font-size:15px;line-height:1.6;
|
||||
color:{MUTED};">{intro}</p>
|
||||
{button}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<p style="font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;
|
||||
font-size:12px;color:{MUTED};margin:16px 0 0 0;max-width:520px;">
|
||||
{note}
|
||||
</p>
|
||||
</td></tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>"""
|
||||
"""Branded HTML mail body (jinja template, byte-identical to the old f-string)."""
|
||||
return _template().render(
|
||||
title=title,
|
||||
intro=intro,
|
||||
button_label=button_label,
|
||||
button_url=button_url,
|
||||
note=note,
|
||||
blue=BLUE,
|
||||
blue_light=BLUE_LIGHT,
|
||||
ink=INK,
|
||||
muted=MUTED,
|
||||
)
|
||||
|
||||
|
||||
def _send(message: EmailMessage) -> None:
|
||||
with smtplib.SMTP(settings.smtp_host, settings.smtp_port, timeout=10) as smtp:
|
||||
smtp.starttls()
|
||||
if settings.smtp_username:
|
||||
smtp.login(settings.smtp_username, settings.smtp_password)
|
||||
smtp.send_message(message)
|
||||
"""Deliver via fastapi-mail (same never-raise contract at call sites)."""
|
||||
import asyncio
|
||||
|
||||
from fastapi_mail import ConnectionConfig, FastMail, MessageSchema, MessageType
|
||||
|
||||
config = ConnectionConfig(
|
||||
MAIL_USERNAME=settings.smtp_username,
|
||||
MAIL_PASSWORD=settings.smtp_password,
|
||||
MAIL_FROM=settings.mail_from or settings.contact_to or message["To"],
|
||||
MAIL_PORT=settings.smtp_port,
|
||||
MAIL_SERVER=settings.smtp_host,
|
||||
MAIL_STARTTLS=True,
|
||||
MAIL_SSL_TLS=False,
|
||||
USE_CREDENTIALS=bool(settings.smtp_username),
|
||||
)
|
||||
html = message.get_body(preferencelist=("html",))
|
||||
plain = message.get_body(preferencelist=("plain",))
|
||||
outgoing = MessageSchema(
|
||||
subject=str(message["Subject"]),
|
||||
recipients=[str(message["To"])],
|
||||
body=plain.get_content() if plain else "",
|
||||
html=html.get_content() if html else "",
|
||||
subtype=MessageType.html,
|
||||
)
|
||||
asyncio.run(FastMail(config).send_message(outgoing))
|
||||
|
||||
|
||||
def _branded_mail(
|
||||
|
|
@ -142,7 +139,7 @@ def send_magic_link(to_email: str, link: str) -> bool:
|
|||
)
|
||||
try:
|
||||
_send(message)
|
||||
except (OSError, smtplib.SMTPException):
|
||||
except Exception: # noqa: BLE001 -- never-raise contract: delivery must not 500
|
||||
logger.warning("magic-link delivery failed for %s", to_email)
|
||||
return False
|
||||
logger.info("magic link mailed to %s", to_email)
|
||||
|
|
@ -167,7 +164,7 @@ def send_access_notification(request_id: str, name: str, email: str) -> bool:
|
|||
)
|
||||
try:
|
||||
_send(message)
|
||||
except (OSError, smtplib.SMTPException):
|
||||
except Exception: # noqa: BLE001 -- best-effort notification, never raises
|
||||
logger.warning("access-request notification failed for %s", request_id)
|
||||
return False
|
||||
return True
|
||||
|
|
|
|||
|
|
@ -1,7 +1,12 @@
|
|||
"""Access requests, magic-link tokens, and sessions (DB via function args).
|
||||
"""Access requests, magic-link tokens, and stateless JWT sessions.
|
||||
|
||||
Keep API handlers thin: all persistence lives here. Tokens and session ids
|
||||
are bearer secrets — log user/request ids only, never secret values.
|
||||
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).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -9,12 +14,14 @@ from __future__ import annotations
|
|||
import hashlib
|
||||
import logging
|
||||
import secrets
|
||||
import uuid
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from uuid import UUID
|
||||
|
||||
import jwt
|
||||
from sqlmodel import Session, func, select
|
||||
|
||||
from app.models import AccessRequest, MagicLinkToken, User, UserSession
|
||||
from app.models import AccessRequest, MagicLinkToken, User
|
||||
from app.models.base import utcnow
|
||||
from app.settings import settings
|
||||
|
||||
|
|
@ -235,8 +242,43 @@ def _aware(value: datetime) -> datetime:
|
|||
return value
|
||||
|
||||
|
||||
def consume_magic_token(session: Session, token: str) -> UserSession:
|
||||
"""Single-use consume → session row. used_at is set before creation."""
|
||||
def _jwt_secret() -> str:
|
||||
secret = settings.jwt_secret
|
||||
if not secret:
|
||||
raise RuntimeError("JWT_SECRET is empty — set it in .env")
|
||||
return secret
|
||||
|
||||
|
||||
def mint_session_token(user: User) -> str:
|
||||
"""Issue a stateless session JWT for a user (30-day expiry)."""
|
||||
now = utcnow()
|
||||
return jwt.encode(
|
||||
{
|
||||
"sub": str(user.id),
|
||||
"email": user.email,
|
||||
"role": user.role,
|
||||
"tenant_id": user.tenant_id,
|
||||
"iat": int(now.timestamp()),
|
||||
"exp": int((now + SESSION_TTL).timestamp()),
|
||||
"jti": uuid.uuid4().hex,
|
||||
},
|
||||
_jwt_secret(),
|
||||
algorithm="HS256",
|
||||
)
|
||||
|
||||
|
||||
def decode_session_token(token: str) -> dict:
|
||||
"""Verify a session JWT → claims. Expired/forged → token errors."""
|
||||
try:
|
||||
return jwt.decode(token.strip(), _jwt_secret(), algorithms=["HS256"])
|
||||
except jwt.ExpiredSignatureError as exc:
|
||||
raise ExpiredTokenError("session expired") from exc
|
||||
except jwt.InvalidTokenError as exc:
|
||||
raise InvalidTokenError("invalid session") from exc
|
||||
|
||||
|
||||
def consume_magic_token(session: Session, token: str) -> tuple[User, str]:
|
||||
"""Single-use consume → (user, session JWT). used_at is set first."""
|
||||
record = session.exec(
|
||||
select(MagicLinkToken).where(MagicLinkToken.token_hash == hash_token(token.strip()))
|
||||
).first()
|
||||
|
|
@ -252,33 +294,23 @@ def consume_magic_token(session: Session, token: str) -> UserSession:
|
|||
# Mark used first: a reused token can never mint a second session.
|
||||
record.used_at = utcnow()
|
||||
session.add(record)
|
||||
user_session = UserSession(
|
||||
id=secrets.token_urlsafe(32),
|
||||
user_id=user.id,
|
||||
expires_at=utcnow() + SESSION_TTL,
|
||||
)
|
||||
session.add(user_session)
|
||||
session.commit()
|
||||
session.refresh(user_session)
|
||||
session.refresh(user)
|
||||
logger.info("magic token consumed for user %s", user.id)
|
||||
return user_session
|
||||
return user, mint_session_token(user)
|
||||
|
||||
|
||||
def get_valid_session(session: Session, session_id: str) -> UserSession | None:
|
||||
record = session.get(UserSession, session_id)
|
||||
if record is None or record.revoked_at is not None:
|
||||
def get_token_user(session: Session, token: str) -> User | None:
|
||||
"""User for a session JWT, or None (expired/forged/deleted user)."""
|
||||
try:
|
||||
claims = decode_session_token(token)
|
||||
except (InvalidTokenError, ExpiredTokenError):
|
||||
return None
|
||||
if _aware(record.expires_at) <= utcnow():
|
||||
try:
|
||||
user_id = UUID(str(claims.get("sub", "")))
|
||||
except ValueError:
|
||||
return None
|
||||
return record
|
||||
|
||||
|
||||
def revoke_session(session: Session, session_id: str) -> None:
|
||||
record = session.get(UserSession, session_id)
|
||||
if record is not None and record.revoked_at is None:
|
||||
record.revoked_at = utcnow()
|
||||
session.add(record)
|
||||
session.commit()
|
||||
return session.get(User, user_id)
|
||||
|
||||
|
||||
def bootstrap_admin(session: Session, email: str) -> tuple[User, str]:
|
||||
|
|
|
|||
42
backend/app/auth/templates/magic_link.html
Normal file
42
backend/app/auth/templates/magic_link.html
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<body style="margin:0;padding:0;background-color:{{ blue_light }};">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0"
|
||||
style="background-color:{{ blue_light }};padding:24px 12px;">
|
||||
<tr><td align="center">
|
||||
<table role="presentation" width="520" cellpadding="0" cellspacing="0"
|
||||
style="max-width:520px;width:100%;font-family:Georgia,'Times New Roman',serif;
|
||||
border-radius:12px;overflow:hidden;border:1px solid #d9e6f5;">
|
||||
<tr>
|
||||
<td bgcolor="{{ blue }}" style="padding:20px 28px;">
|
||||
<span style="color:#ffffff;font-size:18px;font-weight:700;
|
||||
letter-spacing:0.02em;">Kirche Einfach</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td bgcolor="#ffffff" style="padding:28px 28px 24px 28px;">
|
||||
<h1 style="margin:0 0 12px 0;font-size:22px;line-height:1.3;
|
||||
color:{{ ink }};">{{ title }}</h1>
|
||||
<p style="margin:0;font-family:-apple-system,Segoe UI,Roboto,
|
||||
Helvetica,Arial,sans-serif;font-size:15px;line-height:1.6;
|
||||
color:{{ muted }};">{{ intro }}</p>
|
||||
{% if button_url %}
|
||||
<table role="presentation" cellpadding="0" cellspacing="0"
|
||||
style="margin:24px auto 8px auto;"><tr>
|
||||
<td bgcolor="{{ blue }}" style="border-radius:8px;">
|
||||
<a href="{{ button_url }}"
|
||||
style="display:inline-block;padding:12px 28px;font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;font-size:15px;font-weight:600;color:#ffffff;text-decoration:none;border-radius:8px;">{{ button_label }}</a>
|
||||
</td>
|
||||
</tr></table>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<p style="font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;
|
||||
font-size:12px;color:{{ muted }};margin:16px 0 0 0;max-width:520px;">
|
||||
{{ note }}
|
||||
</p>
|
||||
</td></tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -17,12 +17,13 @@ from pathlib import Path
|
|||
from typing import Annotated
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, Query
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, Query, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlmodel import Session
|
||||
|
||||
from app.auth.deps import get_current_user, require_admin
|
||||
from app.db import get_db
|
||||
from app.middleware import limiter, run_limit
|
||||
from app.models import User
|
||||
from app.settings import settings
|
||||
|
||||
|
|
@ -50,6 +51,7 @@ from .schemas import (
|
|||
FlowVersionCreate,
|
||||
FlowVersionRead,
|
||||
InputSchemaRead,
|
||||
NamedRunRead,
|
||||
RunRead,
|
||||
RunRequest,
|
||||
SyncReport,
|
||||
|
|
@ -58,6 +60,7 @@ from .sync import lfx_id_for
|
|||
|
||||
flows_router = APIRouter(prefix="/api/flows", tags=["flows"])
|
||||
approvals_router = APIRouter(prefix="/api/approvals", tags=["approvals"])
|
||||
runs_router = APIRouter(prefix="/api/runs", tags=["runs"])
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -211,7 +214,9 @@ def _resolve_version(session: Session, flow_id: UUID, version_no: int | None):
|
|||
|
||||
|
||||
@flows_router.post("/{flow_id}/run", response_model=RunRead)
|
||||
@limiter.limit(run_limit)
|
||||
async def run_flow(
|
||||
request: Request,
|
||||
flow_id: UUID,
|
||||
data: RunRequest,
|
||||
session: Annotated[Session, Depends(get_db)],
|
||||
|
|
@ -279,6 +284,18 @@ async def _run_stream(
|
|||
return StreamingResponse(event_gen(), media_type="text/event-stream")
|
||||
|
||||
|
||||
@runs_router.get("", response_model=list[NamedRunRead])
|
||||
def list_all_runs(
|
||||
session: Annotated[Session, Depends(get_db)],
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
):
|
||||
"""All runs of the session tenant with flow names (single query)."""
|
||||
return [
|
||||
NamedRunRead(flow_name=name, run=_read_run(run))
|
||||
for run, name in store.list_all_runs(session, user.tenant_id)
|
||||
]
|
||||
|
||||
|
||||
@flows_router.get("/{flow_id}/runs", response_model=list[RunRead])
|
||||
def list_runs(
|
||||
flow_id: UUID,
|
||||
|
|
|
|||
|
|
@ -2,12 +2,19 @@
|
|||
|
||||
Consolidates base_url stripping, header building, per-request client
|
||||
construction, and HTTPError → domain-error mapping. Subclasses only
|
||||
declare their error types and endpoint calls.
|
||||
declare their error types and endpoint calls. Transport-level failures
|
||||
(connect/timeout, never 4xx) retry with exponential backoff via tenacity.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
from tenacity import (
|
||||
retry,
|
||||
retry_if_exception_type,
|
||||
stop_after_attempt,
|
||||
wait_exponential,
|
||||
)
|
||||
|
||||
|
||||
class BaseApiClient:
|
||||
|
|
@ -25,3 +32,13 @@ class BaseApiClient:
|
|||
return httpx.AsyncClient(
|
||||
base_url=self.base_url, timeout=self.timeout, headers=self._headers()
|
||||
)
|
||||
|
||||
|
||||
def transport_retry(func): # type: ignore[no-untyped-def]
|
||||
"""Retry transport errors 3x with backoff; HTTP status errors pass through."""
|
||||
return retry(
|
||||
stop=stop_after_attempt(3),
|
||||
wait=wait_exponential(multiplier=0.5, max=4),
|
||||
retry=retry_if_exception_type((httpx.TransportError, httpx.TimeoutException)),
|
||||
reraise=True,
|
||||
)(func)
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@ import hashlib
|
|||
import json
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
FLOWS_GLOB = "*.json"
|
||||
|
|
@ -20,18 +19,32 @@ FLOWS_GLOB = "*.json"
|
|||
#: validated, or shown in the frontend.
|
||||
IGNORE_FILENAME = ".flowsignore"
|
||||
|
||||
# mtime-keyed cache: the watcher re-reads per event, the file rarely changes.
|
||||
_ignore_cache: dict[str, tuple[float, set[str]]] = {}
|
||||
|
||||
|
||||
def ignored_stems(flows_dir: str | Path) -> set[str]:
|
||||
"""Stems excluded from the pipeline (empty when no ignore file)."""
|
||||
key = str(Path(flows_dir))
|
||||
try:
|
||||
mtime = (Path(flows_dir) / IGNORE_FILENAME).stat().st_mtime
|
||||
except OSError:
|
||||
_ignore_cache.pop(key, None)
|
||||
return set()
|
||||
cached = _ignore_cache.get(key)
|
||||
if cached is not None and cached[0] == mtime:
|
||||
return cached[1]
|
||||
try:
|
||||
text = (Path(flows_dir) / IGNORE_FILENAME).read_text(encoding="utf-8")
|
||||
except OSError:
|
||||
return set()
|
||||
return {
|
||||
stems = {
|
||||
line.strip()
|
||||
for line in text.splitlines()
|
||||
if line.strip() and not line.strip().startswith("#")
|
||||
}
|
||||
_ignore_cache[key] = (mtime, stems)
|
||||
return stems
|
||||
|
||||
|
||||
def filename_stem(name: str) -> str:
|
||||
|
|
@ -97,25 +110,16 @@ def find_reuse_path(flows_dir: str | Path, doc: dict) -> tuple[Path | None, bool
|
|||
def write_flow_bytes(target: Path, doc: dict) -> Path:
|
||||
"""Write a flow document to an explicit path atomically (tmp + rename).
|
||||
|
||||
The file ends up world-readable (0o644): mkstemp creates 0o600, which
|
||||
would lock out the other containers (Langflow/LFX run as different
|
||||
users and crash on unreadable seed files).
|
||||
The file ends up world-readable (0o644): atomicwrites follows the
|
||||
umask, which would lock out the other containers (Langflow/LFX run
|
||||
as different users and crash on unreadable seed files).
|
||||
"""
|
||||
from atomicwrites import atomic_write
|
||||
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd, tmp_name = tempfile.mkstemp(
|
||||
dir=str(target.parent), prefix=f".{target.stem}-", suffix=".tmp"
|
||||
)
|
||||
try:
|
||||
with os.fdopen(fd, "wb") as tmp:
|
||||
tmp.write(canonical_bytes(doc))
|
||||
os.replace(tmp_name, target)
|
||||
os.chmod(target, 0o644)
|
||||
except BaseException:
|
||||
try:
|
||||
os.unlink(tmp_name)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
with atomic_write(target, mode="wb", overwrite=True) as tmp:
|
||||
tmp.write(canonical_bytes(doc))
|
||||
os.chmod(target, 0o644)
|
||||
return target
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ from __future__ import annotations
|
|||
|
||||
import httpx
|
||||
|
||||
from .base_client import BaseApiClient
|
||||
from .base_client import BaseApiClient, transport_retry
|
||||
|
||||
|
||||
class LangflowError(Exception):
|
||||
|
|
@ -29,6 +29,7 @@ class LangflowClient(BaseApiClient):
|
|||
def __init__(self, base_url: str, api_key: str = "", timeout: float = 60.0) -> None:
|
||||
super().__init__(base_url, timeout=timeout, api_key=api_key)
|
||||
|
||||
@transport_retry
|
||||
async def list_flows(self) -> list[dict]:
|
||||
"""List flows (header mode keeps payloads small)."""
|
||||
try:
|
||||
|
|
@ -44,6 +45,7 @@ class LangflowClient(BaseApiClient):
|
|||
except httpx.HTTPError as exc:
|
||||
raise LangflowUnavailable(f"Langflow unreachable: {exc}") from exc
|
||||
|
||||
@transport_retry
|
||||
async def export_flow(self, langflow_flow_id: str) -> dict:
|
||||
"""Export one flow as Langflow JSON (FlowRead: id/name/description/data)."""
|
||||
try:
|
||||
|
|
@ -58,6 +60,7 @@ class LangflowClient(BaseApiClient):
|
|||
except httpx.HTTPError as exc:
|
||||
raise LangflowUnavailable(f"Langflow unreachable: {exc}") from exc
|
||||
|
||||
@transport_retry
|
||||
async def upsert_flow(self, doc: dict) -> dict:
|
||||
"""Create or update a flow in Langflow by name (authoring UI refresh).
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ from dataclasses import dataclass
|
|||
|
||||
import httpx
|
||||
|
||||
from .base_client import BaseApiClient
|
||||
from .base_client import BaseApiClient, transport_retry
|
||||
|
||||
|
||||
class LfxError(Exception):
|
||||
|
|
@ -56,6 +56,7 @@ class LfxClient(BaseApiClient):
|
|||
def __init__(self, base_url: str, timeout: float = 120.0, api_key: str | None = None) -> None:
|
||||
super().__init__(base_url, timeout=timeout, api_key=api_key)
|
||||
|
||||
@transport_retry
|
||||
async def health(self) -> dict:
|
||||
try:
|
||||
async with self._client() as client:
|
||||
|
|
@ -65,6 +66,7 @@ class LfxClient(BaseApiClient):
|
|||
except httpx.HTTPError as exc:
|
||||
raise LfxUnavailable(f"LFX unreachable at {self.base_url}: {exc}") from exc
|
||||
|
||||
@transport_retry
|
||||
async def list_flows(self) -> list[dict]:
|
||||
try:
|
||||
async with self._client() as client:
|
||||
|
|
@ -76,6 +78,7 @@ class LfxClient(BaseApiClient):
|
|||
except httpx.HTTPError as exc:
|
||||
raise LfxUnavailable(f"LFX unreachable at {self.base_url}: {exc}") from exc
|
||||
|
||||
@transport_retry
|
||||
async def upload_flow(
|
||||
self, flow_json: dict, *, flow_id: str | None = None, replace: bool = False
|
||||
) -> LfxUploadResult:
|
||||
|
|
@ -140,6 +143,7 @@ class LfxClient(BaseApiClient):
|
|||
except httpx.HTTPError as exc:
|
||||
raise LfxUnavailable(f"LFX unreachable at {self.base_url}: {exc}") from exc
|
||||
|
||||
@transport_retry
|
||||
async def flow_info(self, lfx_flow_id: str) -> dict:
|
||||
try:
|
||||
async with self._client() as client:
|
||||
|
|
@ -151,6 +155,7 @@ class LfxClient(BaseApiClient):
|
|||
except httpx.HTTPError as exc:
|
||||
raise LfxUnavailable(f"LFX unreachable at {self.base_url}: {exc}") from exc
|
||||
|
||||
@transport_retry
|
||||
async def delete_flow(self, lfx_flow_id: str) -> None:
|
||||
try:
|
||||
async with self._client() as client:
|
||||
|
|
|
|||
|
|
@ -76,6 +76,13 @@ class RunRead(BaseModel):
|
|||
finished_at: datetime | None
|
||||
|
||||
|
||||
class NamedRunRead(BaseModel):
|
||||
"""A run with its flow name (GET /api/runs aggregate, kills N+1)."""
|
||||
|
||||
flow_name: str
|
||||
run: RunRead
|
||||
|
||||
|
||||
class ApprovalCreate(BaseModel):
|
||||
"""Callback contract: a flow reports a terminal approval state."""
|
||||
|
||||
|
|
|
|||
|
|
@ -129,12 +129,18 @@ def validate_flow_json(flow_json: dict) -> None:
|
|||
raise InvalidError("langflow_json.data must contain 'nodes' and 'edges' lists")
|
||||
|
||||
|
||||
def create_version(session: Session, flow_id: UUID, data: FlowVersionCreate) -> FlowVersion:
|
||||
def create_version(
|
||||
session: Session,
|
||||
flow_id: UUID,
|
||||
data: FlowVersionCreate,
|
||||
*,
|
||||
version_no: int | None = None,
|
||||
) -> FlowVersion:
|
||||
get_definition(session, flow_id)
|
||||
validate_flow_json(data.langflow_json)
|
||||
version = FlowVersion(
|
||||
flow_id=flow_id,
|
||||
version_no=next_version_no(session, flow_id),
|
||||
version_no=version_no if version_no is not None else next_version_no(session, flow_id),
|
||||
langflow_json=data.langflow_json,
|
||||
author=data.author,
|
||||
# Single choke point: sync, import, and API creation all cache the
|
||||
|
|
@ -256,6 +262,18 @@ def list_runs(session: Session, flow_id: UUID, tenant_id: str | None = None) ->
|
|||
return list(session.exec(query).all())
|
||||
|
||||
|
||||
def list_all_runs(session: Session, tenant_id: str) -> list[tuple[FlowRun, str]]:
|
||||
"""All runs of a tenant with flow names (single query, kills N+1)."""
|
||||
rows = session.exec(
|
||||
select(FlowRun, FlowDefinition.name)
|
||||
.join(FlowVersion, FlowVersion.id == FlowRun.version_id)
|
||||
.join(FlowDefinition, FlowDefinition.id == FlowVersion.flow_id)
|
||||
.where(FlowRun.tenant_id == tenant_id)
|
||||
.order_by(FlowRun.created_at.desc())
|
||||
).all()
|
||||
return [(run, name) for run, name in rows]
|
||||
|
||||
|
||||
def create_approval_task(session: Session, data: ApprovalCreate) -> ApprovalTask:
|
||||
run = session.get(FlowRun, data.run_id)
|
||||
if run is None:
|
||||
|
|
|
|||
|
|
@ -64,8 +64,8 @@ async def sync_document(
|
|||
"skipped" (content unchanged). Raises store.ConflictError on name
|
||||
clashes, store.InvalidError on invalid JSON, LfxError when deploy fails.
|
||||
With deploy=False a version is recorded without touching LFX.
|
||||
Validation lives in store.create_version (single choke point).
|
||||
"""
|
||||
store.validate_flow_json(doc)
|
||||
name = doc.get("name") or Path(source_filename).stem
|
||||
with sync_lock:
|
||||
definition = store.get_definition_by_source_path(session, source_filename)
|
||||
|
|
@ -102,9 +102,8 @@ async def sync_document(
|
|||
session,
|
||||
definition.id,
|
||||
FlowVersionCreate(langflow_json=doc, author=author),
|
||||
version_no=version_no,
|
||||
)
|
||||
if version.version_no != version_no: # pragma: no cover - lock prevents this
|
||||
raise store.ConflictError(f"concurrent version write for flow {definition.id}")
|
||||
version = store.mark_deployed(session, version, uploaded.id)
|
||||
if latest is not None and latest.lfx_flow_id and latest.lfx_flow_id != uploaded.id:
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -4,13 +4,16 @@ from collections.abc import AsyncIterator
|
|||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
from slowapi.errors import RateLimitExceeded
|
||||
from slowapi.middleware import SlowAPIMiddleware
|
||||
from starlette.middleware.body_limit import RequestBodyLimitMiddleware
|
||||
|
||||
from .auth.api import access_router, auth_router
|
||||
from .flows.api import approvals_router, flows_router
|
||||
from .flows.api import approvals_router, flows_router, runs_router
|
||||
from .flows.sync import format_report, run_sync_blocking, watch_forever
|
||||
from .middleware import RunThrottleMiddleware, SecurityHeadersMiddleware
|
||||
from .middleware import SecurityHeadersMiddleware, limiter
|
||||
from .settings import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -64,12 +67,23 @@ app = FastAPI(
|
|||
# entrypoint.sh) trusts its forwarding headers so Secure cookies, client
|
||||
# IPs (throttle) and absolute URLs are correct. The API is not directly
|
||||
# internet-reachable (proxy-only), so trusting them is safe.
|
||||
app.state.limiter = limiter
|
||||
app.add_middleware(RequestBodyLimitMiddleware, max_body_size=settings.max_body_bytes)
|
||||
app.add_middleware(RunThrottleMiddleware)
|
||||
app.add_middleware(SlowAPIMiddleware)
|
||||
app.add_middleware(SecurityHeadersMiddleware)
|
||||
|
||||
|
||||
@app.exception_handler(RateLimitExceeded)
|
||||
async def _throttle_handler(request: Request, exc: RateLimitExceeded) -> JSONResponse:
|
||||
response = JSONResponse({"detail": "too many runs, try again later"}, status_code=429)
|
||||
retry_after = getattr(exc, "retry_after", None) or "60"
|
||||
response.headers["Retry-After"] = str(retry_after)
|
||||
return response
|
||||
|
||||
|
||||
app.include_router(flows_router)
|
||||
app.include_router(approvals_router)
|
||||
app.include_router(runs_router)
|
||||
app.include_router(access_router)
|
||||
app.include_router(auth_router)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,79 +1,59 @@
|
|||
"""Edge hardening: run throttling, security headers.
|
||||
"""Edge hardening: run throttling (slowapi), security headers (secure).
|
||||
|
||||
Body caps come from Starlette's RequestBodyLimitMiddleware (wired in
|
||||
main.py). All limits are settings-driven so tests can shrink them. State
|
||||
is in-process memory: correct for the single-replica SQLite deployment;
|
||||
revisit with Redis if the API ever scales horizontally.
|
||||
main.py). The run throttle is settings-driven so tests can shrink it.
|
||||
State is in-process memory: correct for the single-replica SQLite
|
||||
deployment; revisit with Redis if the API ever scales horizontally.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from collections import defaultdict
|
||||
|
||||
from slowapi import Limiter
|
||||
from slowapi.util import get_remote_address
|
||||
from starlette.datastructures import MutableHeaders
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import JSONResponse
|
||||
from starlette.types import ASGIApp, Message, Receive, Scope, Send
|
||||
|
||||
from app.settings import settings
|
||||
|
||||
# POST .../run is the expensive endpoint (LFX execution, LLM cost):
|
||||
# per client IP and minute.
|
||||
RUN_PATH_SUFFIX = "/run"
|
||||
THROTTLE_WINDOW_SECONDS = 60.0
|
||||
# per client IP and minute, limit read live from settings so tests can
|
||||
# shrink it without re-creating the app.
|
||||
limiter = Limiter(
|
||||
key_func=get_remote_address,
|
||||
storage_uri="memory://",
|
||||
default_limits=[],
|
||||
)
|
||||
|
||||
_hits: dict[str, list[float]] = defaultdict(list)
|
||||
|
||||
def run_limit() -> str:
|
||||
return f"{settings.run_throttle_per_minute}/minute"
|
||||
|
||||
|
||||
def reset_throttle() -> None:
|
||||
"""Drop all counters (tests)."""
|
||||
_hits.clear()
|
||||
|
||||
|
||||
def _client_ip(request: Request) -> str:
|
||||
# Behind the TLS proxy ProxyHeadersMiddleware has already rewritten
|
||||
# scope["client"] from X-Forwarded-For.
|
||||
if request.client is not None:
|
||||
return request.client.host
|
||||
forwarded = request.headers.get("x-forwarded-for", "")
|
||||
return forwarded.split(",")[0].strip() or "unknown"
|
||||
|
||||
|
||||
class RunThrottleMiddleware:
|
||||
"""Per-IP sliding window on POST .../run (429 + Retry-After)."""
|
||||
|
||||
def __init__(self, app: ASGIApp) -> None:
|
||||
self.app = app
|
||||
|
||||
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
||||
if (
|
||||
scope["type"] != "http"
|
||||
or scope.get("method") != "POST"
|
||||
or not scope.get("path", "").endswith(RUN_PATH_SUFFIX)
|
||||
):
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
request = Request(scope)
|
||||
now = time.monotonic()
|
||||
window_start = now - THROTTLE_WINDOW_SECONDS
|
||||
bucket = [t for t in _hits[_client_ip(request)] if t > window_start]
|
||||
if len(bucket) >= settings.run_throttle_per_minute:
|
||||
retry_after = max(1, int(bucket[0] + THROTTLE_WINDOW_SECONDS - now))
|
||||
response = JSONResponse({"detail": "too many runs, try again later"}, status_code=429)
|
||||
response.headers["Retry-After"] = str(retry_after)
|
||||
await response(scope, receive, send)
|
||||
return
|
||||
bucket.append(now)
|
||||
_hits[_client_ip(request)] = bucket
|
||||
await self.app(scope, receive, send)
|
||||
try:
|
||||
limiter._storage.reset() # type: ignore[attr-defined]
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
|
||||
class SecurityHeadersMiddleware:
|
||||
"""Baseline response headers (TLS/HSTS live on the proxy)."""
|
||||
"""Baseline response headers (TLS/HSTS live on the proxy).
|
||||
|
||||
Built with the ``secure`` package; values stay byte-identical to the
|
||||
previous hand-rolled version (see test_middleware).
|
||||
"""
|
||||
|
||||
def __init__(self, app: ASGIApp) -> None:
|
||||
import secure as secure_lib
|
||||
|
||||
self.app = app
|
||||
self.secure_headers = secure_lib.Secure(
|
||||
xcto=secure_lib.XContentTypeOptions().nosniff(),
|
||||
xfo=secure_lib.XFrameOptions().sameorigin(),
|
||||
referrer=secure_lib.ReferrerPolicy().strict_origin_when_cross_origin(),
|
||||
csp=secure_lib.ContentSecurityPolicy().frame_ancestors("'self'"),
|
||||
)
|
||||
|
||||
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
||||
if scope["type"] != "http":
|
||||
|
|
@ -83,10 +63,8 @@ class SecurityHeadersMiddleware:
|
|||
async def secured(message: Message) -> None:
|
||||
if message["type"] == "http.response.start":
|
||||
headers = MutableHeaders(raw=message.setdefault("headers", []))
|
||||
headers["X-Content-Type-Options"] = "nosniff"
|
||||
headers["X-Frame-Options"] = "SAMEORIGIN"
|
||||
headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
|
||||
headers["Content-Security-Policy"] = "frame-ancestors 'self'"
|
||||
for key, value in self.secure_headers.headers.items():
|
||||
headers[key] = value
|
||||
await send(message)
|
||||
|
||||
await self.app(scope, receive, secured)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
from .flow import ApprovalTask, FlowDefinition, FlowRun, FlowVersion
|
||||
from .user import AccessRequest, MagicLinkToken, User, UserSession
|
||||
from .user import AccessRequest, MagicLinkToken, User
|
||||
|
||||
__all__ = [
|
||||
"AccessRequest",
|
||||
|
|
@ -9,5 +9,4 @@ __all__ = [
|
|||
"FlowVersion",
|
||||
"MagicLinkToken",
|
||||
"User",
|
||||
"UserSession",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -47,19 +47,7 @@ class MagicLinkToken(SQLModel, table=True):
|
|||
user_id: UUID = Field(foreign_key="users.id", index=True)
|
||||
token_hash: str = Field(index=True, unique=True, max_length=64)
|
||||
expires_at: datetime = Field(sa_column=ts_column())
|
||||
# Set before the session row is created, so a reused token can never
|
||||
# Set before the session JWT is minted, so a reused token can never
|
||||
# mint a second session.
|
||||
used_at: datetime | None = Field(default=None, sa_column=ts_column())
|
||||
created_at: datetime = Field(default_factory=utcnow, sa_column=ts_column())
|
||||
|
||||
|
||||
class UserSession(SQLModel, table=True):
|
||||
"""An opaque session id in the DB (cookie value). Revocable, expirable."""
|
||||
|
||||
__tablename__ = "user_sessions"
|
||||
|
||||
id: str = Field(primary_key=True, max_length=128)
|
||||
user_id: UUID = Field(foreign_key="users.id", index=True)
|
||||
created_at: datetime = Field(default_factory=utcnow, sa_column=ts_column())
|
||||
expires_at: datetime = Field(sa_column=ts_column())
|
||||
revoked_at: datetime | None = Field(default=None, sa_column=ts_column())
|
||||
|
|
|
|||
|
|
@ -42,7 +42,9 @@ class Settings(BaseSettings):
|
|||
run_throttle_per_minute: int = 60
|
||||
|
||||
# --- access management (magic-link auth, see app/auth) ---
|
||||
# Public frontend URL (magic links point at /login?token= there).
|
||||
# Secret signing session JWTs (stateless cookies). Long random value;
|
||||
# empty fails closed at login time — set it in prod (.env JWT_SECRET).
|
||||
jwt_secret: str = "" # Public frontend URL (magic links point at /login?token= there).
|
||||
frontend_url: str = "http://localhost:3000"
|
||||
# Comma-separated emails that become admins (role assignment + bootstrap).
|
||||
admin_emails: str = ""
|
||||
|
|
|
|||
|
|
@ -44,10 +44,30 @@ derives a JSON Schema + uiSchema from the node's template
|
|||
The API watcher syncs + deploys it automatically; the form schema is
|
||||
regenerated per version.
|
||||
|
||||
## Branched questionnaires (`visible_if`)
|
||||
## Structured `visible_if` (preferred) + markers (fallback)
|
||||
|
||||
A field can appear only when other fields have certain values — nested
|
||||
to any depth. Declare it in the code editor with a marker in `info`:
|
||||
to any depth. Preferred: structured `visible_if` key (no quoting
|
||||
mini-language, rename-refactorable, validated at sync time with warnings
|
||||
instead of silent fail-open):
|
||||
|
||||
```python
|
||||
(
|
||||
DropdownInput(
|
||||
name="bestandteil",
|
||||
display_name="Welcher Bestandteil der Kirche?",
|
||||
options=["Glocke", "Orgel"],
|
||||
value=[],
|
||||
info="Mehrfachauswahl möglich",
|
||||
visible_if=[{"field": "gebaeude", "value": "Kirche"}],
|
||||
required=True,
|
||||
),
|
||||
)
|
||||
```
|
||||
|
||||
Legacy `[visible_if ...]` markers in `info` still work as deprecated
|
||||
fallback (same syntax, stripped from help text). The parser emits
|
||||
`allOf`/`if`/`then` branches either way (see below).
|
||||
|
||||
```python
|
||||
(
|
||||
|
|
@ -149,20 +169,23 @@ envelope and merge normally), and unasked code defaults stay out of
|
|||
the output. Without the list — raw dicts, Langflow playground — all
|
||||
visible declared defaults apply as before.
|
||||
|
||||
## Variant flows (own component per flow)
|
||||
## Variant flows (own component per flow, shared core)
|
||||
|
||||
A flow whose fields differ from `CustomInputForm` gets its own
|
||||
standalone component (see `bauvorhaven_form.py` → "Bauvorhaben Form"
|
||||
in the palette): same class pattern, own `inputs` list. Standalone
|
||||
means it: no imports between form modules — the LFX runtime executes
|
||||
the embedded module source without our `kirche` package, so every form
|
||||
component must be self-contained (stdlib + `lfx` only). Copy the file
|
||||
as the starting point for future forms.
|
||||
A flow whose fields differ from `CustomInputForm` gets its own thin
|
||||
component (see `bauvorhaben_form.py` → "Bauvorhaben Form" in the
|
||||
palette): same class pattern, own `inputs` list, shared runtime from
|
||||
`kirche._form_core` (`from kirche._form_core import ...` plus the
|
||||
`# <-- _form_core inlined ... -->` marker). The LFX runtime executes
|
||||
the embedded module source without our `kirche` package, so
|
||||
`scripts/make_flow_fixtures.py:kirche_node()` inlines the core into the
|
||||
exported `code` — one truth in the repo, self-contained JSON. Copy the
|
||||
file as the starting point for future forms.
|
||||
|
||||
Rule: code-declared inputs must equal template fields
|
||||
(`test_embedded_code_matches_template_fields` enforces it; required
|
||||
Multiselect uses the `[" "]` sentinel). Generate node + flow with
|
||||
`kirche_node(..., component_cls, code_override)` and
|
||||
(`test_embedded_code_matches_template_fields` enforces it, plus
|
||||
no-sibling-import + core-present asserts; required Multiselect uses
|
||||
the `[" "]` sentinel). Generate node + flow with `kirche_node(...)`
|
||||
(which composes thin source + `_inline_core()` automatically) and
|
||||
`build_bauvorhaben()` in `backend/scripts/make_flow_fixtures.py`
|
||||
rather than hand-editing JSON. Restart Langflow afterwards so the
|
||||
palette picks up the new component, then build/upload the flow.
|
||||
|
|
|
|||
|
|
@ -1,11 +1,9 @@
|
|||
"""Bauvorhaben form component for kirche-einfach flows.
|
||||
|
||||
Standalone by design: this module shares NO code with ``form_inputs``
|
||||
(no imports from it). The LFX runtime executes the embedded module
|
||||
source without our ``kirche`` package, so every form component must be
|
||||
self-contained (stdlib + ``lfx`` only). Do NOT refactor the duplicated
|
||||
machinery below into a shared import — it would break flow execution.
|
||||
Copy this file as the starting point for future forms.
|
||||
Thin variant: shared runtime lives in ``kirche._form_core`` (imported here
|
||||
for the Langflow palette); ``scripts/make_flow_fixtures.py:kirche_node()``
|
||||
inlines the core into the exported ``code`` so the LFX runtime stays
|
||||
self-contained. Copy this file as the starting point for future forms.
|
||||
|
||||
A flow that starts with :class:`CustomInputBauvorhaben` declares the
|
||||
fields of its web form. The API reads the exported node template
|
||||
|
|
@ -32,8 +30,8 @@ Langflow playground), all visible declared defaults apply.
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import json # noqa: F401 -- used by inlined _form_core at runtime
|
||||
import re # noqa: F401 -- used by inlined _form_core at runtime
|
||||
from typing import ClassVar
|
||||
|
||||
from lfx.custom import Component
|
||||
|
|
@ -48,94 +46,16 @@ from lfx.io import (
|
|||
)
|
||||
from lfx.schema.data import Data
|
||||
|
||||
from kirche._form_core import (
|
||||
PLUMBING_INPUTS,
|
||||
_field_conditions,
|
||||
_json_safe,
|
||||
collect_form_data,
|
||||
parse_envelope,
|
||||
)
|
||||
|
||||
def _text(value) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
if hasattr(value, "text"):
|
||||
return value.text or ""
|
||||
return str(value)
|
||||
|
||||
|
||||
def _json_safe(value):
|
||||
"""Plain JSON value for one field: unwrap LFX wrappers, keep scalars."""
|
||||
if value is None or isinstance(value, (bool, int, float, str)):
|
||||
return value
|
||||
if isinstance(value, list):
|
||||
return [_json_safe(item) for item in value]
|
||||
if isinstance(value, dict):
|
||||
return {str(key): _json_safe(item) for key, item in value.items()}
|
||||
text = getattr(value, "text", None)
|
||||
if isinstance(text, str):
|
||||
return text
|
||||
data = getattr(value, "data", None)
|
||||
if isinstance(data, dict):
|
||||
return _json_safe(data)
|
||||
return str(value)
|
||||
|
||||
|
||||
def _split_conditions(text: str) -> list:
|
||||
parts: list = []
|
||||
buf: list = []
|
||||
quoted = False
|
||||
for char in text:
|
||||
if char == '"':
|
||||
quoted = not quoted
|
||||
buf.append(char)
|
||||
elif char == "&" and not quoted:
|
||||
parts.append("".join(buf))
|
||||
buf = []
|
||||
else:
|
||||
buf.append(char)
|
||||
parts.append("".join(buf))
|
||||
return [part.strip() for part in parts if part.strip()]
|
||||
|
||||
|
||||
def _parse_condition(token: str):
|
||||
field, sep, value = token.partition("=")
|
||||
field, value = field.strip(), value.strip()
|
||||
if not sep or not field or not value:
|
||||
return None
|
||||
if len(value) >= 2 and value.startswith('"') and value.endswith('"'):
|
||||
value = value[1:-1]
|
||||
lowered = value.lower()
|
||||
if lowered == "true":
|
||||
return field, True
|
||||
if lowered == "false":
|
||||
return field, False
|
||||
try:
|
||||
return field, int(value)
|
||||
except ValueError:
|
||||
pass
|
||||
try:
|
||||
return field, float(value)
|
||||
except ValueError:
|
||||
pass
|
||||
return field, value
|
||||
|
||||
|
||||
def _field_conditions(info: object) -> list:
|
||||
"""``[(trigger, value)]`` from a field's ``info`` markers."""
|
||||
conditions: list = []
|
||||
|
||||
def _collect(match) -> str:
|
||||
for token in _split_conditions(match.group(1)):
|
||||
parsed = _parse_condition(token)
|
||||
if parsed is not None:
|
||||
conditions.append(parsed)
|
||||
return ""
|
||||
|
||||
_VISIBLE_RE.sub(_collect, info if isinstance(info, str) else "")
|
||||
return conditions
|
||||
|
||||
|
||||
#: Reserved input carrying the run envelope — a real runtime input, so it
|
||||
#: must stay out of the output. Never name a form field ``form_values``.
|
||||
PLUMBING_INPUTS = frozenset({"form_values"})
|
||||
|
||||
#: ``[visible_if ...]`` marker (same syntax as the backend parser in
|
||||
#: ``app/flows/input_schema.py`` — keep the two in sync).
|
||||
_VISIBLE_RE = re.compile(r"\[visible_if\s+([^\]]+)\]")
|
||||
# <-- _form_core inlined by scripts/make_flow_fixtures.py:kirche_node()
|
||||
# (LFX executes embedded source without the kirche package) -->
|
||||
|
||||
|
||||
class CustomInputBauvorhaben(Component):
|
||||
|
|
@ -246,9 +166,11 @@ class CustomInputBauvorhaben(Component):
|
|||
"""
|
||||
inputs = getattr(self, "_inputs", None)
|
||||
info = ""
|
||||
structured = None
|
||||
if isinstance(inputs, dict) and name in inputs:
|
||||
info = getattr(inputs[name], "info", "") or ""
|
||||
for trigger, expected in _field_conditions(info):
|
||||
structured = getattr(inputs[name], "visible_if", None)
|
||||
for trigger, expected in _field_conditions(info, structured):
|
||||
if trigger == name or trigger not in effective:
|
||||
# Self-reference or unknown trigger: fail open (visible),
|
||||
# mirroring the backend parser.
|
||||
|
|
@ -262,33 +184,14 @@ class CustomInputBauvorhaben(Component):
|
|||
return True
|
||||
|
||||
def collect(self) -> Data:
|
||||
try:
|
||||
envelope = json.loads(_text(self.form_values) or "{}")
|
||||
except (json.JSONDecodeError, ValueError, AttributeError):
|
||||
envelope = {}
|
||||
payload: dict = {}
|
||||
asked: list | None = None
|
||||
if isinstance(envelope, dict):
|
||||
# Asked-field list counts only in true envelope shape
|
||||
# ({"payload": {...}, "fields": [...]}); a raw dict or a form
|
||||
# field literally named "fields" must not confuse it.
|
||||
if isinstance(envelope.get("payload"), dict):
|
||||
payload = envelope["payload"]
|
||||
fields = envelope.get("fields")
|
||||
if isinstance(fields, list) and all(isinstance(item, str) for item in fields):
|
||||
asked = [item for item in fields if item]
|
||||
else:
|
||||
payload = envelope
|
||||
payload, asked = parse_envelope(self.form_values)
|
||||
# Effective trigger values: submitted first, code defaults after.
|
||||
declared = self._field_names()
|
||||
effective = {name: _json_safe(self._field_value(name)) for name in declared}
|
||||
for key, val in payload.items():
|
||||
if isinstance(key, str) and key:
|
||||
effective[key] = _json_safe(val)
|
||||
field_values = {name: _json_safe(self._field_value(name)) for name in declared}
|
||||
effective, scope = collect_form_data(declared, field_values, payload, asked)
|
||||
# Defaults only for asked AND visible fields (hidden branch fields
|
||||
# stay out, like the frontend never submits them); without an asked
|
||||
# list, all visible declared fields (Langflow playground).
|
||||
scope = [name for name in (asked if asked is not None else declared) if name in declared]
|
||||
data = {name: effective[name] for name in scope if self._is_visible(name, effective)}
|
||||
# Submitted values win over the field defaults. Unknown keys are
|
||||
# kept: the frontend may already know renamed fields this code
|
||||
|
|
|
|||
|
|
@ -34,8 +34,8 @@ upload comes later.
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import json # noqa: F401 -- used by inlined _form_core at runtime
|
||||
import re # noqa: F401 -- used by inlined _form_core at runtime
|
||||
from typing import ClassVar
|
||||
|
||||
from lfx.custom import Component
|
||||
|
|
@ -56,97 +56,16 @@ from lfx.io import (
|
|||
)
|
||||
from lfx.schema.data import Data
|
||||
|
||||
from kirche._form_core import (
|
||||
PLUMBING_INPUTS,
|
||||
_field_conditions,
|
||||
_json_safe,
|
||||
collect_form_data,
|
||||
parse_envelope,
|
||||
)
|
||||
|
||||
def _text(value) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
if hasattr(value, "text"):
|
||||
return value.text or ""
|
||||
return str(value)
|
||||
|
||||
|
||||
def _json_safe(value):
|
||||
"""Plain JSON value for one field: unwrap LFX wrappers, keep scalars."""
|
||||
if value is None or isinstance(value, (bool, int, float, str)):
|
||||
return value
|
||||
if isinstance(value, list):
|
||||
return [_json_safe(item) for item in value]
|
||||
if isinstance(value, dict):
|
||||
return {str(key): _json_safe(item) for key, item in value.items()}
|
||||
text = getattr(value, "text", None)
|
||||
if isinstance(text, str):
|
||||
return text
|
||||
data = getattr(value, "data", None)
|
||||
if isinstance(data, dict):
|
||||
return _json_safe(data)
|
||||
return str(value)
|
||||
|
||||
|
||||
def _split_conditions(text: str) -> list:
|
||||
parts: list = []
|
||||
buf: list = []
|
||||
quoted = False
|
||||
for char in text:
|
||||
if char == '"':
|
||||
quoted = not quoted
|
||||
buf.append(char)
|
||||
elif char == "&" and not quoted:
|
||||
parts.append("".join(buf))
|
||||
buf = []
|
||||
else:
|
||||
buf.append(char)
|
||||
parts.append("".join(buf))
|
||||
return [part.strip() for part in parts if part.strip()]
|
||||
|
||||
|
||||
def _parse_condition(token: str):
|
||||
field, sep, value = token.partition("=")
|
||||
field, value = field.strip(), value.strip()
|
||||
if not sep or not field or not value:
|
||||
return None
|
||||
if len(value) >= 2 and value.startswith('"') and value.endswith('"'):
|
||||
value = value[1:-1]
|
||||
lowered = value.lower()
|
||||
if lowered == "true":
|
||||
return field, True
|
||||
if lowered == "false":
|
||||
return field, False
|
||||
try:
|
||||
return field, int(value)
|
||||
except ValueError:
|
||||
pass
|
||||
try:
|
||||
return field, float(value)
|
||||
except ValueError:
|
||||
pass
|
||||
return field, value
|
||||
|
||||
|
||||
def _field_conditions(info: object) -> list:
|
||||
"""``[(trigger, value)]`` from a field's ``info`` markers."""
|
||||
conditions: list = []
|
||||
|
||||
def _collect(match) -> str:
|
||||
for token in _split_conditions(match.group(1)):
|
||||
parsed = _parse_condition(token)
|
||||
if parsed is not None:
|
||||
conditions.append(parsed)
|
||||
return ""
|
||||
|
||||
_VISIBLE_RE.sub(_collect, info if isinstance(info, str) else "")
|
||||
return conditions
|
||||
|
||||
|
||||
#: Reserved input carrying the run envelope — a real runtime input, so it
|
||||
#: must stay out of the output (otherwise the whole envelope JSON would
|
||||
#: leak in as a field value on every path without an asked-fields list).
|
||||
#: Never name a form field ``form_values``.
|
||||
PLUMBING_INPUTS = frozenset({"form_values"})
|
||||
|
||||
#: ``[visible_if ...]`` marker (same syntax as the backend parser in
|
||||
#: ``app/flows/input_schema.py`` — keep the two in sync; this module must
|
||||
#: stay import-light for the LFX runtime and cannot import it).
|
||||
_VISIBLE_RE = re.compile(r"\[visible_if\s+([^\]]+)\]")
|
||||
# <-- _form_core inlined by scripts/make_flow_fixtures.py:kirche_node()
|
||||
# (LFX executes embedded source without the kirche package) -->
|
||||
|
||||
|
||||
class CustomInputForm(Component):
|
||||
|
|
@ -272,9 +191,11 @@ class CustomInputForm(Component):
|
|||
"""
|
||||
inputs = getattr(self, "_inputs", None)
|
||||
info = ""
|
||||
structured = None
|
||||
if isinstance(inputs, dict) and name in inputs:
|
||||
info = getattr(inputs[name], "info", "") or ""
|
||||
for trigger, expected in _field_conditions(info):
|
||||
structured = getattr(inputs[name], "visible_if", None)
|
||||
for trigger, expected in _field_conditions(info, structured):
|
||||
if trigger == name or trigger not in effective:
|
||||
# Self-reference or unknown trigger: fail open (visible),
|
||||
# mirroring the backend parser.
|
||||
|
|
@ -288,33 +209,14 @@ class CustomInputForm(Component):
|
|||
return True
|
||||
|
||||
def collect(self) -> Data:
|
||||
try:
|
||||
envelope = json.loads(_text(self.form_values) or "{}")
|
||||
except (json.JSONDecodeError, ValueError, AttributeError):
|
||||
envelope = {}
|
||||
payload: dict = {}
|
||||
asked: list | None = None
|
||||
if isinstance(envelope, dict):
|
||||
# Asked-field list counts only in true envelope shape
|
||||
# ({"payload": {...}, "fields": [...]}); a raw dict or a form
|
||||
# field literally named "fields" must not confuse it.
|
||||
if isinstance(envelope.get("payload"), dict):
|
||||
payload = envelope["payload"]
|
||||
fields = envelope.get("fields")
|
||||
if isinstance(fields, list) and all(isinstance(item, str) for item in fields):
|
||||
asked = [item for item in fields if item]
|
||||
else:
|
||||
payload = envelope
|
||||
payload, asked = parse_envelope(self.form_values)
|
||||
# Effective trigger values: submitted first, code defaults after.
|
||||
declared = self._field_names()
|
||||
effective = {name: _json_safe(self._field_value(name)) for name in declared}
|
||||
for key, val in payload.items():
|
||||
if isinstance(key, str) and key:
|
||||
effective[key] = _json_safe(val)
|
||||
field_values = {name: _json_safe(self._field_value(name)) for name in declared}
|
||||
effective, scope = collect_form_data(declared, field_values, payload, asked)
|
||||
# Defaults only for asked AND visible fields (hidden branch fields
|
||||
# stay out, like the frontend never submits them); without an asked
|
||||
# list, all visible declared fields (Langflow playground).
|
||||
scope = [name for name in (asked if asked is not None else declared) if name in declared]
|
||||
data = {name: effective[name] for name in scope if self._is_visible(name, effective)}
|
||||
# Submitted values win over the field defaults. Unknown keys are
|
||||
# kept: the frontend may already know renamed fields this code
|
||||
|
|
|
|||
|
|
@ -1,11 +1,9 @@
|
|||
"""Mitarbeiter-einstellen form component for kirche-einfach flows.
|
||||
|
||||
Standalone by design: this module shares NO code with the other form
|
||||
modules (no imports from them). The LFX runtime executes the embedded
|
||||
module source without our ``kirche`` package, so every form component
|
||||
must be self-contained (stdlib + ``lfx`` only). Do NOT refactor the
|
||||
duplicated machinery below into a shared import — it would break flow
|
||||
execution. Copy this file (or ``bauvorhaben_form.py``) as the starting
|
||||
Thin variant: shared runtime lives in ``kirche._form_core`` (imported here
|
||||
for the Langflow palette); ``scripts/make_flow_fixtures.py:kirche_node()``
|
||||
inlines the core into the exported ``code`` so the LFX runtime stays
|
||||
self-contained. Copy this file (or ``bauvorhaben_form.py``) as the starting
|
||||
point for future forms.
|
||||
|
||||
A flow that starts with :class:`CustomInputMitarbeiter` declares the
|
||||
|
|
@ -33,8 +31,8 @@ Langflow playground), all visible declared defaults apply.
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import json # noqa: F401 -- used by inlined _form_core at runtime
|
||||
import re # noqa: F401 -- used by inlined _form_core at runtime
|
||||
from typing import ClassVar
|
||||
|
||||
from lfx.custom import Component
|
||||
|
|
@ -46,94 +44,16 @@ from lfx.io import (
|
|||
)
|
||||
from lfx.schema.data import Data
|
||||
|
||||
from kirche._form_core import (
|
||||
PLUMBING_INPUTS,
|
||||
_field_conditions,
|
||||
_json_safe,
|
||||
collect_form_data,
|
||||
parse_envelope,
|
||||
)
|
||||
|
||||
def _text(value) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
if hasattr(value, "text"):
|
||||
return value.text or ""
|
||||
return str(value)
|
||||
|
||||
|
||||
def _json_safe(value):
|
||||
"""Plain JSON value for one field: unwrap LFX wrappers, keep scalars."""
|
||||
if value is None or isinstance(value, (bool, int, float, str)):
|
||||
return value
|
||||
if isinstance(value, list):
|
||||
return [_json_safe(item) for item in value]
|
||||
if isinstance(value, dict):
|
||||
return {str(key): _json_safe(item) for key, item in value.items()}
|
||||
text = getattr(value, "text", None)
|
||||
if isinstance(text, str):
|
||||
return text
|
||||
data = getattr(value, "data", None)
|
||||
if isinstance(data, dict):
|
||||
return _json_safe(data)
|
||||
return str(value)
|
||||
|
||||
|
||||
def _split_conditions(text: str) -> list:
|
||||
parts: list = []
|
||||
buf: list = []
|
||||
quoted = False
|
||||
for char in text:
|
||||
if char == '"':
|
||||
quoted = not quoted
|
||||
buf.append(char)
|
||||
elif char == "&" and not quoted:
|
||||
parts.append("".join(buf))
|
||||
buf = []
|
||||
else:
|
||||
buf.append(char)
|
||||
parts.append("".join(buf))
|
||||
return [part.strip() for part in parts if part.strip()]
|
||||
|
||||
|
||||
def _parse_condition(token: str):
|
||||
field, sep, value = token.partition("=")
|
||||
field, value = field.strip(), value.strip()
|
||||
if not sep or not field or not value:
|
||||
return None
|
||||
if len(value) >= 2 and value.startswith('"') and value.endswith('"'):
|
||||
value = value[1:-1]
|
||||
lowered = value.lower()
|
||||
if lowered == "true":
|
||||
return field, True
|
||||
if lowered == "false":
|
||||
return field, False
|
||||
try:
|
||||
return field, int(value)
|
||||
except ValueError:
|
||||
pass
|
||||
try:
|
||||
return field, float(value)
|
||||
except ValueError:
|
||||
pass
|
||||
return field, value
|
||||
|
||||
|
||||
def _field_conditions(info: object) -> list:
|
||||
"""``[(trigger, value)]`` from a field's ``info`` markers."""
|
||||
conditions: list = []
|
||||
|
||||
def _collect(match) -> str:
|
||||
for token in _split_conditions(match.group(1)):
|
||||
parsed = _parse_condition(token)
|
||||
if parsed is not None:
|
||||
conditions.append(parsed)
|
||||
return ""
|
||||
|
||||
_VISIBLE_RE.sub(_collect, info if isinstance(info, str) else "")
|
||||
return conditions
|
||||
|
||||
|
||||
#: Reserved input carrying the run envelope — a real runtime input, so it
|
||||
#: must stay out of the output. Never name a form field ``form_values``.
|
||||
PLUMBING_INPUTS = frozenset({"form_values"})
|
||||
|
||||
#: ``[visible_if ...]`` marker (same syntax as the backend parser in
|
||||
#: ``app/flows/input_schema.py`` — keep the two in sync).
|
||||
_VISIBLE_RE = re.compile(r"\[visible_if\s+([^\]]+)\]")
|
||||
# <-- _form_core inlined by scripts/make_flow_fixtures.py:kirche_node()
|
||||
# (LFX executes embedded source without the kirche package) -->
|
||||
|
||||
|
||||
class CustomInputMitarbeiter(Component):
|
||||
|
|
@ -223,9 +143,11 @@ class CustomInputMitarbeiter(Component):
|
|||
"""
|
||||
inputs = getattr(self, "_inputs", None)
|
||||
info = ""
|
||||
structured = None
|
||||
if isinstance(inputs, dict) and name in inputs:
|
||||
info = getattr(inputs[name], "info", "") or ""
|
||||
for trigger, expected in _field_conditions(info):
|
||||
structured = getattr(inputs[name], "visible_if", None)
|
||||
for trigger, expected in _field_conditions(info, structured):
|
||||
if trigger == name or trigger not in effective:
|
||||
# Self-reference or unknown trigger: fail open (visible),
|
||||
# mirroring the backend parser.
|
||||
|
|
@ -239,33 +161,14 @@ class CustomInputMitarbeiter(Component):
|
|||
return True
|
||||
|
||||
def collect(self) -> Data:
|
||||
try:
|
||||
envelope = json.loads(_text(self.form_values) or "{}")
|
||||
except (json.JSONDecodeError, ValueError, AttributeError):
|
||||
envelope = {}
|
||||
payload: dict = {}
|
||||
asked: list | None = None
|
||||
if isinstance(envelope, dict):
|
||||
# Asked-field list counts only in true envelope shape
|
||||
# ({"payload": {...}, "fields": [...]}); a raw dict or a form
|
||||
# field literally named "fields" must not confuse it.
|
||||
if isinstance(envelope.get("payload"), dict):
|
||||
payload = envelope["payload"]
|
||||
fields = envelope.get("fields")
|
||||
if isinstance(fields, list) and all(isinstance(item, str) for item in fields):
|
||||
asked = [item for item in fields if item]
|
||||
else:
|
||||
payload = envelope
|
||||
payload, asked = parse_envelope(self.form_values)
|
||||
# Effective trigger values: submitted first, code defaults after.
|
||||
declared = self._field_names()
|
||||
effective = {name: _json_safe(self._field_value(name)) for name in declared}
|
||||
for key, val in payload.items():
|
||||
if isinstance(key, str) and key:
|
||||
effective[key] = _json_safe(val)
|
||||
field_values = {name: _json_safe(self._field_value(name)) for name in declared}
|
||||
effective, scope = collect_form_data(declared, field_values, payload, asked)
|
||||
# Defaults only for asked AND visible fields (hidden branch fields
|
||||
# stay out, like the frontend never submits them); without an asked
|
||||
# list, all visible declared fields (Langflow playground).
|
||||
scope = [name for name in (asked if asked is not None else declared) if name in declared]
|
||||
data = {name: effective[name] for name in scope if self._is_visible(name, effective)}
|
||||
# Submitted values win over the field defaults. Unknown keys are
|
||||
# kept: the frontend may already know renamed fields this code
|
||||
|
|
|
|||
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
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -0,0 +1,46 @@
|
|||
"""drop user_sessions (stateless JWT sessions)
|
||||
|
||||
Revision ID: c4d8f2a1e6b9
|
||||
Revises: b7e2d4a1c9f3
|
||||
Create Date: 2026-09-28 00:00:00.000000
|
||||
|
||||
Sessions are stateless HS256 JWTs in the HttpOnly cookie (see
|
||||
app/auth/service.py mint_session_token). All sessions are invalidated
|
||||
at deploy — users re-login via magic link.
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
import sqlmodel
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "c4d8f2a1e6b9"
|
||||
down_revision: str | Sequence[str] | None = "b7e2d4a1c9f3"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
op.drop_index(op.f("ix_user_sessions_user_id"), table_name="user_sessions")
|
||||
op.drop_table("user_sessions")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
op.create_table(
|
||||
"user_sessions",
|
||||
sa.Column("id", sqlmodel.sql.sqltypes.AutoString(length=128), nullable=False),
|
||||
sa.Column("user_id", sa.Uuid(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.ForeignKeyConstraint(
|
||||
["user_id"],
|
||||
["users.id"],
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index(op.f("ix_user_sessions_user_id"), "user_sessions", ["user_id"], unique=False)
|
||||
|
|
@ -5,10 +5,19 @@ description = "Kirche Einfach - KI-gestuetzte Verwaltungsplattform fuer Kirche"
|
|||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"alembic>=1.14",
|
||||
"atomicwrites>=1.4.1",
|
||||
"fastapi[standard]>=0.115.0",
|
||||
"fastapi-mail>=1.4.1",
|
||||
"httpx>=0.28",
|
||||
"jinja2>=3.1",
|
||||
"pydantic-settings>=2.0",
|
||||
"pyjwt>=2.10",
|
||||
"python-slugify>=8.0",
|
||||
"secure>=1.0",
|
||||
"slowapi>=0.1.9",
|
||||
"sqlmodel>=0.0.24",
|
||||
"tenacity>=9.0",
|
||||
"typer>=0.15",
|
||||
"uvicorn[standard]>=0.30.0",
|
||||
"watchfiles>=0.24",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -11,10 +11,11 @@ from app (stdlib only), prints no secret values.
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import typer
|
||||
|
||||
BACKEND = Path(__file__).resolve().parent.parent
|
||||
REPO_ROOT = BACKEND.parent
|
||||
|
||||
|
|
@ -57,6 +58,7 @@ def check(values: dict[str, str]) -> tuple[list[str], list[str]]:
|
|||
|
||||
# Shared + service API keys.
|
||||
secret("CALLBACK_TOKEN", MIN_TOKEN, "callback token")
|
||||
secret("JWT_SECRET", MIN_TOKEN, "session JWT secret")
|
||||
secret("LANGFLOW_API_KEY", MIN_API_KEY, "langflow api key")
|
||||
placeholder("LANGFLOW_API_KEY", ("sk-",))
|
||||
secret("LFX_API_KEY", MIN_API_KEY, "lfx api key")
|
||||
|
|
@ -87,11 +89,9 @@ def check(values: dict[str, str]) -> tuple[list[str], list[str]]:
|
|||
return failures, warnings
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--env", default=str(REPO_ROOT / ".env"))
|
||||
args = parser.parse_args()
|
||||
path = Path(args.env)
|
||||
def main(env: str = typer.Option(str(REPO_ROOT / ".env"), "--env")) -> int:
|
||||
"""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
|
||||
|
|
@ -108,4 +108,4 @@ def main() -> int:
|
|||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
sys.exit(typer.run(main))
|
||||
|
|
|
|||
|
|
@ -7,11 +7,11 @@ Usage:
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
|
||||
import httpx
|
||||
import typer
|
||||
|
||||
|
||||
def post(api: str, path: str, body: dict) -> dict:
|
||||
|
|
@ -26,23 +26,22 @@ def post(api: str, path: str, body: dict) -> dict:
|
|||
return {"status": 0, "body": str(exc)[:2000]}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--api", default="http://localhost:8000")
|
||||
parser.add_argument("--langflow-id", required=True)
|
||||
parser.add_argument("--name", default=None)
|
||||
parser.add_argument("--author", default="")
|
||||
parser.add_argument("--no-deploy", action="store_true")
|
||||
args = parser.parse_args()
|
||||
|
||||
def main(
|
||||
langflow_id: str = typer.Option(..., "--langflow-id"),
|
||||
api: str = typer.Option("http://localhost:8000", "--api"),
|
||||
name: str | None = typer.Option(None, "--name"),
|
||||
author: str = typer.Option("", "--author"),
|
||||
no_deploy: bool = typer.Option(False, "--no-deploy"),
|
||||
) -> int:
|
||||
"""Import a flow from Langflow via the running API (post() stays importable)."""
|
||||
result = post(
|
||||
args.api,
|
||||
api,
|
||||
"/api/flows/import",
|
||||
{
|
||||
"langflow_flow_id": args.langflow_id,
|
||||
"name": args.name,
|
||||
"author": args.author,
|
||||
"deploy": not args.no_deploy,
|
||||
"langflow_flow_id": langflow_id,
|
||||
"name": name,
|
||||
"author": author,
|
||||
"deploy": not no_deploy,
|
||||
},
|
||||
)
|
||||
print(json.dumps(result, indent=2, ensure_ascii=False))
|
||||
|
|
@ -50,4 +49,4 @@ def main() -> int:
|
|||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
sys.exit(typer.run(main))
|
||||
|
|
|
|||
|
|
@ -29,6 +29,74 @@ FLOW_DIR = REPO_ROOT / "backend" / "flows"
|
|||
|
||||
HANDLE_QUOTE = "\u0153" # ReactFlow handle-id quoting used by Langflow exports
|
||||
|
||||
FORM_CORE_PATH = REPO_ROOT / "backend" / "components" / "kirche" / "_form_core.py"
|
||||
|
||||
INLINE_MARKER = "# <-- _form_core inlined by scripts/make_flow_fixtures.py:kirche_node()"
|
||||
|
||||
|
||||
def _inline_core() -> str:
|
||||
"""Body of _form_core.py minus docstring/future/std-imports (already present)."""
|
||||
text = FORM_CORE_PATH.read_text(encoding="utf-8")
|
||||
lines = text.splitlines()
|
||||
body: list[str] = []
|
||||
skip_docstring = True
|
||||
in_docstring = False
|
||||
for line in lines:
|
||||
stripped = line.strip()
|
||||
if skip_docstring:
|
||||
if stripped.startswith('"""') and not in_docstring:
|
||||
if stripped.count('"""') >= 2:
|
||||
skip_docstring = False
|
||||
else:
|
||||
in_docstring = True
|
||||
continue
|
||||
if in_docstring:
|
||||
if '"""' in stripped:
|
||||
in_docstring = False
|
||||
skip_docstring = False
|
||||
continue
|
||||
if stripped in (
|
||||
"from __future__ import annotations",
|
||||
"import json",
|
||||
"import re",
|
||||
"",
|
||||
):
|
||||
continue
|
||||
skip_docstring = False
|
||||
if stripped in ("from __future__ import annotations", "import json", "import re"):
|
||||
continue
|
||||
body.append(line)
|
||||
return "\n".join(body).strip() + "\n"
|
||||
|
||||
|
||||
def _compose_component_code(source: str) -> str:
|
||||
"""Replace the sibling import + marker with inlined core (LFX self-contained)."""
|
||||
core = _inline_core()
|
||||
out: list[str] = []
|
||||
skipping_import = False
|
||||
for line in source.splitlines():
|
||||
stripped = line.strip()
|
||||
if not skipping_import and stripped.startswith(
|
||||
("from kirche._form_core import", "from ._form_core import")
|
||||
):
|
||||
out.append(core.rstrip())
|
||||
# Multi-line parenthesized import: skip continuation lines
|
||||
# up to and including the closing paren.
|
||||
if "(" in stripped and ")" not in stripped:
|
||||
skipping_import = True
|
||||
continue
|
||||
if skipping_import:
|
||||
if ")" in stripped:
|
||||
skipping_import = False
|
||||
continue
|
||||
if "_form_core inlined by" in stripped:
|
||||
continue
|
||||
out.append(line)
|
||||
composed = "\n".join(out)
|
||||
assert "from kirche" not in composed, "sibling import survived inlining"
|
||||
assert "from ._" not in composed, "relative import survived inlining"
|
||||
return composed
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# node builders
|
||||
|
|
@ -118,7 +186,7 @@ def kirche_node(
|
|||
comp_name = getattr(component_cls, "name", None) or component_cls.__name__
|
||||
inst = component_cls()
|
||||
if code_override is not None:
|
||||
inst._code = code_override
|
||||
inst._code = _compose_component_code(code_override)
|
||||
frontend_node = ComponentFrontendNode.from_inputs(**inst.get_template_config(inst))
|
||||
inst._map_parameters_on_frontend_node(frontend_node)
|
||||
node = frontend_node.to_dict(keep_name=False)
|
||||
|
|
@ -126,6 +194,10 @@ def kirche_node(
|
|||
inst._map_parameters_on_template(node["template"])
|
||||
if not getattr(inst, "_code", None):
|
||||
inst.set_class_code()
|
||||
# Always compose: default path may already carry thin source with the
|
||||
# sibling import (Component base sets _code on init); override path is
|
||||
# pre-composed but re-composing is idempotent (no import lines left).
|
||||
inst._code = _compose_component_code(inst._code)
|
||||
node["template"]["code"] = {
|
||||
"advanced": True,
|
||||
"api_editable": False,
|
||||
|
|
|
|||
|
|
@ -37,6 +37,9 @@ LFX_PORT = _free_port()
|
|||
API_PORT = _free_port()
|
||||
LFX_KEY = "lfx-759GsfJKjAeOBZ_44-cll9WdHp_yfYn_tIg7eHG38Rs"
|
||||
|
||||
# Stateless admin session JWT, minted in main() after planting the user.
|
||||
_SMOKE_JWT = ""
|
||||
|
||||
|
||||
def wait_healthy(url: str, timeout: int = 180) -> None:
|
||||
deadline = time.time() + timeout
|
||||
|
|
@ -54,9 +57,9 @@ def api(method: str, path: str, body: dict | None = None) -> tuple[int, dict | s
|
|||
import httpx
|
||||
|
||||
with httpx.Client(base_url=f"http://127.0.0.1:{API_PORT}", timeout=120) as c:
|
||||
# Management + runs are login-gated; the smoke admin session is
|
||||
# planted directly in the throwaway DB (see main()).
|
||||
c.cookies.set("kirche_session", "smoke-session", domain="127.0.0.1")
|
||||
# Management + runs are login-gated; the smoke admin JWT is minted
|
||||
# in main() (stateless sessions, see app/auth/service.py).
|
||||
c.cookies.set("kirche_session", _SMOKE_JWT, domain="127.0.0.1")
|
||||
r = c.request(method, path, json=body)
|
||||
try:
|
||||
return r.status_code, r.json()
|
||||
|
|
@ -90,27 +93,31 @@ def main() -> None:
|
|||
app_settings.database_url = db_url
|
||||
alembic_command.upgrade(cfg, "head")
|
||||
|
||||
# Plant an admin session: management + runs require login, and there is
|
||||
# no SMTP in smoke to deliver a magic link.
|
||||
from datetime import UTC, datetime, timedelta
|
||||
# Plant an admin user + mint its session JWT: management + runs
|
||||
# require login, and there is no SMTP in smoke to deliver a magic link.
|
||||
# Sessions are stateless (see app/auth/service.py) — the cookie value
|
||||
# below is the JWT sent as `kirche_session`.
|
||||
global _SMOKE_JWT
|
||||
import os
|
||||
|
||||
from sqlmodel import Session, create_engine
|
||||
|
||||
from app.models import User, UserSession
|
||||
from app.auth import service as auth_service
|
||||
from app.models import User
|
||||
from app.settings import settings as app_settings
|
||||
|
||||
if not app_settings.jwt_secret:
|
||||
app_settings.jwt_secret = os.environ.get(
|
||||
"JWT_SECRET", "smoke-jwt-secret-32-chars-minimum!!"
|
||||
)
|
||||
engine = create_engine(db_url)
|
||||
with Session(engine) as s:
|
||||
user = User(name="smoke", email="smoke@example.com", role="admin")
|
||||
s.add(user)
|
||||
s.flush()
|
||||
s.add(
|
||||
UserSession(
|
||||
id="smoke-session",
|
||||
user_id=user.id,
|
||||
expires_at=datetime.now(UTC) + timedelta(hours=1),
|
||||
)
|
||||
)
|
||||
s.commit()
|
||||
s.refresh(user)
|
||||
_SMOKE_JWT = auth_service.mint_session_token(user)
|
||||
print("smoke admin JWT minted")
|
||||
engine.dispose()
|
||||
|
||||
env = {
|
||||
|
|
|
|||
|
|
@ -9,12 +9,12 @@ Exit codes: 0 ok, 1 sync errors, 2 flows dir missing.
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import typer
|
||||
from sqlmodel import Session
|
||||
|
||||
from app.db import get_engine, reset_engine
|
||||
|
|
@ -80,18 +80,17 @@ async def main_async(args) -> int:
|
|||
return 0
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument(
|
||||
"--pull", action="store_true", help="export all Langflow flows to backend/flows first"
|
||||
)
|
||||
parser.add_argument("--sync", action="store_true", help="reconcile backend/flows into DB + LFX")
|
||||
parser.add_argument("--dir", default=None, help="flows directory (default: settings.flows_dir)")
|
||||
args = parser.parse_args()
|
||||
if not args.pull and not args.sync:
|
||||
args.sync = True
|
||||
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:
|
||||
"""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))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
sys.exit(typer.run(main))
|
||||
|
|
|
|||
|
|
@ -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