Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a0e6f0-a6f6-7b93-9124-55ef584930e2
198 lines
8.6 KiB
Python
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()
|