2026-11-06 08:28:04 +00:00
from __future__ import annotations
import json
import unittest
from hub_case import HubCase
2026-11-07 19:59:47 +00:00
from hub_support import STREAMS , Key , key
2026-11-06 22:20:06 +00:00
from kvb_orchestrator import settings
2026-11-06 08:28:04 +00:00
class TestHubSettings ( HubCase ):
def settings_payload ( self , streams : list [ dict ]) -> dict :
colors = { "top" : "#101820" , "bottom" : "#405060" , "accent" : "#ffaa00" }
2026-11-06 22:20:06 +00:00
speech = { "on" : True , "classes" : [ "call" , "mayday" , "pan-pan" ], "muted" : [ "name:Docs" , "s1" ]}
2026-11-06 08:28:04 +00:00
space = { "graph" : { "axis" : "time" , "yaw" : - 45.5 , "pitch" : 12.25 , "zoom" : 1.5 , "pan_x" : - 30.0 , "pan_y" : 12.0 }, "sky" : { "azimuth" : 225.0 , "altitude" : 20.0 , "fov" : 120.0 }}
return {
** settings . SETTINGS , "latitude" : 52.52 , "longitude" : 13.405 , "theme" : "custom" , "colors" : colors , "music" : { "on" : True , "volume" : 0.3 }, "streams" : streams , "speech" : speech ,
"space" : space ,
}
def media_src ( self ) -> str :
csp = self . get ( "/" )[ 1 ][ "content-security-policy" ]
return next ( part . strip () for part in csp . split ( ";" ) if part . strip () . startswith ( "media-src" ))
2026-11-07 19:59:47 +00:00
def prepared ( self , payload : dict ) -> dict :
status , _ , body = self . post_json ( "/api/settings/sign" , payload , self . guard ( self . host ))
self . assertEqual ( status , 200 , body )
return json . loads ( body )
def save ( self , payload : dict , signer : Key ) -> tuple [ int , dict , bytes ]:
request = self . prepared ( payload )[ "payload" ]
proof = None if request is None else signer . proof ( request , self . origin )
return self . post_json ( "/api/settings" , { ** payload , "proof" : proof }, self . guard ( self . host ))
2026-11-08 10:23:25 +00:00
def test_a_new_stream_url_needs_the_passkey ( self ):
2026-11-07 19:59:47 +00:00
good = self . guard ( self . host )
payload = self . settings_payload ( STREAMS )
2026-11-08 17:26:51 +00:00
self . assertEqual ( self . post_json ( "/api/settings" , payload , good )[: 3 : 2 ], ( 400 , b "not signed: no signature \n " ))
2026-11-07 19:59:47 +00:00
self . assertFalse (( self . state / "settings.json" ) . exists ())
signer = self . pin ()
request = self . prepared ( payload )[ "payload" ]
stranger = key ( - 7 , "stranger" ) . proof ( request , self . origin )
2026-11-08 17:26:51 +00:00
self . assertEqual ( self . post_json ( "/api/settings" , { ** payload , "proof" : stranger }, good )[: 3 : 2 ], ( 400 , b "not signed: the signing key is not pinned \n " ))
2026-11-07 19:59:47 +00:00
self . assertEqual ( self . media_src (), "media-src 'self'" )
proof = signer . proof ( request , self . origin )
status , _ , body = self . post_json ( "/api/settings" , { ** payload , "proof" : proof }, good )
self . assertEqual ( status , 200 , body )
self . assertEqual ( self . media_src (), "media-src 'self' http://10.0.0.5:8000 https://other.example:8443 https://radio.example" )
2026-11-08 17:26:51 +00:00
moved = self . settings_payload ([{ ** STREAMS [ 0 ], "url" : "https://radio.example/evening" }, STREAMS [ 1 ]])
self . assertEqual ( self . post_json ( "/api/settings" , moved , good )[: 3 : 2 ], ( 400 , b "not signed: no signature \n " ))
2026-11-08 10:23:25 +00:00
status , _ , body = self . save ( moved , signer )
2026-11-07 19:59:47 +00:00
self . assertEqual ( status , 200 , body )
self . assertEqual ( self . media_src (), "media-src 'self' http://10.0.0.5:8000 https://radio.example" )
2026-11-08 10:23:25 +00:00
self . assertEqual ( self . post_json ( "/api/settings" , self . settings_payload ([]), good )[ 0 ], 200 )
2026-11-08 17:26:51 +00:00
self . assertEqual ( self . post_json ( "/api/settings" , { ** payload , "proof" : proof }, good )[: 3 : 2 ], ( 400 , b "not signed: this signature was already used for this item \n " ))
2026-11-08 10:23:25 +00:00
self . assertEqual ( self . save ( moved , signer )[ 0 ], 200 )
2026-11-07 19:59:47 +00:00
evil = self . settings_payload ([ * moved [ "streams" ], { ** STREAMS [ 0 ], "url" : "https://evil.example/listen" }])
2026-11-08 17:26:51 +00:00
self . assertEqual ( self . post_json ( "/api/settings" , { ** evil , "proof" : signer . proof ( self . prepared ( payload )[ "payload" ], self . origin )}, good )[: 3 : 2 ], ( 400 , b "not signed: the signature does not list this item \n " ))
2026-11-07 19:59:47 +00:00
self . assertEqual ( self . post_json ( "/api/settings" , evil , good )[ 0 ], 400 )
self . assertEqual ( self . media_src (), "media-src 'self' http://10.0.0.5:8000 https://radio.example" )
status , _ , body = self . save ( evil , signer )
self . assertEqual ( status , 200 , body )
self . assertIn ( "https://evil.example" , self . media_src ())
for broken in ({ ** payload , "theme" : "neon" }, { ** payload , "proof" : None }):
with self . subTest ( broken = list ( broken )):
self . assertEqual ( self . post_json ( "/api/settings/sign" , broken , good )[ 0 ], 400 )
self . assertEqual ( self . post_json ( "/api/settings/sign" , payload , self . client ())[ 0 ], 403 )
self . assertEqual ( self . get ( "/api/settings/sign" )[ 0 ], 405 )
2026-11-06 08:28:04 +00:00
def test_settings_are_guarded_validated_and_persisted ( self ):
self . assertEqual ( self . snapshot ()[ "settings" ], settings . SETTINGS )
payload = self . settings_payload ( STREAMS )
good = self . guard ( self . host )
2026-11-06 22:20:06 +00:00
for headers in ({ k : v for k , v in good . items () if k != "X-Kvb-Orchestrator" }, { k : v for k , v in good . items () if k != "Origin" }, { ** good , "Origin" : "http://evil.example" }):
2026-11-06 08:28:04 +00:00
with self . subTest ( headers = headers ):
self . assertEqual ( self . post_json ( "/api/settings" , payload , headers )[ 0 ], 403 )
self . assertFalse (( self . state / "settings.json" ) . exists ())
stream = STREAMS [ 0 ]
for broken in (
{ ** payload , "latitude" : 91 },
{ ** payload , "theme" : "neon" },
{ ** payload , "extra" : 1 },
{ ** payload , "colors" : { ** payload [ "colors" ], "accent" : "red" }},
{ ** payload , "music" : { "on" : "yes" , "volume" : 0.3 }},
{ ** payload , "streams" : [{ ** stream , "url" : "javascript:alert(1)" }]},
{ ** payload , "streams" : [{ ** stream , "url" : "https://evil.example;script-src" }]},
{ ** payload , "streams" : [{ ** stream , "url" : "https://a.example 'unsafe-inline'" }]},
{ ** payload , "streams" : [{ ** stream , "url" : "https://user:pw@a.example/" }]},
{ ** payload , "streams" : [{ ** stream , "days" : [ 7 ]}]},
{ ** payload , "streams" : [{ ** stream , "days" : [ 1 , 1 ]}]},
{ ** payload , "streams" : [{ ** stream , "from" : "09:00" , "to" : "09:00" }]},
{ ** payload , "streams" : [{ ** stream , "to" : "25:00" }]},
* ({ ** payload , "speech" : { ** payload [ "speech" ], ** change }} for change in (
{ "on" : "yes" },
2026-11-06 22:20:06 +00:00
{ "classes" : [ "call" , "pan-pan" ]},
2026-11-06 08:28:04 +00:00
{ "classes" : [ "mayday" , "shout" ]},
{ "classes" : [ "mayday" , "mayday" ]},
{ "classes" : "mayday" },
{ "muted" : "s1" },
{ "muted" : [ "s1" , "s1" ]},
{ "muted" : [ "two \n lines" ]},
{ "muted" : [ "" ]},
{ "muted" : [ 7 ]},
{ "muted" : [ f "s { n } " for n in range ( settings . MUTED_LIMIT + 1 )]},
{ "extra" : 1 },
)),
{ ** payload , "speech" : { "on" : True , "classes" : [ "mayday" ]}},
2026-11-07 19:59:47 +00:00
{ name : value for name , value in payload . items () if name != "speech" },
2026-11-06 08:28:04 +00:00
* ({ ** payload , "space" : change } for change in (
"flat" ,
{ "graph" : payload [ "space" ][ "graph" ]},
{ ** payload [ "space" ], "extra" : 1 },
{ ** payload [ "space" ], "sky" : [ 180 , 0 , 180 ]},
)),
* ({ ** payload , "space" : { ** payload [ "space" ], "graph" : { ** payload [ "space" ][ "graph" ], ** change }}} for change in (
{ "axis" : "depth" },
{ "axis" : 1 },
{ "yaw" : 180.5 },
{ "pitch" : "up" },
{ "zoom" : True },
{ "zoom" : 0.2 },
{ "pan_x" : 2001 },
{ "pan_y" : float ( "nan" )},
{ "roll" : 0 },
)),
2026-11-07 19:59:47 +00:00
{ ** payload , "space" : { ** payload [ "space" ], "graph" : { name : value for name , value in payload [ "space" ][ "graph" ] . items () if name != "pan_y" }}},
2026-11-06 08:28:04 +00:00
* ({ ** payload , "space" : { ** payload [ "space" ], "sky" : { ** payload [ "space" ][ "sky" ], ** change }}} for change in (
{ "azimuth" : - 1 },
{ "azimuth" : 360.5 },
{ "altitude" : 86 },
{ "fov" : 10 },
{ "fov" : None },
{ "fov" : float ( "inf" )},
{ "zoom" : 1 },
)),
2026-11-07 19:59:47 +00:00
{ name : value for name , value in payload . items () if name != "space" },
2026-11-06 08:28:04 +00:00
):
with self . subTest ( broken = broken ):
self . assertEqual ( self . post_json ( "/api/settings" , broken , good )[ 0 ], 400 )
2026-11-07 19:59:47 +00:00
status , _ , body = self . save ( payload , self . pin ())
2026-11-06 08:28:04 +00:00
self . assertEqual ( status , 200 , body )
2026-11-08 10:23:25 +00:00
unresolved = { ** payload , "streams" : [{ ** stream , "via" : None if stream [ "url" ] . startswith ( "https:" ) else []} for stream in payload [ "streams" ]]}
self . assertEqual ( json . loads ( body )[ "settings" ], unresolved )
self . assertEqual ( json . loads (( self . state / "settings.json" ) . read_text ( encoding = "utf-8" )), unresolved )
self . assertEqual ( self . snapshot ()[ "settings" ], unresolved )
self . assertEqual ( settings . Settings ( self . state / "settings.json" ) . get (), unresolved )
2026-11-06 08:28:04 +00:00
self . assertEqual ( self . get ( "/api/settings" )[ 0 ], 405 )
shuffled = { ** payload , "speech" : { "on" : False , "classes" : [ "mayday" , "broadcast" , "security" ], "muted" : [ "s1" , "Operator" , "name:Docs" ]}}
status , _ , body = self . post_json ( "/api/settings" , shuffled , good )
self . assertEqual ( status , 200 , body )
2026-11-08 10:23:25 +00:00
self . assertEqual ( json . loads ( body )[ "settings" ][ "speech" ], { "on" : False , "classes" : [ "broadcast" , "mayday" , "security" ], "muted" : [ "Operator" , "name:Docs" , "s1" ]})
2026-11-06 08:28:04 +00:00
def test_settings_written_before_the_radio_filter_keep_their_values ( self ):
older = { key : value for key , value in self . settings_payload ( STREAMS ) . items () if key != "speech" }
( self . state / "settings.json" ) . write_text ( json . dumps ( older ), encoding = "utf-8" )
loaded = settings . Settings ( self . state / "settings.json" ) . get ()
self . assertEqual ( loaded , { ** older , "speech" : settings . SETTINGS [ "speech" ]})
flat = { key : value for key , value in older . items () if key != "space" }
( self . state / "settings.json" ) . write_text ( json . dumps ( flat ), encoding = "utf-8" )
self . assertEqual ( settings . Settings ( self . state / "settings.json" ) . get (), { ** flat , "speech" : settings . SETTINGS [ "speech" ], "space" : settings . SETTINGS [ "space" ]})
( self . state / "settings.json" ) . write_text ( json . dumps ({ ** older , "unknown" : 1 }), encoding = "utf-8" )
self . assertEqual ( settings . Settings ( self . state / "settings.json" ) . get (), settings . SETTINGS )
def test_csp_media_src_lists_exactly_the_stream_origins ( self ):
self . assertEqual ( self . media_src (), "media-src 'self'" )
2026-11-08 17:26:51 +00:00
streams = [ * STREAMS , { ** STREAMS [ 0 ], "name" : "Same" , "url" : "https://Radio.Example:443/second?x=1" }]
2026-11-07 19:59:47 +00:00
status , _ , body = self . save ( self . settings_payload ( streams ), self . pin ())
2026-11-06 08:28:04 +00:00
self . assertEqual ( status , 200 , body )
origins = [ "http://10.0.0.5:8000" , "https://other.example:8443" , "https://radio.example" ]
self . assertEqual ( self . media_src (), " " . join ([ "media-src 'self'" , * origins ]))
csp = self . get ( "/" )[ 1 ][ "content-security-policy" ]
self . assertIn ( "connect-src 'self';" , csp )
self . assertEqual ( csp . count ( "radio.example" ), 1 )
self . assertEqual ( self . snapshot ()[ "media" ], origins )
if __name__ == "__main__" :
unittest . main ()