""" Integration tests for XMPP connectivity. These tests require the GIA stack to be running (Prosody + gia app). They probe both the XEP-0114 component port and the c2s (client-to-server) port on 5222, mirroring exactly the flow a phone XMPP client uses: TCP connect → STARTTLS → TLS upgrade (with cert check) → SASL PLAIN auth Tests are skipped automatically when XMPP settings are absent (e.g. in CI environments without a running stack). """ from __future__ import annotations import base64 import hashlib import http.client import re import socket import ssl import time import unittest import urllib.parse import xml.etree.ElementTree as ET from django.conf import settings from django.test import SimpleTestCase # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _xmpp_configured() -> bool: return bool( getattr(settings, "XMPP_JID", None) and getattr(settings, "XMPP_SECRET", None) and getattr(settings, "XMPP_ADDRESS", None) ) def _xmpp_address() -> str: return str(settings.XMPP_ADDRESS) def _xmpp_component_port() -> int: return int(getattr(settings, "XMPP_PORT", None) or 8888) def _xmpp_c2s_port() -> int: """Standard XMPP c2s port (same as a phone client would use).""" return int(getattr(settings, "XMPP_C2S_PORT", None) or 5222) def _xmpp_domain() -> str: """The VirtualHost domain (zm.is), derived from XMPP_JID or XMPP_DOMAIN.""" domain = getattr(settings, "XMPP_DOMAIN", None) if domain: return str(domain) jid = str(settings.XMPP_JID) # Component JID is like "jews.zm.is" → parent domain is "zm.is" parts = jid.split(".") if len(parts) > 2: return ".".join(parts[1:]) return jid def _prosody_auth_endpoint() -> str: """URL of the Django auth bridge that Prosody calls for c2s authentication.""" return str(getattr(settings, "PROSODY_AUTH_ENDPOINT", "http://127.0.0.1:8090/internal/prosody/auth/")) def _recv_until(sock: socket.socket, patterns: list[bytes], timeout: float = 8.0, max_bytes: int = 16384) -> bytes: """Read from sock until one of the byte patterns appears or timeout/max_bytes hit.""" buf = b"" deadline = time.monotonic() + timeout while time.monotonic() < deadline: remaining = deadline - time.monotonic() sock.settimeout(max(0.1, remaining)) try: chunk = sock.recv(4096) except socket.timeout: break if not chunk: break buf += chunk if any(p in buf for p in patterns): break if len(buf) >= max_bytes: break return buf def _component_handshake(address: str, port: int, jid: str, secret: str, timeout: float = 5.0) -> tuple[bool, str]: """ Attempt an XEP-0114 external component handshake. Returns (success, message). """ stream_open = ( "" f"" ) try: with socket.create_connection((address, port), timeout=timeout) as sock: sock.settimeout(timeout) sock.sendall(stream_open.encode()) buf = _recv_until(sock, [b"id="], timeout=timeout) header_text = buf.decode(errors="replace") try: root = ET.fromstring(header_text + "") stream_id = root.get("id", "") except ET.ParseError: m = re.search(r'\bid=["\']([^"\']+)["\']', header_text) stream_id = m.group(1) if m else "" if not stream_id: return False, f"No stream id in header: {header_text[:200]}" token = hashlib.sha1((stream_id + secret).encode()).hexdigest() sock.sendall(f"{token}".encode()) response = _recv_until(sock, [b"" in resp_text or "" in resp_text: return True, "Handshake accepted" if "conflict" in resp_text and "already connected" in resp_text: return True, "Credentials valid (component already connected)" if "not-authorized" in resp_text: return False, "Handshake rejected: not-authorized" if response: return False, f"Unexpected response: {resp_text[:200]}" return False, "No response received after handshake" except socket.timeout: return False, f"Timed out connecting to {address}:{port}" except ConnectionRefusedError: return False, f"Connection refused to {address}:{port}" except OSError as exc: return False, f"Socket error: {exc}" class _C2SResult: """Return value from _c2s_sasl_auth.""" def __init__(self, success: bool, stage: str, detail: str): self.success = success # True = SASL self.stage = stage # where we got to: tcp/starttls/tls/features/auth self.detail = detail # human-readable explanation def __repr__(self): return f"" def _c2s_sasl_auth( address: str, port: int, domain: str, username: str, password: str, verify_cert: bool = True, timeout: float = 10.0, ) -> _C2SResult: """ Mirror the full c2s auth flow a phone XMPP client performs: 1. TCP connect to address:port 2. Open XMPP stream to domain 3. Receive with 4. Send , receive 5. ssl.wrap_socket with server_hostname=domain (cert validation if verify_cert) 6. Re-open XMPP stream 7. Receive post-TLS with SASL mechanisms 8. Send SASL PLAIN base64(\x00username\x00password) 9. Return _C2SResult with (True, "auth") on or (False, "auth") on """ NS_STREAM = "http://etherx.jabber.org/streams" NS_TLS = "urn:ietf:params:xml:ns:xmpp-tls" NS_SASL = "urn:ietf:params:xml:ns:xmpp-sasl" def stream_open(to: str) -> bytes: return ( "" f"" ).encode() try: raw = socket.create_connection((address, port), timeout=timeout) except ConnectionRefusedError: return _C2SResult(False, "tcp", f"Connection refused to {address}:{port}") except (socket.timeout, OSError) as exc: return _C2SResult(False, "tcp", f"TCP connect failed: {exc}") try: raw.settimeout(timeout) raw.sendall(stream_open(domain)) # --- Receive pre-TLS features (expect ) --- buf = _recv_until(raw, [b"", b" in pre-TLS features: {text[:300]}") # --- Negotiate STARTTLS --- raw.sendall(f"".encode()) buf2 = _recv_until(raw, [b" after STARTTLS request: {text2[:200]}") # --- Upgrade to TLS --- ctx = ssl.create_default_context() if not verify_cert: ctx.check_hostname = False ctx.verify_mode = ssl.CERT_NONE try: tls = ctx.wrap_socket(raw, server_hostname=domain) except ssl.SSLCertVerificationError as exc: return _C2SResult(False, "tls", f"TLS cert verification failed for {domain!r}: {exc}") except ssl.SSLError as exc: return _C2SResult(False, "tls", f"TLS handshake error: {exc}") tls.settimeout(timeout) # --- Re-open stream over TLS --- tls.sendall(stream_open(domain)) buf3 = _recv_until(tls, [b"", b"([^<]+)", text3, re.IGNORECASE) if not mechanisms: return _C2SResult(False, "features", f"No SASL mechanisms in post-TLS features: {text3[:300]}") if "PLAIN" not in [m.upper() for m in mechanisms]: return _C2SResult(False, "features", f"SASL PLAIN not offered; got: {mechanisms}") # --- SASL PLAIN auth --- credential = base64.b64encode(f"\x00{username}\x00{password}".encode()).decode() tls.sendall( f"{credential}".encode() ) buf4 = _recv_until(tls, [b"]*>\s*<([a-z-]+)", text4) condition = m.group(1) if m else "unknown" return _C2SResult(False, "auth", f"SASL PLAIN rejected: {condition} — {text4[:200]}") if "