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'})