railiance-clock/tests/test_authority.py
tegwick c976c9c277 Harden authority sampling and health failures; prepare operations handoff
Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a0e6f0-a6f6-7b93-9124-55ef584930e2
2026-09-28 10:08:52 +02:00

198 lines
8.6 KiB
Python

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()