Files
aza/AzA march 2026/_test_rcv7_office_chat_contacts_sso.py

241 lines
9.4 KiB
Python
Raw Normal View History

2026-06-13 22:47:31 +02:00
# -*- coding: utf-8 -*-
"""RC V7: Office -> Chat/Kontakte Single-Sign-On ueber den lokalen Test-Proxy.
Root Cause: der Test-Proxy folgte 3xx-Redirects selbst (Default-urllib-Opener).
Beim GET /empfang/shell/launch (302 + Set-Cookie aza_session) wurde dadurch der
Einmal-Token serverseitig verbraucht und das Set-Cookie verschluckt -> die
WebView blieb unauthentifiziert -> Loginmaske in Chat UND Kontakte.
Fix: _PROXY_OPENER folgt 3xx NICHT mehr; die 302 (Location lokal, Set-Cookie
weitergereicht) geht an die WebView, die dem Redirect selbst folgt. _localize_set_cookie
macht das Cookie fuer 127.0.0.1/HTTP annehmbar (Domain/Secure nur im Testmodus entfernt).
Der Test startet einen Fake-Upstream, der /shell/launch wie das echte Backend
beantwortet, und prueft den Proxy end-to-end.
"""
from __future__ import annotations
import http.cookiejar
import inspect
import json
import os
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-abc123-secret"
_USER = {"user_id": "u-andre-001", "practice_id": "p-lindengut-007", "display_name": "Andre M. Surovy"}
_used_tokens = set()
_used_lock = threading.Lock()
class _FakeUpstreamHandler(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def log_message(self, *a):
pass
def _json(self, code, payload):
body = json.dumps(payload).encode("utf-8")
self.send_response(code)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_GET(self):
if self.path.startswith("/empfang/shell/launch"):
# Token-Query auslesen
tok = ""
if "token=" in self.path:
tok = self.path.split("token=", 1)[1].split("&", 1)[0]
with _used_lock:
if not tok or tok in _used_tokens:
self._json(401, {"detail": "Shell-Token ungueltig/verbraucht"})
return
_used_tokens.add(tok)
# 302 + Set-Cookie wie echtes Backend (mit Domain + Secure, mehrere Cookies)
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("Set-Cookie", "aza_csrf=csrf-xyz; Path=/; SameSite=Lax")
self.send_header("Content-Length", "0")
self.end_headers()
return
if self.path.startswith("/empfang/shell/context/me") or self.path.startswith("/auth/me"):
cookie = self.headers.get("Cookie") or ""
if f"aza_session={_SESSION_VALUE}" in cookie:
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 OfficeChatContactsSSOTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
os.environ["AZA_DOKU_PROMPT_TEST"] = "1"
_used_tokens.clear()
cls.upstream = ThreadingHTTPServer(("127.0.0.1", 0), _FakeUpstreamHandler)
cls.up_port = cls.upstream.server_address[1]
threading.Thread(target=cls.upstream.serve_forever, daemon=True).start()
os.environ["AZA_EMPFANG_TEST_UPSTREAM"] = f"http://127.0.0.1:{cls.up_port}"
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):
try:
cls.srv.shutdown()
cls.srv.server_close()
except Exception:
pass
try:
cls.upstream.shutdown()
cls.upstream.server_close()
except Exception:
pass
# --- Root-Cause-Beweis + Fix: Proxy folgt 302 NICHT, reicht Cookie durch ---
def _launch_once(self, token):
opener = _no_redirect_opener()
req = urllib.request.Request(f"{self.base}/empfang/shell/launch?token={token}&target=empfang_chat_shell")
try:
resp = opener.open(req, timeout=8)
return resp.status, resp.headers
except urllib.error.HTTPError as e:
return e.code, e.headers
def test_launch_returns_302_not_followed(self):
status, headers = self._launch_once("tok-chat-1")
self.assertEqual(status, 302, "Proxy darf den Redirect nicht selbst folgen")
loc = headers.get("Location") or ""
self.assertTrue(loc.startswith(self.base + "/empfang/"), f"Location nicht lokal: {loc}")
self.assertIn("empfang_chat_shell=1", loc)
def test_set_cookie_forwarded_and_localized(self):
status, headers = self._launch_once("tok-chat-2")
cookies = headers.get_all("Set-Cookie") or []
joined = " || ".join(cookies)
self.assertIn("aza_session=" + _SESSION_VALUE, joined)
# Mehrere Set-Cookie bleiben erhalten
self.assertTrue(any(c.startswith("aza_csrf=") for c in cookies), f"2. Cookie fehlt: {cookies}")
# Domain (Live) und Secure (ueber HTTP) im Testmodus entfernt
self.assertNotIn("domain=", joined.lower())
self.assertNotIn("secure", joined.lower())
# Schutzattribute bleiben
self.assertIn("HttpOnly", joined)
self.assertIn("SameSite", joined)
def test_context_me_authenticated_after_launch(self):
# 1) Launch -> Cookie holen
_status, headers = self._launch_once("tok-chat-3")
session_cookie = ""
for c in headers.get_all("Set-Cookie") or []:
if c.startswith("aza_session="):
session_cookie = c.split(";", 1)[0]
break
self.assertTrue(session_cookie, "kein aza_session-Cookie erhalten")
# 2) context/me mit Cookie ueber den Proxy -> derselbe Benutzer wie Office
req = urllib.request.Request(
f"{self.base}/empfang/shell/context/me",
headers={"Cookie": session_cookie},
)
with urllib.request.urlopen(req, timeout=8) as r:
self.assertEqual(r.status, 200)
data = json.loads(r.read().decode("utf-8"))
self.assertEqual(data.get("user_id"), _USER["user_id"])
self.assertEqual(data.get("practice_id"), _USER["practice_id"])
self.assertEqual(data.get("display_name"), _USER["display_name"])
def test_context_me_401_without_cookie(self):
try:
with urllib.request.urlopen(f"{self.base}/empfang/shell/context/me", timeout=8) as r:
code = r.status
except urllib.error.HTTPError as e:
code = e.code
self.assertEqual(code, 401)
def test_token_is_one_time(self):
s1, _ = self._launch_once("tok-once")
s2, _ = self._launch_once("tok-once")
self.assertEqual(s1, 302)
self.assertEqual(s2, 401, "verbrauchter Token muss abgelehnt werden")
# --- Einheiten: Cookie-Lokalisierung + No-Redirect-Opener ---
def test_localize_set_cookie_strips_domain_and_secure(self):
h = self.px._EmpfangTestProxyHandler
inst = h.__new__(h)
out = inst._localize_set_cookie(
"aza_session=V; HttpOnly; Path=/; SameSite=Lax; Secure; Domain=api.aza-medwork.ch"
)
self.assertIn("aza_session=V", out)
self.assertIn("HttpOnly", out)
self.assertIn("SameSite=Lax", out)
self.assertIn("Path=/", out)
self.assertNotIn("Domain", out)
self.assertNotIn("Secure", out)
def test_localize_set_cookie_keeps_plain_cookie(self):
h = self.px._EmpfangTestProxyHandler
inst = h.__new__(h)
out = inst._localize_set_cookie("aza_session=V; HttpOnly; Path=/; SameSite=Lax")
self.assertEqual(out, "aza_session=V; HttpOnly; Path=/; SameSite=Lax")
def test_proxy_does_not_follow_redirects(self):
self.assertIsNone(
self.px._NoRedirectHandler.redirect_request(
self.px._NoRedirectHandler(), None, None, 302, "", {}, "http://x/"
)
)
def test_proxy_does_not_log_requests(self):
src = inspect.getsource(self.px._EmpfangTestProxyHandler.log_message)
self.assertIn("pass", src)
class ServerSideHandoffSourceTests(unittest.TestCase):
"""Office bleibt Quelle der Wahrheit; Token einmalig + an user_id/practice_id gebunden."""
@classmethod
def setUpClass(cls):
cls.src = (ROOT / "empfang_routes.py").read_text(encoding="utf-8")
def test_token_one_time_consumed(self):
self.assertIn("del _shell_store[stok]", self.src)
def test_launch_sets_httponly_lax_cookie(self):
self.assertIn('"aza_session"', self.src)
self.assertIn("httponly=True", self.src)
self.assertIn('samesite="lax"', self.src)
def test_token_bound_to_user_and_practice(self):
self.assertIn('"user_id": uid', self.src)
self.assertIn('"practice_id": pid', self.src)
self.assertIn('"purpose": _SHELL_PURPOSE', self.src)
if __name__ == "__main__":
unittest.main()