import json import os from pathlib import Path import threading from urllib.request import Request, urlopen from urllib.error import HTTPError import pytest from cryptography.hazmat.primitives.asymmetric import ec from cryptography.hazmat.primitives import serialization from railiance_clock.authority import Authority, Server from railiance_clock.protocol import PROFILE, encode64, verify from railiance_clock.core import ClockError from railiance_clock.health import assess @pytest.fixture def authority(tmp_path): key=ec.generate_private_key(ec.SECP256R1()) kp=tmp_path/'key.pem';kp.write_bytes(key.private_bytes(serialization.Encoding.PEM,serialization.PrivateFormat.PKCS8,serialization.NoEncryption()));kp.chmod(0o600) hp=tmp_path/'health.json' hp.write_text(json.dumps({'quality':'bounded','boot_id':Path('/proc/sys/kernel/random/boot_id').read_text().strip(),'policy_id':'test-v1','observed_monotonic_ns':1_000_000_000,'error_ns':1000}));hp.chmod(0o644) cfg={'private_key_file':str(kp),'health_file':str(hp),'health_owner_uid':os.getuid(),'policy_id':'test-v1','authority_id':'test-authority','environment':'test','kid':'k1','max_health_age_ns':1_000_000_000,'host_drift_ppm':1000,'max_error_ns':100_000_000,'max_continuity_error_ns':10_000_000,'max_sample_age_ns':5_000_000_000} clocks={'wall':100_000_000_000,'mono':1_000_000_000} return Authority(cfg,wall=lambda:clocks['wall'],monotonic=lambda:clocks['mono']),hp,clocks def request(): return dict(profile=PROFILE,authority_id='test-authority',environment='test',nonce=encode64(os.urandom(32))) def test_live_http_sample_and_health_refusal(authority): a,hp,clocks=authority server=Server(('127.0.0.1',0),a);thread=threading.Thread(target=server.serve_forever);thread.start() try: url='http://127.0.0.1:'+str(server.server_port)+'/v1/time-samples' req=Request(url,data=json.dumps(request()).encode(),headers={'Content-Type':'application/json'}) with urlopen(req) as r: assert r.status==200 assert verify(r.read(),a.key.public_key(),'k1')['epoch']==a.epoch hp.write_text('{"quality":"unusable"}') with pytest.raises(HTTPError) as exc:urlopen(req) assert exc.value.code==503 finally: server.shutdown();server.server_close();thread.join() @pytest.mark.parametrize('change',['stale','step','unsafe_health','wrong_boot','unknown_policy']) def test_authority_refuses_unusable_health(authority,change): a,hp,clocks=authority if change=='stale':clocks['mono']+=2_000_000_000;clocks['wall']+=2_000_000_000 if change=='step':clocks['wall']-=1_000_000_000 if change=='unsafe_health':hp.chmod(0o666) if change in ('wrong_boot','unknown_policy'): d=json.loads(hp.read_text());d['boot_id' if change=='wrong_boot' else 'policy_id']='other';hp.write_text(json.dumps(d)) with pytest.raises(ClockError):a.sample(json.dumps(request()).encode()) def test_restart_changes_epoch(authority): a,_,clocks=authority b=Authority(a.config,wall=lambda:clocks['wall'],monotonic=lambda:clocks['mono']) assert a.epoch!=b.epoch def test_health_model_refusals(): p={'upstreams':['ntp.ubuntu.com'],'measurement_margin_ns':1000,'max_error_ns':100_000_000} k={'state':0,'status':0,'maxerror_us':1000,'esterror_us':100,'tolerance_scaled_ppm':32768000} c={'NTP':'yes','NTPSynchronized':'yes'};u={'ServerName':'ntp.ubuntu.com','NTPMessage':'{ Leap=0, Ignored=no, }'} assert assess(p,k,c,u)==1001000 for changed in [{**k,'state':5},{**k,'status':16},{**k,'maxerror_us':200_000},{**k,'maxerror_us':-1}]: with pytest.raises(ClockError):assess(p,changed,c,u) with pytest.raises(ClockError):assess(p,k,c,{**u,'ServerName':'unadmitted'}) @pytest.mark.parametrize('step', [100_000_000, -100_000_000]) def test_step_at_final_timestamp_latches_refusal(authority, step): a, _, clocks = authority original = clocks['wall'] # Receive and initial health are normal; only the timestamp to sign steps. readings = iter([original, original, original + step]) a.wall = lambda: next(readings, original) with pytest.raises(ClockError): a.sample(json.dumps(request()).encode()) assert a.failed # Returning to the old mapping must not restore the epoch's usability. with pytest.raises(ClockError): a.health() def test_health_expires_during_sample_preparation(authority): a, _, clocks = authority original = a.health calls = 0 def advancing_health(**kwargs): nonlocal calls error = original(**kwargs) calls += 1 if calls == 1: clocks['mono'] += a.config['max_health_age_ns'] clocks['wall'] += a.config['max_health_age_ns'] return error a.health = advancing_health with pytest.raises(ClockError, match='stale'): a.sample(json.dumps(request()).encode()) @pytest.fixture def http_authority(authority): a, hp, clocks = authority server = Server(('127.0.0.1', 0), a) thread = threading.Thread(target=server.serve_forever) thread.start() try: yield server, hp finally: server.shutdown(); server.server_close(); thread.join() @pytest.mark.parametrize('bad', [None, [], True, 1, 'invalid', {'error_ns': -1}, {'error_ns': True}, {'error_ns': '1000'}, {'observed_monotonic_ns': None}, {'observed_monotonic_ns': False}]) def test_malformed_health_is_stable_http_refusal(http_authority, bad): server, hp = http_authority if isinstance(bad, dict): valid = json.loads(hp.read_text()); valid.update(bad); bad = valid hp.write_text(json.dumps(bad)) base = f'http://127.0.0.1:{server.server_port}' requests = [base + '/readyz', Request(base + '/v1/time-samples', data=json.dumps(request()).encode(), headers={'Content-Type': 'application/json'})] for req in requests: with pytest.raises(HTTPError) as exc: urlopen(req, timeout=2) assert exc.value.code == 503 assert exc.value.read() == b'{"status":"unusable"}' with urlopen(base + '/healthz', timeout=2) as response: assert response.status == 200 @pytest.mark.parametrize('headers,body', [ ('Content-Length: 2\r\nContent-Length: 2\r\n', b'{}'), ('Content-Length: 2\r\nTransfer-Encoding: chunked\r\n', b'{}'), ('Content-Length: 2\r\nContent-Encoding: gzip\r\n', b'{}'), ('Content-Length: 1025\r\n', b''), ('Content-Length: -1\r\n', b''), ('Content-Length: 5\r\n', b'{}'), ]) def test_http_framing_refused(http_authority, headers, body): import socket server, _ = http_authority with socket.create_connection(server.server_address, timeout=2) as conn: conn.sendall(('POST /v1/time-samples HTTP/1.0\r\n' 'Content-Type: application/json\r\n' + headers + '\r\n').encode() + body) conn.shutdown(socket.SHUT_WR) with conn.makefile('rb') as response: raw = response.read() assert raw.startswith(b'HTTP/1.0 503') assert raw.endswith(b'{"status":"unusable"}') def test_connection_limit_and_capacity_recovery(authority, monkeypatch): import socket from railiance_clock.authority import Handler a, _, _ = authority ready = threading.Event() released = threading.Event() count = 0 lock = threading.Lock() original_setup = Handler.setup original_worker = Server.process_request_thread def setup(handler): nonlocal count original_setup(handler) with lock: count += 1 if count == 8: ready.set() def worker(server, request, address): nonlocal count try: original_worker(server, request, address) finally: with lock: count -= 1 if count == 0: released.set() monkeypatch.setattr(Handler, 'setup', setup) monkeypatch.setattr(Server, 'process_request_thread', worker) server = Server(('127.0.0.1', 0), a) thread = threading.Thread(target=server.serve_forever) thread.start() clients = [] try: for _ in range(8): clients.append(socket.create_connection(server.server_address, timeout=2)) assert ready.wait(2) with socket.create_connection(server.server_address, timeout=2) as extra: assert extra.recv(1) == b'' for conn in clients: conn.close() assert released.wait(2) with urlopen(f'http://127.0.0.1:{server.server_port}/readyz', timeout=2) as response: assert response.status == 200 finally: for conn in clients: conn.close() server.shutdown(); server.server_close(); thread.join()