"""
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 "