3proxy/tests/harness.py
2026-08-26 16:28:27 +03:00

874 lines
33 KiB
Python

"""Support code for the 3proxy regression tests.
Everything here is standard library, so the suite runs wherever 3proxy
builds: no shell, no curl, no netcat.
A test case is a module under tests/cases/ exporting run(t). It writes the
configurations it needs, starts them, and states what it expects:
def run(t):
srv = t.free_port()
t.start("echo", f'''
log
auth iponly
allow *
http * /echo echo
httpsrv -p{srv}
''', ports=[srv])
r = t.http(f"http://127.0.0.1:{srv}/echo")
t.eq(200, r.status, "the server answers")
"""
import base64
import http.client
import os
import shutil
import socket
import ssl
import struct
import subprocess
import sys
import textwrap
import threading
import time
class Response:
"""A reply, or the reason there wasn't one."""
def __init__(self, status=None, body=b"", headers=None, error=None):
self.status = status
self.body = body
self.headers = headers or {}
self.error = error
@property
def text(self):
return self.body.decode("utf-8", "replace")
@property
def length(self):
return len(self.body)
def header(self, name):
for k, v in self.headers.items():
if k.lower() == name.lower():
return v
return None
def __repr__(self):
if self.error:
return f"<no reply: {self.error}>"
return f"<{self.status}, {len(self.body)} bytes>"
class Server:
"""A running 3proxy, with the configuration it was given."""
def __init__(self, name, path, proc, logfile):
self.name = name
self.path = path
self.proc = proc
self.logfile = logfile
def output(self):
try:
with open(self.logfile, "rb") as fp:
return fp.read().decode("utf-8", "replace")
except OSError:
return ""
def stop(self):
if self.proc.poll() is None:
self.proc.terminate()
try:
self.proc.wait(timeout=5)
except subprocess.TimeoutExpired:
self.proc.kill()
self.proc.wait(timeout=5)
class Certs:
"""A test CA, a certificate it signed, and somewhere to cache spoofed ones.
Paths use forward slashes: they are written into configurations read by
3proxy, and ssl_certcache insists on a trailing separator.
"""
def __init__(self, directory):
self.dir = directory.replace("\\", "/")
self.ca = self.dir + "/ca.pem"
self.ca_key = self.dir + "/ca.key"
self.server = self.dir + "/server.pem"
self.server_key = self.dir + "/server.key"
# a second CA nothing is signed by, for the cases that must fail
self.other = self.dir + "/other.pem"
self.other_key = self.dir + "/other.key"
self.cache = self.dir + "/cache/"
self.verified = False
self.verify_output = ""
class Failure(Exception):
"""Raised when a case cannot go on, e.g. a server refused to start."""
class Tester:
"""The API a case runs against: start servers, make requests, assert."""
def __init__(self, binary, tmpdir, case):
self.binary = binary
self.tmpdir = tmpdir
self.case = case
self.servers = []
self.checks = []
self.timeout = 10
self._skipped = 0
self._certs = None
self.logs = []
self.udp_servers = []
# ---- servers -----------------------------------------------------
def has_ipv6(self):
"""Whether this machine can use the IPv6 loopback at all."""
try:
sock = socket.socket(socket.AF_INET6, socket.SOCK_STREAM)
except OSError:
return False
try:
sock.bind(("::1", 0))
return True
except OSError:
return False
finally:
sock.close()
def free_port(self):
"""A port nothing is listening on. Closed again before it is used,
which is racy in principle and reliable enough in practice."""
s = socket.socket()
try:
s.bind(("127.0.0.1", 0))
return s.getsockname()[1]
finally:
s.close()
def write_config(self, name, config):
path = os.path.join(self.tmpdir, name + ".cfg")
text = textwrap.dedent(config).strip() + "\n"
# newline="" keeps the line endings as written, rather than letting
# Windows turn them into CRLF behind the parser's back
with open(path, "w", newline="") as fp:
fp.write(text)
return path
def start(self, name, config, ports=()):
"""Write a configuration, run it, and wait for its ports to open.
A port may be given as a number, or as (address, port) for a service
bound somewhere other than 127.0.0.1.
"""
path = self.write_config(name, config)
logfile = os.path.join(self.tmpdir, name + ".out")
with open(logfile, "wb") as out:
proc = subprocess.Popen([self.binary, path], stdout=out,
stderr=subprocess.STDOUT)
server = Server(name, path, proc, logfile)
self.servers.append(server)
for entry in ports:
host, port = entry if isinstance(entry, tuple) else ("127.0.0.1", entry)
if not self.wait_port(port, host=host):
code = proc.poll()
if code is None:
died = "the process is still running"
else:
died = f"the process exited with code {code}"
if os.name == "nt" and code is not None and code & 0xFFFFFFFF == 0xC0000135:
died += " (a DLL it needs was not found)"
raise Failure(
f"{name} never listened on port {port}: {died}\n"
f"--- configuration ---\n{open(path).read()}"
f"--- output ---\n{server.output()}")
return server
def run_config(self, name, config):
"""Run a configuration expected to be rejected; return its output."""
path = self.write_config(name, config)
done = subprocess.run([self.binary, path], stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, timeout=15)
return done.stdout.decode("utf-8", "replace")
def wait_port(self, port, timeout=5.0, host="127.0.0.1"):
deadline = time.time() + timeout
while time.time() < deadline:
try:
with socket.create_connection((host, port), 0.25):
return True
except OSError:
time.sleep(0.02)
return False
def wait_output(self, server, needle, timeout=5.0, since=0):
"""Wait for a server to log something.
A record is written when the connection it describes finishes, not
when the reply reaches the client, so reading straight after a
request usually finds nothing yet.
"""
deadline = time.time() + timeout
while True:
text = server.output()[since:]
if needle in text or time.time() > deadline:
return text
time.sleep(0.05)
def stop_all(self):
"""Stop the servers, keeping what they printed for the report."""
for sock in self.udp_servers:
sock.close()
self.udp_servers = []
for server in self.servers:
server.stop()
self.logs.append((server.name, server.output()))
self.servers = []
# ---- requests ----------------------------------------------------
def http(self, url, proxy=None, socks=None, socks4=False,
remote_dns=False, method="GET", body=None, headers=None,
auth=None, proxy_auth=None, tunnel=False, conn=None):
"""Make a request, directly or through a proxy, and read the reply.
proxy "host:port" of an HTTP proxy
socks "host:port" of a SOCKS proxy
tunnel reach the origin with CONNECT rather than an absolute URI
conn reuse a connection returned by connection()
"""
host, port, path = self._split(url)
headers = dict(headers or {})
if auth:
headers["Authorization"] = self._basic(auth)
if proxy_auth:
headers["Proxy-Authorization"] = self._basic(proxy_auth)
own = conn is None
try:
if own:
conn = self.connection(host, port, proxy=proxy, socks=socks,
socks4=socks4, remote_dns=remote_dns,
tunnel=tunnel)
target = path
if proxy and not tunnel:
# an address with colons goes back in brackets, or the
# absolute URI cannot be read
authority = f"[{host}]" if ":" in host else host
target = f"http://{authority}:{port}{path}"
if body is not None and not isinstance(body, bytes):
body = body.encode()
conn.request(method, target, body=body, headers=headers)
reply = conn.getresponse()
data = reply.read()
return Response(reply.status, data, dict(reply.getheaders()))
except (OSError, http.client.HTTPException) as exc:
return Response(error=f"{type(exc).__name__}: {exc}")
finally:
if own and conn is not None:
try:
conn.close()
except OSError:
pass
def connection(self, host, port, proxy=None, socks=None, socks4=False,
remote_dns=False, tunnel=False):
"""A connection to an origin, kept open for reuse."""
if socks:
shost, sport = self._hostport(socks)
sock = self._socks_connect(shost, sport, host, port,
socks4=socks4, remote_dns=remote_dns)
conn = http.client.HTTPConnection(host, port, timeout=self.timeout)
conn.sock = sock
return conn
if proxy:
phost, pport = self._hostport(proxy)
conn = http.client.HTTPConnection(phost, pport, timeout=self.timeout)
if tunnel:
conn.set_tunnel(host, port)
return conn
return http.client.HTTPConnection(host, port, timeout=self.timeout)
def raw(self, port, request, host="127.0.0.1"):
"""Send bytes as they are and return whatever comes back."""
if not isinstance(request, bytes):
request = request.encode("latin-1")
try:
with socket.create_connection((host, port), self.timeout) as sock:
sock.settimeout(self.timeout)
sock.sendall(request)
chunks = []
while True:
try:
piece = sock.recv(65536)
except OSError:
# a timeout, or a reset once the server is done:
# either way keep whatever already arrived
break
if not piece:
break
chunks.append(piece)
return b"".join(chunks).decode("utf-8", "replace")
except OSError as exc:
return f"<no reply: {exc}>"
# ---- UDP ---------------------------------------------------------
def udp_echo(self, prefix=b"echo:"):
"""Start a UDP server that echoes what it receives, and give its port.
Something has to be on the far side of a port mapper or a SOCKS
association for the data path to be visible at all.
"""
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.bind(("127.0.0.1", 0))
port = sock.getsockname()[1]
def serve():
while True:
try:
data, peer = sock.recvfrom(65536)
except OSError:
return
try:
sock.sendto(prefix + data, peer)
except OSError:
return
thread = threading.Thread(target=serve, daemon=True)
thread.start()
self.udp_servers.append(sock)
return port
def udp_exchange(self, port, payload, host="127.0.0.1"):
"""Send one datagram and return the reply, or None."""
if not isinstance(payload, bytes):
payload = payload.encode()
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.settimeout(self.timeout)
try:
sock.sendto(payload, (host, port))
return sock.recvfrom(65536)[0]
except OSError:
return None
finally:
sock.close()
def wait_udp(self, port, payload=b"ping", timeout=5.0):
"""Wait until a UDP service answers.
There is no socket to connect to, so readiness can only be found
out by asking; a datagram sent before the service is up is simply
lost.
"""
deadline = time.time() + timeout
while time.time() < deadline:
if self.udp_exchange(port, payload) is not None:
return True
time.sleep(0.05)
return False
def socks_udp(self, socks, host, port, payload, keep=None):
"""Relay a datagram through a SOCKS5 association.
Returns (reply payload, association port), or (None, port) if
nothing came back. The control connection has to stay open for the
association to live, so it is closed only on the way out.
"""
if not isinstance(payload, bytes):
payload = payload.encode()
shost, sport = self._hostport(socks)
ctrl = None
udp = None
try:
ctrl = socket.create_connection((shost, sport), self.timeout)
ctrl.settimeout(self.timeout)
ctrl.sendall(b"\x05\x01\x00")
if self._recvall(ctrl, 2) != b"\x05\x00":
return None, None
ctrl.sendall(b"\x05\x03\x00\x01\x00\x00\x00\x00" + struct.pack("!H", 0))
reply = self._recvall(ctrl, 4)
if len(reply) < 4 or reply[1] != 0:
return None, None
_, bound = self._read_socks_addr(ctrl, reply[3])
udp = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
udp.settimeout(self.timeout)
header = (b"\x00\x00\x00\x01" + socket.inet_aton(host) +
struct.pack("!H", port))
udp.sendto(header + payload, (shost, bound))
try:
data = udp.recvfrom(65536)[0]
except OSError:
return None, bound
# the reply carries the same kind of header, which is not payload
if len(data) < 10 or data[3] != 1:
return None, bound
return data[10:], bound
except OSError:
return None, None
finally:
if udp:
udp.close()
if ctrl:
ctrl.close()
# ---- DNS ---------------------------------------------------------
def dns_query(self, port, name, host="127.0.0.1", timeout=2.0):
"""Ask for an A record and return the addresses in the answer.
The default timeout is short: a name server on the loopback answers
at once or not at all, and waiting the full request timeout on every
attempt turns a server that answers nothing into a very slow run.
"""
query = struct.pack("!HHHHHH", 0x2A2A, 0x0100, 1, 0, 0, 0)
for label in name.split("."):
query += bytes([len(label)]) + label.encode()
query += b"\x00" + struct.pack("!HH", 1, 1)
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.settimeout(timeout)
try:
sock.sendto(query, (host, port))
data = sock.recvfrom(65536)[0]
except OSError:
return None
finally:
sock.close()
if len(data) < 12 or data[:2] != query[:2]:
return None
answers = struct.unpack("!H", data[6:8])[0]
addresses = []
pos = 12
while pos < len(data) and data[pos]: # skip the question
pos += data[pos] + 1
pos += 5
for _ in range(answers):
if pos + 12 > len(data):
break
if data[pos] & 0xC0 == 0xC0:
pos += 2
else:
while pos < len(data) and data[pos]:
pos += data[pos] + 1
pos += 1
rtype, _, _, rdlen = struct.unpack("!HHIH", data[pos:pos + 10])
pos += 10
if rtype == 1 and rdlen == 4:
addresses.append(socket.inet_ntoa(data[pos:pos + 4]))
pos += rdlen
return addresses
# ---- SOCKS -------------------------------------------------------
def _socks_connect(self, shost, sport, host, port, socks4=False,
remote_dns=False, auth=None):
sock = socket.create_connection((shost, sport), self.timeout)
sock.settimeout(self.timeout)
try:
if socks4:
addr = socket.inet_aton(socket.gethostbyname(host))
sock.sendall(b"\x04\x01" + struct.pack("!H", port) + addr + b"\x00")
reply = self._recvall(sock, 8)
if len(reply) < 2 or reply[1] != 0x5a:
raise OSError("SOCKS4 request refused")
return sock
if auth:
sock.sendall(b"\x05\x02\x00\x02")
else:
sock.sendall(b"\x05\x01\x00")
reply = self._recvall(sock, 2)
if len(reply) < 2 or reply[0] != 5:
raise OSError("SOCKS5 handshake failed")
if reply[1] == 0x02:
if not auth:
raise OSError("SOCKS5 server demands credentials")
user, password = auth
sock.sendall(b"\x01" + bytes([len(user)]) + user.encode() +
bytes([len(password)]) + password.encode())
status = self._recvall(sock, 2)
if len(status) < 2 or status[1] != 0:
raise OSError("SOCKS5 credentials refused")
elif reply[1] != 0x00:
raise OSError("SOCKS5 offered no acceptable method")
if remote_dns:
target = b"\x03" + bytes([len(host)]) + host.encode()
elif ":" in host:
target = b"\x04" + socket.inet_pton(socket.AF_INET6, host)
else:
target = b"\x01" + socket.inet_aton(socket.gethostbyname(host))
sock.sendall(b"\x05\x01\x00" + target + struct.pack("!H", port))
reply = self._recvall(sock, 4)
if len(reply) < 4 or reply[1] != 0:
raise OSError("SOCKS5 request refused")
self._read_socks_addr(sock, reply[3])
return sock
except Exception:
sock.close()
raise
def socks_connect(self, socks, host, port, socks4=False, remote_dns=False,
auth=None):
"""Open a SOCKS connection, reporting failure rather than raising."""
shost, sport = self._hostport(socks)
try:
sock = self._socks_connect(shost, sport, host, port, socks4=socks4,
remote_dns=remote_dns, auth=auth)
sock.close()
return None
except OSError as exc:
return str(exc)
def socks_http(self, socks, url, auth=None, **kwargs):
"""A request through SOCKS, with optional SOCKS credentials."""
host, port, path = self._split(url)
shost, sport = self._hostport(socks)
try:
sock = self._socks_connect(shost, sport, host, port, auth=auth,
**kwargs)
except OSError as exc:
return Response(error=str(exc))
conn = http.client.HTTPConnection(host, port, timeout=self.timeout)
conn.sock = sock
try:
conn.request("GET", path)
reply = conn.getresponse()
return Response(reply.status, reply.read(), dict(reply.getheaders()))
except (OSError, http.client.HTTPException) as exc:
return Response(error=str(exc))
finally:
conn.close()
def socks_udp_associate(self, port, host="127.0.0.1"):
"""Ask for a UDP association and report the port handed back.
That socket is allocated per association, which is where an intport
range has to take effect.
"""
try:
with socket.create_connection((host, port), self.timeout) as sock:
sock.settimeout(self.timeout)
sock.sendall(b"\x05\x01\x00")
if self._recvall(sock, 2) != b"\x05\x00":
return None
sock.sendall(b"\x05\x03\x00\x01\x00\x00\x00\x00" +
struct.pack("!H", 0))
reply = self._recvall(sock, 4)
if len(reply) < 4 or reply[1] != 0:
return None
_, bound = self._read_socks_addr(sock, reply[3])
return bound
except OSError:
return None
def _read_socks_addr(self, sock, atyp):
if atyp == 1:
addr = socket.inet_ntoa(self._recvall(sock, 4))
elif atyp == 3:
length = self._recvall(sock, 1)[0]
addr = self._recvall(sock, length).decode()
elif atyp == 4:
addr = self._recvall(sock, 16).hex()
else:
raise OSError(f"unknown SOCKS address type {atyp}")
port = struct.unpack("!H", self._recvall(sock, 2))[0]
return addr, port
@staticmethod
def _recvall(sock, count):
data = b""
while len(data) < count:
piece = sock.recv(count - len(data))
if not piece:
break
data += piece
return data
# ---- TLS ---------------------------------------------------------
def certs(self):
"""A CA and a certificate for 127.0.0.1, generated once per run.
Returns None when openssl is unavailable, so a case can skip rather
than fail on a machine that cannot make key material.
"""
if self._certs is not None:
return self._certs or None
if not shutil.which("openssl"):
self._certs = False
return None
c = Certs(os.path.join(self.tmpdir, "certs"))
os.makedirs(c.cache, exist_ok=True)
csr = c.dir + "/server.csr"
ext = c.dir + "/server.ext"
ca_ext = c.dir + "/ca.ext"
# The key identifiers are spelled out because LibreSSL does not add
# them for a signed certificate the way OpenSSL 3 does, and Python
# rejects a chain with no Authority Key Identifier from 3.13.
with open(ext, "w") as fp:
fp.write("subjectAltName=IP:127.0.0.1,DNS:localhost,DNS:sni.test\n"
"subjectKeyIdentifier=hash\n"
"authorityKeyIdentifier=keyid,issuer\n")
# A CA without these is not usable as one. They go in a file rather
# than in -addext, which LibreSSL - the openssl on a stock macOS -
# does not apply the same way.
with open(ca_ext, "w") as fp:
fp.write("basicConstraints=critical,CA:TRUE\n"
"keyUsage=critical,keyCertSign,cRLSign\n"
"subjectKeyIdentifier=hash\n")
def ca_steps(key, csr_path, out, name):
return [
["openssl", "genrsa", "-out", key, "2048"],
["openssl", "req", "-new", "-nodes", "-key", key,
"-subj", "/CN=" + name, "-out", csr_path],
["openssl", "x509", "-req", "-in", csr_path, "-signkey", key,
"-days", "3650", "-sha256", "-extfile", ca_ext, "-out", out],
]
steps = (
ca_steps(c.ca_key, c.dir + "/ca.csr", c.ca, "3proxy-test-ca") +
ca_steps(c.other_key, c.dir + "/other.csr", c.other,
"3proxy-test-other-ca") +
[
["openssl", "genrsa", "-out", c.server_key, "2048"],
["openssl", "req", "-new", "-key", c.server_key,
"-subj", "/CN=127.0.0.1", "-out", csr],
["openssl", "x509", "-req", "-in", csr, "-CA", c.ca,
"-CAkey", c.ca_key, "-CAcreateserial", "-out", c.server,
"-days", "3650", "-sha256", "-extfile", ext],
])
for step in steps:
done = subprocess.run(step, stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, timeout=60)
if done.returncode:
self._certs = False
return None
# If the chain does not verify, the fault is in the generation, not
# in whatever is about to present it.
# -x509_strict is what a current client applies, so check that here
# rather than discovering it in a handshake.
check = subprocess.run(["openssl", "verify", "-x509_strict",
"-CAfile", c.ca, c.server],
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, timeout=60)
c.verified = check.returncode == 0
c.verify_output = check.stdout.decode("utf-8", "replace").strip()
self._certs = c
return c
def _context(self, ca=None, strict=True, verify_name=True):
"""A client context.
strict=False drops the RFC 5280 checks Python turns on by default
from 3.13, which reject a certificate with no Authority Key
Identifier. verify_name=False keeps the chain check but ignores
which host the certificate names, for the intercepted connections
where that is the upstream identity rather than the one asked for.
"""
if ca:
context = ssl.create_default_context(cafile=ca)
if not strict:
context.verify_flags &= ~getattr(ssl, "VERIFY_X509_STRICT", 0)
if not verify_name:
context.check_hostname = False
return context
context = ssl.create_default_context()
context.check_hostname = False
context.verify_mode = ssl.CERT_NONE
return context
def tls_proxy_http(self, proxy, url, ca=None, strict=True, method="GET",
body=None, headers=None):
"""A request to a proxy that is itself wrapped in TLS (ssl_serv)."""
host, port, path = self._split(url)
phost, pport = self._hostport(proxy)
try:
raw = socket.create_connection((phost, pport), self.timeout)
sock = self._context(ca, strict).wrap_socket(raw, server_hostname=phost)
except (OSError, ssl.SSLError) as exc:
return Response(error=f"{type(exc).__name__}: {exc}")
conn = http.client.HTTPConnection(host, port, timeout=self.timeout)
conn.sock = sock
try:
if body is not None and not isinstance(body, bytes):
body = body.encode()
authority = f"[{host}]" if ":" in host else host
conn.request(method, f"http://{authority}:{port}{path}", body=body,
headers=headers or {})
reply = conn.getresponse()
return Response(reply.status, reply.read(), dict(reply.getheaders()))
except (OSError, http.client.HTTPException) as exc:
return Response(error=f"{type(exc).__name__}: {exc}")
finally:
conn.close()
def https(self, url, proxy=None, ca=None, strict=True, verify_name=True,
method="GET", headers=None, connect_to=None):
"""An https:// request, optionally tunnelled through a proxy.
connect_to sends the handshake somewhere other than the name in the
URL, which is how a name-directed proxy is reached: the name still
goes out in the handshake and is what the certificate is checked
against.
"""
host, port, path = self._split(url, default_port=443)
context = self._context(ca, strict, verify_name)
try:
if connect_to:
raw = socket.create_connection(connect_to, self.timeout)
conn = http.client.HTTPSConnection(host, port, context=context,
timeout=self.timeout)
conn.sock = context.wrap_socket(raw, server_hostname=host)
conn.request(method, path, headers=headers or {})
reply = conn.getresponse()
return Response(reply.status, reply.read(),
dict(reply.getheaders()))
if proxy:
phost, pport = self._hostport(proxy)
conn = http.client.HTTPSConnection(phost, pport, context=context,
timeout=self.timeout)
conn.set_tunnel(host, port)
else:
conn = http.client.HTTPSConnection(host, port, context=context,
timeout=self.timeout)
conn.request(method, path, headers=headers or {})
reply = conn.getresponse()
return Response(reply.status, reply.read(), dict(reply.getheaders()))
except (OSError, ssl.SSLError, http.client.HTTPException) as exc:
return Response(error=f"{type(exc).__name__}: {exc}")
finally:
try:
conn.close()
except (OSError, NameError, UnboundLocalError):
pass
# ---- helpers -----------------------------------------------------
@staticmethod
def _basic(credentials):
user, password = credentials
token = base64.b64encode(f"{user}:{password}".encode()).decode()
return "Basic " + token
@staticmethod
def _hostport(value):
if value.startswith("["):
host, _, rest = value[1:].partition("]")
return host, int(rest[1:])
host, _, port = value.rpartition(":")
return host or "127.0.0.1", int(port)
@staticmethod
def _split(url, default_port=80):
"""Split a URL, understanding an address in brackets.
The brackets are dropped: they belong to the URL, not to the address
a socket call or a certificate check wants.
"""
for prefix in ("http://", "https://"):
if url.startswith(prefix):
url = url[len(prefix):]
break
authority, _, path = url.partition("/")
if authority.startswith("["):
host, _, rest = authority[1:].partition("]")
port = rest[1:] if rest.startswith(":") else default_port
elif ":" in authority:
host, _, port = authority.rpartition(":")
else:
host, port = authority, default_port
return host or "127.0.0.1", int(port), "/" + path
# ---- assertions --------------------------------------------------
def _record(self, passed, label, expected=None, actual=None):
self.checks.append((passed, label, expected, actual))
return passed
def ok(self, label):
return self._record(True, label)
def fail(self, label, expected=None, actual=None):
return self._record(False, label, expected, actual)
def eq(self, expected, actual, label):
return self._record(expected == actual, label, expected, actual)
def ne(self, unexpected, actual, label):
return self._record(unexpected != actual, label,
f"anything but {unexpected!r}", actual)
@staticmethod
def _as_text(value):
"""A reply that never arrived has no text, so report the reason."""
if isinstance(value, Response):
if value.error:
return f"<no reply: {value.error}>"
if not value.body and value.status is not None:
return f"<{value.status}, empty body>"
return value.text
return value
def contains(self, haystack, needle, label):
haystack = self._as_text(haystack)
return self._record(needle in haystack, label,
f"text containing {needle!r}", self._clip(haystack))
def not_contains(self, haystack, needle, label):
haystack = self._as_text(haystack)
return self._record(needle not in haystack, label,
f"text without {needle!r}", self._clip(haystack))
def in_range(self, value, low, high, label):
good = isinstance(value, int) and low <= value <= high
return self._record(good, label, f"between {low} and {high}", value)
def not_in_range(self, value, low, high, label):
good = isinstance(value, int) and not (low <= value <= high)
return self._record(good, label, f"outside {low}-{high}", value)
def skip(self, label):
self._skipped += 1
self.checks.append((None, label, None, None))
@staticmethod
def _clip(text, limit=200):
text = str(text).replace("\r\n", " ").replace("\n", " ")
return text[:limit] + ("..." if len(text) > limit else "")
def field(response, name):
"""Pull one 'key=value' line out of an echo reply."""
text = response.text if isinstance(response, Response) else response
for line in text.splitlines():
key, _, value = line.partition("=")
if key == name:
return value
return None
def int_field(response, name):
value = field(response, name)
try:
return int(value)
except (TypeError, ValueError):
return None