from datetime import datetime, timedelta, timezone from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer import ipaddress import json import ssl import threading from cryptography import x509 from cryptography.hazmat.primitives import hashes, serialization from cryptography.hazmat.primitives.asymmetric import ec from cryptography.x509.oid import NameOID import pytest from railiance_clock.client import Clock, FileTrust from railiance_clock.core import ClockError @pytest.fixture def endpoint(setup): path, _, timer, transport = setup servers = [] def create(*, status=200, media='application/jose', encoding=None, tls=None): calls = [] class Handler(BaseHTTPRequestHandler): def log_message(self, *_): pass def do_GET(self): calls.append(self.path) self.send_response(500); self.end_headers() def do_POST(self): calls.append(self.path) body = transport(json.loads(self.rfile.read(int(self.headers['Content-Length'])))) self.send_response(status) self.send_header('Content-Type', media) self.send_header('Content-Length', str(len(body))) if 300 <= status < 400: self.send_header('Location', '/must-not-follow') if encoding: self.send_header('Content-Encoding', encoding) self.end_headers(); self.wfile.write(body) server = ThreadingHTTPServer(('127.0.0.1', 0), Handler) if tls: server.socket = tls.wrap_socket(server.socket, server_side=True) thread = threading.Thread(target=server.serve_forever); thread.start() servers.append((server, thread)) data = json.loads(path.read_text()) data['endpoint'] = f'{"https" if tls else "http"}://127.0.0.1:{server.server_port}/v1/time-samples' data['transport'] = 'https' if tls else 'admitted-loopback' path.write_text(json.dumps(data)) return Clock(FileTrust(path), timer=timer), calls yield create for server, thread in servers: server.shutdown(); server.server_close(); thread.join() def test_http_response_positive_control(endpoint): clock, calls = endpoint() assert clock.read().authority_id == 'railiance01' assert calls == ['/v1/time-samples'] @pytest.mark.parametrize('status', [301, 302, 303, 307, 308]) def test_redirect_is_never_followed(endpoint, status): clock, calls = endpoint(status=status) with pytest.raises(ClockError): clock.read() assert calls == ['/v1/time-samples'] @pytest.mark.parametrize('options', [{'media': 'application/json'}, {'media': 'text/plain'}, {'encoding': 'gzip'}, {'status': 201}, {'status': 503}]) def test_unexpected_http_response_refused(endpoint, options): clock, calls = endpoint(**options) with pytest.raises(ClockError): clock.read() assert calls == ['/v1/time-samples'] @pytest.mark.parametrize('case', ['valid', 'expired', 'future', 'wrong-host', 'untrusted']) def test_tls_certificate_validation(endpoint, tmp_path, monkeypatch, case): now = datetime.now(timezone.utc) ca_key = ec.generate_private_key(ec.SECP256R1()) ca_name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, 'Disposable test CA')]) ca = (x509.CertificateBuilder().subject_name(ca_name).issuer_name(ca_name) .public_key(ca_key.public_key()).serial_number(x509.random_serial_number()) .not_valid_before(now - timedelta(days=2)).not_valid_after(now + timedelta(days=2)) .add_extension(x509.BasicConstraints(ca=True, path_length=0), critical=True) .sign(ca_key, hashes.SHA256())) key = ec.generate_private_key(ec.SECP256R1()) start, end = now - timedelta(hours=1), now + timedelta(hours=1) if case == 'expired': start, end = now - timedelta(days=1), now - timedelta(hours=1) if case == 'future': start, end = now + timedelta(hours=1), now + timedelta(days=1) san = x509.DNSName('wrong.invalid') if case == 'wrong-host' else x509.IPAddress(ipaddress.ip_address('127.0.0.1')) leaf_name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, 'Disposable test server')]) cert = (x509.CertificateBuilder().subject_name(leaf_name).issuer_name(ca_name) .public_key(key.public_key()).serial_number(x509.random_serial_number()) .not_valid_before(start).not_valid_after(end) .add_extension(x509.BasicConstraints(ca=False, path_length=None), critical=True) .add_extension(x509.SubjectAlternativeName([san]), critical=False) .sign(ca_key, hashes.SHA256())) ca_file, cert_file, key_file = (tmp_path / name for name in ('ca.pem', 'cert.pem', 'key.pem')) ca_file.write_bytes(ca.public_bytes(serialization.Encoding.PEM)) cert_file.write_bytes(cert.public_bytes(serialization.Encoding.PEM)) key_file.write_bytes(key.private_bytes(serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, serialization.NoEncryption())) key_file.chmod(0o600) empty = tmp_path / 'empty-ca-directory'; empty.mkdir() # Exercise urllib's real default verification; no permissive SSL context. monkeypatch.setenv('SSL_CERT_FILE', str(ca_file if case != 'untrusted' else empty / 'missing.pem')) monkeypatch.setenv('SSL_CERT_DIR', str(empty)) context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) context.load_cert_chain(cert_file, key_file) clock, calls = endpoint(tls=context) if case == 'valid': assert clock.read().authority_id == 'railiance01' assert calls == ['/v1/time-samples'] else: with pytest.raises(ClockError) as exc: clock.read() reason = exc.value.__cause__.reason assert isinstance(reason, ssl.SSLCertVerificationError) expected = {'expired': 'expired', 'future': 'not yet valid', 'wrong-host': 'IP address mismatch', 'untrusted': 'issuer certificate'} assert expected[case] in reason.verify_message assert calls == [] # Refused during TLS, before application data.