#!/usr/bin/env python3 """Lokaler Aufrufer für den Workflow Editor; benötigt nur Python 3.9+. Start: python3 tools/editor_test_server.py --json tools/example_tasks.json Der Editor muss separat laufen (standardmäßig http://127.0.0.1:8080). """ import argparse import json import sys import threading import time import urllib.error import urllib.parse import urllib.request import webbrowser from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path MAX_BODY_BYTES = 2 * 1024 * 1024 TOKEN_TTL_SECONDS = 60 * 60 RESULT_TYPE = "tasklist-editor:result" HTML_PATH = Path(__file__).with_suffix(".html") PRINT_LOCK = threading.Lock() class RequestError(Exception): def __init__(self, status, message): super().__init__(message) self.status = status def reject_constant(value): raise ValueError("Ungültiger JSON-Zahlenwert: " + value) def decode_json(raw): try: return json.loads(raw.decode("utf-8-sig"), parse_constant=reject_constant) except (UnicodeError, ValueError, RecursionError) as exc: raise RequestError(400, "Ungültiges JSON: " + str(exc)) from exc def origin_for(url): parsed = urllib.parse.urlsplit(url) if parsed.scheme not in ("http", "https") or not parsed.hostname: raise ValueError("Die Editor-URL muss eine vollständige http(s)-URL sein.") if parsed.username or parsed.password: raise ValueError("Zugangsdaten in der Editor-URL werden nicht unterstützt.") port = parsed.port host = parsed.hostname.lower() if ":" in host: host = "[" + host + "]" if port and port != {"http": 80, "https": 443}[parsed.scheme]: host += ":" + str(port) return parsed.scheme + "://" + host def normalize_payload(value): if isinstance(value, list): value = {"tasks": value} if not isinstance(value, dict): raise RequestError(400, "Erwartet wird ein tasks-Array oder ein Objekt mit name, tasks, customTypes und metadata.") tasks = value.get("tasks") if tasks is None: tasks = [] if not isinstance(tasks, list) or any(not isinstance(task, dict) for task in tasks): raise RequestError(400, "tasks muss ein Array aus Task-Objekten sein; [] legt eine neue Liste an.") custom_types = value.get("customTypes") if custom_types is None: custom_types = [] if not isinstance(custom_types, list) or any(not isinstance(item, dict) for item in custom_types): raise RequestError(400, "customTypes muss ein Array aus Typ-Objekten sein; [] übergibt keine eigenen Typen.") if value.get("name") is not None and not isinstance(value["name"], str): raise RequestError(400, "name muss eine Zeichenkette oder null sein.") payload = {"tasks": tasks, "customTypes": custom_types} for key in ("name", "metadata"): if key in value: payload[key] = value[key] return payload class EditorTestServer(ThreadingHTTPServer): daemon_threads = True def __init__(self, port, editor_url, initial_payload): editor_url = editor_url.rstrip("/") self.editor_origin = origin_for(editor_url) parsed = urllib.parse.urlsplit(editor_url) if parsed.query or parsed.fragment: raise ValueError("Die Editor-URL darf keine Query-Parameter oder Fragmente enthalten.") self.editor_url = editor_url self.initial_payload = normalize_payload(initial_payload) self.sessions = {} self.session_lock = threading.Lock() super().__init__(("127.0.0.1", port), EditorTestHandler) self.local_origin = "http://127.0.0.1:" + str(self.server_port) def open_editor(self, value): payload = normalize_payload(value) payload["returnOrigin"] = self.local_origin request = urllib.request.Request( self.editor_url + "/api/editor/import", data=json.dumps(payload, ensure_ascii=False, allow_nan=False).encode("utf-8"), headers={"Content-Type": "application/json", "Accept": "application/json"}, method="POST", ) try: with urllib.request.urlopen(request, timeout=15) as response: raw = response.read(MAX_BODY_BYTES + 1) except urllib.error.HTTPError as exc: detail = exc.read(4096).decode("utf-8", errors="replace").strip() raise RequestError(502, "Editor lehnt den Import ab (HTTP {}): {}".format( exc.code, detail or exc.reason)) from exc except (urllib.error.URLError, TimeoutError, OSError) as exc: raise RequestError(502, "Editor unter {} nicht erreichbar: {}".format(self.editor_url, exc)) from exc if len(raw) > MAX_BODY_BYTES: raise RequestError(502, "Die Antwort des Editors ist zu groß.") try: result = decode_json(raw) token = result.get("token") if isinstance(result, dict) else None deep_link = result.get("deepLink") if isinstance(result, dict) else None if not isinstance(token, str) or not token or len(token) > 512: raise ValueError("token fehlt oder ist ungültig") if not isinstance(deep_link, str) or origin_for(deep_link) != self.editor_origin: raise ValueError("deepLink muss auf den konfigurierten Editor-Origin zeigen") except (RequestError, ValueError) as exc: raise RequestError(502, "Ungültige Import-Antwort des Editors: " + str(exc)) from exc with self.session_lock: now = time.monotonic() self.sessions = {key: session for key, session in self.sessions.items() if now - session["created"] < TOKEN_TTL_SECONDS} if len(self.sessions) >= 1000: raise RequestError(503, "Zu viele offene Tests. Bitte den Testserver neu starten.") self.sessions[token] = {"created": now, "result": None} return {"token": token, "deepLink": deep_link} def receive_result(self, result): if not isinstance(result, dict) or result.get("type") != RESULT_TYPE: raise RequestError(400, "Ungültiger Rückgabetyp.") token = result.get("token") if not isinstance(token, str): raise RequestError(400, "Die Rückgabe benötigt ein token.") status = result.get("status") if status not in ("completed", "cancelled"): raise RequestError(400, "status muss completed oder cancelled sein.") if status == "completed" and (not isinstance(result.get("tasks"), list) or any(not isinstance(task, dict) for task in result["tasks"])): raise RequestError(400, "Eine abgeschlossene Bearbeitung benötigt ein tasks-Array.") if status == "completed" and (not isinstance(result.get("customTypes"), list) or any(not isinstance(item, dict) for item in result["customTypes"])): raise RequestError(400, "Eine abgeschlossene Bearbeitung benötigt den vollständigen customTypes-Katalog als Array.") if status == "cancelled" and ("tasks" in result or "customTypes" in result): raise RequestError(400, "Eine abgebrochene Bearbeitung darf keine tasks oder customTypes zurückgeben.") if result.get("name") is not None and not isinstance(result["name"], str): raise RequestError(400, "name muss eine Zeichenkette oder null sein.") with self.session_lock: session = self.sessions.get(token) if session is None or time.monotonic() - session["created"] >= TOKEN_TTL_SECONDS: raise RequestError(404, "Unbekanntes oder abgelaufenes token. Bitte einen neuen Test öffnen.") # Ein Retry nach verlorener HTTP-Antwort darf dieselbe Rückgabe erneut bestätigen. if session["result"] is not None: if session["result"] != result: raise RequestError(409, "Für dieses token wurde bereits ein anderes Ergebnis empfangen.") return {"ok": True, "duplicate": True} with PRINT_LOCK: print("\n--- Editor-Rückgabe ---", flush=True) print(json.dumps(result, ensure_ascii=False, indent=2, allow_nan=False), flush=True) session["result"] = result return {"ok": True} class EditorTestHandler(BaseHTTPRequestHandler): server_version = "WorkflowEditorTestServer/1.0" def setup(self): super().setup() self.connection.settimeout(20) def log_message(self, fmt, *args): with PRINT_LOCK: print("[{}] {}".format(self.log_date_time_string(), fmt % args), file=sys.stderr, flush=True) def send_bytes(self, status, body, content_type): self.send_response(status) self.send_header("Content-Type", content_type) self.send_header("Content-Length", str(len(body))) self.send_header("Cache-Control", "no-store") self.send_header("X-Content-Type-Options", "nosniff") self.send_header("Referrer-Policy", "no-referrer") self.end_headers() self.wfile.write(body) def send_json(self, status, value): self.send_bytes(status, json.dumps(value, ensure_ascii=False, allow_nan=False).encode("utf-8"), "application/json; charset=utf-8") def check_host(self): expected = urllib.parse.urlsplit(self.server.local_origin).netloc if self.headers.get("Host") != expected: raise RequestError(403, "Bitte den Testserver über {} öffnen.".format(self.server.local_origin)) def read_json(self): if self.headers.get("Origin") != self.server.local_origin: raise RequestError(403, "Anfragen müssen von der lokalen Testseite stammen.") if self.headers.get_content_type() != "application/json": raise RequestError(415, "Content-Type application/json wird benötigt.") if self.headers.get("Transfer-Encoding"): raise RequestError(400, "Transfer-Encoding wird nicht unterstützt.") try: length = int(self.headers["Content-Length"]) except (TypeError, ValueError) as exc: raise RequestError(411, "Content-Length wird benötigt.") from exc if length <= 0 or length > MAX_BODY_BYTES: raise RequestError(413, "Der JSON-Body muss zwischen 1 Byte und 2 MiB groß sein.") raw = self.rfile.read(length) if len(raw) != length: raise RequestError(400, "Unvollständiger Request-Body.") return decode_json(raw) def do_GET(self): try: self.check_host() if self.path in ("/", "/index.html"): self.send_bytes(200, HTML_PATH.read_bytes(), "text/html; charset=utf-8") elif self.path == "/api/config": self.send_json(200, {"editorUrl": self.server.editor_url, "editorOrigin": self.server.editor_origin, "initialPayload": self.server.initial_payload}) else: raise RequestError(404, "Nicht gefunden.") except RequestError as exc: self.send_json(exc.status, {"error": str(exc)}) except OSError as exc: self.send_json(500, {"error": "Testseite konnte nicht geladen werden: " + str(exc)}) def do_POST(self): try: self.check_host() if self.path not in ("/api/open", "/api/result"): raise RequestError(404, "Nicht gefunden.") value = self.read_json() result = self.server.open_editor(value) if self.path == "/api/open" else self.server.receive_result(value) self.send_json(200, result) except RequestError as exc: self.send_json(exc.status, {"error": str(exc)}) except (TimeoutError, OSError) as exc: self.send_json(408, {"error": "Zeitüberschreitung beim Lesen der Anfrage: " + str(exc)}) def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--port", type=int, default=8090, help="Lokaler Port (Standard: 8090)") parser.add_argument("--editor-url", default="http://127.0.0.1:8080", help="Basis-URL des laufenden Editors") parser.add_argument("--json", type=Path, help="JSON-Datei zum Vorbelegen (Standard: tools/example_tasks.json)") parser.add_argument("--no-browser", action="store_true", help="Browser nicht automatisch öffnen") args = parser.parse_args() if not 0 <= args.port <= 65535: parser.error("--port muss zwischen 0 und 65535 liegen.") try: json_path = args.json or Path(__file__).with_name("example_tasks.json") with json_path.open("rb") as source: raw = source.read(MAX_BODY_BYTES + 1) if len(raw) > MAX_BODY_BYTES: raise ValueError("Die JSON-Datei darf höchstens 2 MiB groß sein.") initial_payload = decode_json(raw) server = EditorTestServer(args.port, args.editor_url, initial_payload) except (RequestError, ValueError, OSError) as exc: parser.error(str(exc)) print("Testseite: " + server.local_origin + "/", flush=True) print("Editor: " + server.editor_url, flush=True) print("Rückgaben erscheinen hier als JSON. Beenden mit Strg+C.", flush=True) if not args.no_browser: webbrowser.open(server.local_origin + "/") try: server.serve_forever() except KeyboardInterrupt: print("\nTestserver beendet.", flush=True) finally: server.server_close() if __name__ == "__main__": main()