241 lines
9.3 KiB
Python
241 lines
9.3 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""RC V7 (real GUI Runde 2):
|
|
|
|
A) WebView-SSO blieb FAIL, weil ein VERALTETER Proxy-Prozess (alter Code im
|
|
Speicher) denselben Port bediente; SO_REUSEADDR erlaubte unter Windows ein
|
|
Co-Binding. Fix: kein Co-Bind (_pick_free_port SO_EXCLUSIVEADDRUSE +
|
|
_TestProxyServer.allow_reuse_address=False auf Windows); Startskript beendet
|
|
veraltete --serve-Proxys; secret-freier SSO-Diagnoselog (nur Cookie-NAMEN).
|
|
|
|
HINWEIS: Diese mechanischen Tests sind KEIN Beweis fuer WebView-SSO. Der
|
|
verbindliche Beweis ist der reale GUI-Lauf (siehe Abschlussbericht).
|
|
|
|
B) Mini-Diktat hatte keinen Stop-Pfad. Fix: "Weiterfahren" wirkt waehrend der
|
|
Aufnahme als "Stoppen" (bestehender toggle_diktat-Stop/Finalize); der Stop
|
|
setzt _diktat_neu_busy und setzt es IMMER (finally) zurueck.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import inspect
|
|
import json
|
|
import os
|
|
import re
|
|
import socket
|
|
import sys
|
|
import threading
|
|
import unittest
|
|
import urllib.error
|
|
import urllib.request
|
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
from pathlib import Path
|
|
|
|
ROOT = Path(__file__).resolve().parent
|
|
|
|
_SESSION_VALUE = "sess-rcv7r2-secret-value"
|
|
_USER = {"user_id": "u-andre-001", "practice_id": "p-lindengut-007", "display_name": "Andre M. Surovy"}
|
|
_used = set()
|
|
_used_lock = threading.Lock()
|
|
|
|
|
|
def _func_body(src: str, name: str) -> str:
|
|
m = re.search(r"\n def " + re.escape(name) + r"\(", src)
|
|
if not m:
|
|
m = re.search(r"\n def " + re.escape(name) + r"\(", src)
|
|
if not m:
|
|
return ""
|
|
start = m.start()
|
|
rest = src[start + 1:]
|
|
nxt = re.search(r"\n def |\n def ", rest)
|
|
end = (start + 1 + nxt.start()) if nxt else len(src)
|
|
return src[start:end]
|
|
|
|
|
|
class _FakeUpstream(BaseHTTPRequestHandler):
|
|
protocol_version = "HTTP/1.1"
|
|
|
|
def log_message(self, *a):
|
|
pass
|
|
|
|
def _json(self, code, payload):
|
|
b = json.dumps(payload).encode("utf-8")
|
|
self.send_response(code)
|
|
self.send_header("Content-Type", "application/json")
|
|
self.send_header("Content-Length", str(len(b)))
|
|
self.end_headers()
|
|
self.wfile.write(b)
|
|
|
|
def do_GET(self):
|
|
if self.path.startswith("/empfang/shell/launch"):
|
|
tok = self.path.split("token=", 1)[1].split("&", 1)[0] if "token=" in self.path else ""
|
|
with _used_lock:
|
|
if not tok or tok in _used:
|
|
self._json(401, {"detail": "verbraucht"})
|
|
return
|
|
_used.add(tok)
|
|
self.send_response(302)
|
|
self.send_header("Location", "/empfang/?empfang_chat_shell=1&shell_source=empfang_chat_shell")
|
|
self.send_header("Set-Cookie", f"aza_session={_SESSION_VALUE}; HttpOnly; Path=/; SameSite=Lax; Secure; Domain=api.aza-medwork.ch")
|
|
self.send_header("Content-Length", "0")
|
|
self.end_headers()
|
|
return
|
|
if self.path.startswith("/empfang/auth/me") or self.path.startswith("/empfang/shell/context/me"):
|
|
ck = self.headers.get("Cookie") or ""
|
|
if f"aza_session={_SESSION_VALUE}" in ck:
|
|
self._json(200, {"authenticated": True, "success": True, **_USER})
|
|
else:
|
|
self._json(401, {"detail": "nicht angemeldet"})
|
|
return
|
|
self._json(404, {"detail": "nf"})
|
|
|
|
|
|
def _no_redirect_opener():
|
|
class _NR(urllib.request.HTTPRedirectHandler):
|
|
def redirect_request(self, *a, **k):
|
|
return None
|
|
return urllib.request.build_opener(_NR)
|
|
|
|
|
|
class ProxyAntiCoBindTests(unittest.TestCase):
|
|
def setUp(self):
|
|
os.environ["AZA_DOKU_PROMPT_TEST"] = "1"
|
|
import aza_empfang_test_html_proxy as px
|
|
self.px = px
|
|
|
|
def test_server_no_reuse_addr_on_windows(self):
|
|
self.assertEqual(self.px._TestProxyServer.allow_reuse_address, sys.platform != "win32")
|
|
|
|
def test_pick_free_port_skips_occupied(self):
|
|
occ = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
occ.bind(("127.0.0.1", self.px._PORT_RANGE_START))
|
|
occ.listen(1)
|
|
try:
|
|
port = self.px._pick_free_port()
|
|
self.assertNotEqual(port, self.px._PORT_RANGE_START,
|
|
"Proxy darf nicht an belegten Port co-binden")
|
|
finally:
|
|
occ.close()
|
|
|
|
|
|
class ProxySSOMechanicalTests(unittest.TestCase):
|
|
"""Mechanisch (Fake-Upstream) — KEIN WebView-SSO-Beweis, nur Proxy-Verhalten."""
|
|
|
|
@classmethod
|
|
def setUpClass(cls):
|
|
os.environ["AZA_DOKU_PROMPT_TEST"] = "1"
|
|
_used.clear()
|
|
cls.up = ThreadingHTTPServer(("127.0.0.1", 0), _FakeUpstream)
|
|
threading.Thread(target=cls.up.serve_forever, daemon=True).start()
|
|
os.environ["AZA_EMPFANG_TEST_UPSTREAM"] = f"http://127.0.0.1:{cls.up.server_address[1]}"
|
|
import aza_empfang_test_html_proxy as px
|
|
cls.px = px
|
|
cls.srv, cls.port = px._start_server_on_port(0)
|
|
cls.base = f"http://127.0.0.1:{cls.port}"
|
|
|
|
@classmethod
|
|
def tearDownClass(cls):
|
|
for s in (getattr(cls, "srv", None), getattr(cls, "up", None)):
|
|
try:
|
|
s.shutdown(); s.server_close()
|
|
except Exception:
|
|
pass
|
|
|
|
def test_302_not_followed_cookie_localized(self):
|
|
op = _no_redirect_opener()
|
|
try:
|
|
r = op.open(f"{self.base}/empfang/shell/launch?token=t-a&target=empfang_chat_shell", timeout=8)
|
|
status, headers = r.status, r.headers
|
|
except urllib.error.HTTPError as e:
|
|
status, headers = e.code, e.headers
|
|
self.assertEqual(status, 302)
|
|
cookies = headers.get_all("Set-Cookie") or []
|
|
joined = " || ".join(cookies)
|
|
self.assertIn("aza_session=", joined)
|
|
self.assertNotIn("secure", joined.lower())
|
|
self.assertNotIn("domain=", joined.lower())
|
|
|
|
def test_cookie_roundtrip_auth_me_200(self):
|
|
op = _no_redirect_opener()
|
|
try:
|
|
r = op.open(f"{self.base}/empfang/shell/launch?token=t-b&target=empfang_chat_shell", timeout=8)
|
|
headers = r.headers
|
|
except urllib.error.HTTPError as e:
|
|
headers = e.headers
|
|
sc = ""
|
|
for c in headers.get_all("Set-Cookie") or []:
|
|
if c.startswith("aza_session="):
|
|
sc = c.split(";", 1)[0]
|
|
self.assertTrue(sc)
|
|
req = urllib.request.Request(f"{self.base}/empfang/auth/me", headers={"Cookie": sc})
|
|
with urllib.request.urlopen(req, timeout=8) as r2:
|
|
self.assertEqual(r2.status, 200)
|
|
d = json.loads(r2.read().decode("utf-8"))
|
|
self.assertEqual(d.get("user_id"), _USER["user_id"])
|
|
self.assertEqual(d.get("practice_id"), _USER["practice_id"])
|
|
|
|
def test_sso_diag_logs_names_only_no_values(self):
|
|
log = ROOT / ".aza_test_proxy_logs" / "sso_diag.log"
|
|
try:
|
|
if log.exists():
|
|
log.unlink()
|
|
except Exception:
|
|
pass
|
|
op = _no_redirect_opener()
|
|
try:
|
|
op.open(f"{self.base}/empfang/shell/launch?token=t-diag&target=empfang_chat_shell", timeout=8)
|
|
except urllib.error.HTTPError:
|
|
pass
|
|
self.assertTrue(log.exists(), "Diagnoselog fehlt")
|
|
content = log.read_text(encoding="utf-8", errors="replace")
|
|
self.assertIn("set_cookie=aza_session", content) # nur Name
|
|
self.assertIn("status=302", content)
|
|
self.assertNotIn(_SESSION_VALUE, content) # niemals Wert
|
|
|
|
|
|
class MiniDiktatStopCycleTests(unittest.TestCase):
|
|
@classmethod
|
|
def setUpClass(cls):
|
|
import aza_mini_diktat_window as md
|
|
import aza_diktat_mixin as dm
|
|
cls.md_open = inspect.getsource(md.open_mini_diktat_window)
|
|
cls.md_sync = inspect.getsource(md.sync_mini_diktat_window)
|
|
cls.md_close = inspect.getsource(md.close_mini_diktat_window)
|
|
cls.dm_src = inspect.getsource(dm.AzaDiktatMixin.open_diktat_window)
|
|
|
|
def test_weiterfahren_stops_during_recording(self):
|
|
body = _func_body(self.md_open, "_on_mini_stop")
|
|
self.assertTrue(body)
|
|
self.assertIn("_aza_diktat_toggle", body)
|
|
self.assertIn("_diktat_neu_busy", body)
|
|
sync_body = self.md_sync
|
|
self.assertIn("command=stop_cmd", sync_body)
|
|
|
|
def test_weiter_button_reference_stored(self):
|
|
self.assertIn("_mini_diktat_weiter_btn", self.md_open)
|
|
|
|
def test_sync_button_reflects_state(self):
|
|
self.assertIn('"Stoppen"', self.md_sync)
|
|
self.assertIn('"Weiterfahren"', self.md_sync)
|
|
self.assertIn("_diktat_phase", self.md_sync)
|
|
self.assertIn("command=stop_cmd", self.md_sync)
|
|
|
|
def test_close_during_recording_stops(self):
|
|
self.assertIn("_aza_diktat_toggle", self.md_close)
|
|
self.assertIn("_diktat_phase", self.md_close)
|
|
self.assertIn("_skip_stop", self.md_close)
|
|
|
|
def test_toggle_stop_sets_and_resets_busy(self):
|
|
self.assertIn("self._diktat_neu_busy = True", self.dm_src)
|
|
self.assertIn("def _after_finalize", self.dm_src)
|
|
self.assertIn("finally:", self.dm_src)
|
|
self.assertIn("_safe_after(_after_finalize)", self.dm_src)
|
|
|
|
def test_busy_guard_on_entrypoints(self):
|
|
for name in ("toggle_diktat", "_diktat_weiterfahren", "_diktat_neu_von_logo", "do_neu"):
|
|
body = _func_body(self.dm_src, name)
|
|
self.assertTrue(body, f"{name} fehlt")
|
|
self.assertIn("_diktat_neu_busy", body, f"{name} ohne Guard")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|