2026-10-28 09:38:33 +00:00
"""Actual version/guard helpers and authenticated Flask routes; no real reboot."""
import fcntl
import importlib.util
import io
import json
from pathlib import Path
import shlex
import subprocess
import sys
import tempfile
from types import ModuleType , SimpleNamespace
import unittest
2026-10-28 16:54:36 +00:00
from contextlib import contextmanager
2026-10-28 09:38:33 +00:00
from unittest.mock import Mock , patch
from flask import Flask
ROOT = Path ( __file__ ) . resolve () . parents [ 1 ]
sys . path . insert ( 0 , str ( ROOT / 'AppImage/scripts' ))
import host_update_controls as controls
import proxmenux_version_status as versions
2026-10-28 16:54:36 +00:00
SHUTDOWN_HELPER = b '/usr/share/unattended-upgrades/unattended-upgrade-shutdown'
WAITING_HELPER = b '/usr/bin/python3 \0 ' + SHUTDOWN_HELPER + b ' \0 --wait-for-signal \0 '
REAL_UPDATER = b '/usr/bin/python3 \0 /usr/bin/unattended-upgrade \0 '
2026-10-28 09:38:33 +00:00
class VersionStatus ( unittest . TestCase ):
def setUp ( self ):
temporary = tempfile . TemporaryDirectory (); self . addCleanup ( temporary . cleanup )
self . root = Path ( temporary . name )
self . root_patch = patch . object ( versions , 'ROOT' , self . root ); self . root_patch . start (); self . addCleanup ( self . root_patch . stop )
versions . _cache . clear ()
( self . root / 'version.txt' ) . write_text ( '1.2.7 \n ' )
self . now = 1000.
self . clock = patch . object ( versions . time , 'monotonic' , side_effect = lambda : self . now ); self . clock . start (); self . addCleanup ( self . clock . stop )
self . remote = Mock ( return_value = io . BytesIO ( b '1.2.8 \n ' ))
self . network = patch . object ( versions . urllib . request , 'urlopen' , self . remote ); self . network . start (); self . addCleanup ( self . network . stop )
def test_real_contract_stable_and_beta_use_only_fixed_urls ( self ):
result = versions . version_status ()
self . assertEqual (( result [ 'state' ], result [ 'installed' ], result [ 'available' ]), ( 'available' , '1.2.7' , '1.2.8' ))
self . assertEqual ( self . remote . call_args . args [ 0 ] . full_url , versions . URLS [ 'stable' ])
( self . root / 'beta_version.txt' ) . write_text ( '1.2.9' )
self . remote . return_value = io . BytesIO ( b '1.2.10' )
result = versions . version_status ()
self . assertEqual (( result [ 'channel' ], result [ 'state' ], result [ 'available' ]), ( 'beta' , 'available' , '1.2.10' ))
self . assertEqual ( self . remote . call_args . args [ 0 ] . full_url , versions . URLS [ 'beta' ])
def test_fresh_cache_then_ttl_boundary_refetch ( self ):
versions . version_status ()
for age in ( 0 , 1799.999 ):
self . now = 1000 + age
self . assertEqual ( versions . version_status ()[ 'state' ], 'available' )
self . assertEqual ( self . remote . call_count , 1 )
self . now = 2800
self . remote . return_value = io . BytesIO ( b '1.2.7' )
self . assertEqual ( versions . version_status ()[ 'state' ], 'current' )
self . assertEqual ( self . remote . call_count , 2 )
self . now = 4600.001 ; self . remote . return_value = io . BytesIO ( b '1.2.9' )
self . assertEqual ( versions . version_status ()[ 'state' ], 'available' )
def test_failed_refresh_keeps_last_known_and_retries_after_sixty_seconds ( self ):
first = versions . version_status ()
self . now = 2800 ; self . remote . side_effect = OSError ( 'fixture timeout' )
failed = versions . version_status ()
self . assertEqual ( failed [ 'state' ], 'unknown' )
self . assertEqual ( failed [ 'available' ], first [ 'available' ])
self . assertEqual ( failed [ 'checked_at' ], first [ 'checked_at' ])
self . now = 2859.999 ; versions . version_status (); self . assertEqual ( self . remote . call_count , 2 )
self . now = 2860 ; versions . version_status (); self . assertEqual ( self . remote . call_count , 3 )
def test_missing_or_invalid_installed_never_probes_network ( self ):
for text in ( '' , 'junk' , '1.2.7 \n partial' , '1.2.7-rc1' ):
( self . root / 'version.txt' ) . write_text ( text )
self . assertEqual ( versions . version_status ()[ 'state' ], 'unknown' )
( self . root / 'version.txt' ) . unlink ()
self . assertEqual ( versions . version_status ()[ 'state' ], 'unknown' )
self . remote . assert_not_called ()
def test_bad_remote_and_http_failure_never_mean_current_or_write_local_files ( self ):
before = { p . name : p . read_bytes () for p in self . root . iterdir ()}
for content in ( b '' , b '<html>error</html>' , b '1.2.8 \n partial' , b '1.' + b '9' * 128 , b ' \xff ' ):
versions . _cache . clear (); self . remote . return_value = io . BytesIO ( content )
self . assertEqual ( versions . version_status ()[ 'state' ], 'unknown' )
versions . _cache . clear (); self . remote . side_effect = TimeoutError ( 'fixture' )
self . assertEqual ( versions . version_status ()[ 'state' ], 'unknown' )
self . assertEqual ({ p . name : p . read_bytes () for p in self . root . iterdir ()}, before )
def test_installed_version_change_and_numeric_comparison ( self ):
versions . version_status ()
( self . root / 'version.txt' ) . write_text ( '1.2.10' )
self . assertEqual ( versions . version_status ()[ 'state' ], 'current' )
self . assertEqual ( self . remote . call_count , 1 )
self . assertEqual ( versions . _version ( '1.2' ), versions . _version ( '1.2.0' ))
class RebootGuards ( unittest . TestCase ):
def setUp ( self ):
temporary = tempfile . TemporaryDirectory (); self . addCleanup ( temporary . cleanup )
self . root = Path ( temporary . name ); self . lock = self . root / 'update.lock'
2026-10-28 09:46:32 +00:00
( self . root / 'proc' ) . mkdir ()
self . proc = patch . object ( controls , 'PROC' , self . root / 'proc' ); self . proc . start (); self . addCleanup ( self . proc . stop )
2026-10-28 09:38:33 +00:00
boot = self . root / 'boot_id' ; boot . write_text ( 'fixture-boot' )
self . boot = patch . object ( controls , 'BOOT_ID' , boot ); self . boot . start (); self . addCleanup ( self . boot . stop )
self . a = patch . object ( controls , 'LOCK' , str ( self . lock )); self . a . start (); self . addCleanup ( self . a . stop )
self . jobs = patch . object ( controls , 'list_jobs' , return_value = []) . start ()
self . probe = patch . object ( controls , 'reboot_status' , return_value = { 'state' : 'required' }) . start ()
self . addCleanup ( patch . stopall )
2026-10-28 16:54:36 +00:00
def package ( self , command = WAITING_HELPER , name = 'unattended-upgr' , pid = '123' ):
path = self . root / 'proc' / pid ; path . mkdir ( exist_ok = True )
( path / 'comm' ) . write_text ( name + ' \n ' )
( path / 'cmdline' ) . write_bytes ( command )
return path
def test_waiting_shutdown_helper_does_not_block_required_reboot_or_run_commands ( self ):
# Debian's service ExecStart, including the /usr/bin/python3 shebang.
for command in ( WAITING_HELPER , SHUTDOWN_HELPER + b ' \0 --wait-for-signal \0 ' ):
with self . subTest ( command = command ), patch . object ( controls . subprocess , 'run' ) as run :
self . package ( command )
self . assertEqual ( controls . reboot_context ()[ 'reason' ], 'required' )
run . assert_not_called ()
def test_actual_package_tools_and_unattended_updater_still_block_before_probe ( self ):
for name in ( 'apt' , 'apt-get' , 'dpkg' , 'unattended-upgr' ):
with self . subTest ( name = name ):
self . package ( REAL_UPDATER , name )
self . assertEqual ( controls . reboot_context ()[ 'reason' ], 'busy' )
self . probe . assert_not_called ()
def test_waiting_helper_never_hides_another_active_updater ( self ):
self . package ()
self . package ( REAL_UPDATER , pid = '124' )
self . assertEqual ( controls . reboot_context ()[ 'reason' ], 'busy' )
self . probe . assert_not_called ()
def test_helper_without_wait_flag_and_lookalike_arguments_stay_busy ( self ):
commands = ( SHUTDOWN_HELPER + b ' \0 ' ,
b '/usr/bin/unattended-upgrade \0 --wait-for-signal \0 ' ,
b '/usr/bin/unattended-upgrade \0 ' + SHUTDOWN_HELPER + b ' \0 --wait-for-signal \0 ' ,
b '/bin/sh \0 -c \0 ' + SHUTDOWN_HELPER + b ' --wait-for-signal \0 ' ,
b '/usr/bin/python3 \0 -c \0 ' + SHUTDOWN_HELPER + b ' \0 --wait-for-signal \0 ' ,
WAITING_HELPER + b '--unexpected \0 ' ,
b '/tmp/unattended-upgrade-shutdown \0 --wait-for-signal \0 ' )
for command in commands :
with self . subTest ( command = command ):
self . package ( command )
self . assertEqual ( controls . reboot_context ()[ 'reason' ], 'busy' )
self . probe . assert_not_called ()
def test_empty_partial_malformed_and_missing_live_cmdline_stay_busy ( self ):
for command in ( b '' , b ' \0 ' , WAITING_HELPER [: - 1 ], WAITING_HELPER + b ' \0 ' , b ' \xff\0 ' ):
with self . subTest ( command = command ):
self . package ( command )
self . assertEqual ( controls . reboot_context ()[ 'reason' ], 'busy' )
( self . root / 'proc/123/cmdline' ) . unlink ()
self . assertEqual ( controls . reboot_context ()[ 'reason' ], 'busy' )
self . probe . assert_not_called ()
def test_unreadable_cmdline_blocks_but_process_that_exits_can_be_skipped ( self ):
process = self . package ()
with patch . object ( Path , 'read_bytes' , side_effect = PermissionError ( 'fixture denied' )):
self . assertEqual ( controls . reboot_context ()[ 'reason' ], 'busy' )
self . probe . assert_not_called ()
def exited ():
for path in process . iterdir (): path . unlink ()
process . rmdir ()
raise FileNotFoundError ( 'fixture exited after comm read' )
with patch . object ( Path , 'read_bytes' , side_effect = exited ):
self . assertTrue ( controls . reboot_context ()[ 'allowed' ])
2026-10-28 09:38:33 +00:00
def test_active_attention_starting_and_unreadable_jobs_block_before_kernel_probe ( self ):
for state in ( 'starting' , 'running' , 'attention' , 'finalizing' , None ):
self . jobs . return_value = [{ 'state' : state }]
self . assertFalse ( controls . reboot_context ()[ 'allowed' ])
self . jobs . side_effect = OSError ( 'fixture missing registry' )
self . assertFalse ( controls . reboot_context ()[ 'allowed' ])
self . probe . assert_not_called ()
def test_lock_probe_does_not_create_missing_file_and_symlink_is_refused ( self ):
self . assertTrue ( controls . reboot_context ()[ 'allowed' ])
self . assertFalse ( self . lock . exists ())
self . lock . symlink_to ( self . root / 'missing' )
self . probe . reset_mock ()
self . assertFalse ( controls . reboot_context ()[ 'allowed' ])
self . probe . assert_not_called ()
def test_real_shared_flock_stops_probe_and_final_command_before_any_effect ( self ):
self . lock . touch ()
marker = self . root / 'inert-action'
command = list ( controls . COMMAND )
2026-10-28 09:46:32 +00:00
self . assertEqual ( command [ 5 :],[ '/usr/bin/python3' , '/usr/local/share/proxmenux/monitor-app/usr/bin/host_update_controls.py' , '--execute-reboot' ])
command [ 4 ] = str ( self . lock ); command [ 5 :] = [ '/bin/sh' , '-c' , 'printf reached > ' + shlex . quote ( str ( marker ))]
2026-10-28 09:38:33 +00:00
with self . lock . open ( 'r' ) as owner :
fcntl . flock ( owner , fcntl . LOCK_EX | fcntl . LOCK_NB )
self . assertFalse ( controls . reboot_context ()[ 'allowed' ])
self . probe . assert_not_called ()
self . assertEqual ( subprocess . run ( command , check = False ) . returncode , 75 )
self . assertFalse ( marker . exists ())
self . assertEqual ( subprocess . run ( command , check = False ) . returncode , 0 )
self . assertEqual ( marker . read_text (), 'reached' )
def test_current_unknown_and_read_failures_never_allow_restart ( self ):
for state in ( 'current' , 'unknown' , 'not_applicable' ):
self . probe . return_value = { 'state' : state }
self . assertFalse ( controls . reboot_context ()[ 'allowed' ])
2026-10-28 09:46:32 +00:00
def test_direct_package_process_blocks_before_kernel_probe ( self ):
path = self . root / 'proc/123' ; path . mkdir ();( path / 'comm' ) . write_text ( 'apt-get \n ' )
self . assertFalse ( controls . reboot_context ()[ 'allowed' ])
self . probe . assert_not_called ()
def test_worker_package_locks_missing_or_unknown_kernel_never_reboot ( self ):
paths = [ str ( self . root / name ) for name in ( 'frontend' , 'backend' )]
with patch . object ( controls , 'PACKAGE_LOCKS' , paths ), patch . object ( controls . subprocess , 'run' ) as reboot , patch . object ( controls . time , 'sleep' ) as delay :
self . assertEqual ( controls . execute_reboot (), 75 )
reboot . assert_not_called (); delay . assert_not_called ()
for path in paths : Path ( path ) . touch ()
self . probe . return_value = { 'state' : 'unknown' }
self . assertEqual ( controls . execute_reboot (), 77 )
reboot . assert_not_called ()
self . probe . return_value = { 'state' : 'required' }
self . assertEqual ( controls . execute_reboot (), 0 )
reboot . assert_called_once_with ([ '/bin/systemctl' , 'reboot' ], check = True , timeout = 10 )
delay . assert_called_once_with ( 60 )
def test_actual_posix_package_lock_blocks_worker_before_reboot ( self ):
paths = [ str ( self . root / name ) for name in ( 'frontend' , 'backend' )]
for path in paths : Path ( path ) . touch ()
# A separate process holds the same POSIX lock, just as APT does.
code = 'import fcntl,sys,time; f=open(sys.argv[1],"r+"); fcntl.lockf(f,fcntl.LOCK_EX); print("locked",flush=True); time.sleep(5)'
2026-10-28 16:54:36 +00:00
for held in paths :
child = subprocess . Popen ([ sys . executable , '-c' , code , held ], stdout = subprocess . PIPE , text = True )
try :
self . assertEqual ( child . stdout . readline () . strip (), 'locked' )
with patch . object ( controls , 'PACKAGE_LOCKS' , paths ), patch . object ( controls . subprocess , 'run' ) as reboot :
self . assertEqual ( controls . execute_reboot (), 75 )
reboot . assert_not_called ()
self . probe . assert_not_called ()
self . assertEqual ([ Path ( path ) . read_bytes () for path in paths ],[ b '' , b '' ])
finally :
child . terminate (); child . wait ( timeout = 2 ); child . stdout . close ()
2026-10-28 09:46:32 +00:00
2026-10-28 09:38:33 +00:00
class Routes ( unittest . TestCase ):
def setUp ( self ):
auth = ModuleType ( 'auth_manager' )
auth . load_auth_config = lambda :{ 'enabled' : True }
auth . verify_token = lambda token : 'fixture' if token == 'admin' else None
auth . verify_token_full = lambda token :( 'fixture' , 'full_admin' ) if token == 'admin' else ( 'fixture' , 'read_only' ) if token == 'reader' else ( None , None )
patches = { 'auth_manager' : auth , 'health_monitor' : SimpleNamespace ( health_monitor = object ()),
'health_persistence' : SimpleNamespace ( health_persistence = object ()), 'mount_monitor' : ModuleType ( 'mount_monitor' )}
self . modules = patch . dict ( sys . modules , patches ); self . modules . start (); self . addCleanup ( self . modules . stop )
def load ( name , filename ):
spec = importlib . util . spec_from_file_location ( name , ROOT / 'AppImage/scripts' / filename )
module = importlib . util . module_from_spec ( spec ); spec . loader . exec_module ( module ); return module
middleware = load ( 'controls_middleware' , 'jwt_middleware.py' )
self . mw = patch . dict ( sys . modules ,{ 'jwt_middleware' : middleware }); self . mw . start (); self . addCleanup ( self . mw . stop )
routes = load ( 'controls_routes' , 'flask_health_routes.py' )
self . app = Flask ( __name__ ); self . app . register_blueprint ( routes . health_bp )
self . run = Mock ( return_value = ( True ,{})); self . state = Mock ( return_value = { 'state' : 'running' })
self . app . extensions [ 'host_update_power' ] = { 'run' : self . run , 'state' : self . state }
2026-10-28 16:54:36 +00:00
self . real_context = controls . reboot_context
2026-10-28 09:38:33 +00:00
self . context = patch . object ( controls , 'reboot_context' , return_value = { 'allowed' : True , 'reason' : 'required' , 'boot_id' : 'fixture-boot' }) . start ()
self . version = patch . object ( versions , 'version_status' , return_value = { 'state' : 'unknown' }) . start ()
self . addCleanup ( patch . stopall )
self . client = self . app . test_client ()
import socket
self . body = { 'confirm' : True , 'hostname' : socket . gethostname (), 'boot_id' : 'fixture-boot' }
2026-10-28 16:54:36 +00:00
@contextmanager
def actual_package_context ( self , command ):
with tempfile . TemporaryDirectory () as folder :
root = Path ( folder ); proc = root / 'proc' ;( proc / '123' ) . mkdir ( parents = True )
( proc / '123/comm' ) . write_text ( 'unattended-upgr \n ' )
( proc / '123/cmdline' ) . write_bytes ( command )
boot = root / 'boot_id' ; boot . write_text ( 'fixture-boot' )
with patch . object ( controls , 'PROC' , proc ), patch . object ( controls , 'BOOT_ID' , boot ), \
patch . object ( controls , 'LOCK' , str ( root / 'missing-lock' )), \
patch . object ( controls , 'list_jobs' , return_value = []), \
patch . object ( controls , 'reboot_status' , return_value = { 'state' : 'required' }) as probe , \
patch . object ( controls , 'reboot_context' , wraps = self . real_context ):
yield probe
def test_actual_context_routes_allow_waiting_helper_and_only_queue_guarded_action ( self ):
with self . actual_package_context ( WAITING_HELPER ):
response = self . client . get ( '/api/health/reboot-context' , headers = { 'Authorization' : 'Bearer admin' })
self . assertEqual ( response . status_code , 200 )
self . assertTrue ( response . json [ 'allowed' ])
self . run . assert_not_called ()
response = self . client . post ( '/api/health/reboot-after-update' , json = self . body , headers = { 'Authorization' : 'Bearer admin' })
self . assertEqual ( response . status_code , 202 )
self . run . assert_called_once_with ( controls . UNIT , list ( controls . COMMAND ), description = 'ProxMenux: reboot after host update' , extra_properties = [ 'RuntimeMaxSec=75' ])
def test_actual_context_routes_deny_active_or_uncertain_updater_without_launch ( self ):
for command in ( REAL_UPDATER , b '' , WAITING_HELPER [: - 1 ]):
with self . subTest ( command = command ), self . actual_package_context ( command ) as probe :
response = self . client . get ( '/api/health/reboot-context' , headers = { 'Authorization' : 'Bearer admin' })
self . assertEqual ( response . status_code , 200 )
self . assertEqual ( response . json [ 'reason' ], 'busy' )
response = self . client . post ( '/api/health/reboot-after-update' , json = self . body , headers = { 'Authorization' : 'Bearer admin' })
self . assertEqual ( response . status_code , 409 )
self . assertEqual ( response . json [ 'reason' ], 'busy' )
probe . assert_not_called (); self . run . assert_not_called ()
2026-10-28 09:38:33 +00:00
def test_denied_auth_and_methods_do_not_probe_or_launch ( self ):
for token in ( None , 'bad' , 'reader' ):
headers = { 'Authorization' : 'Bearer ' + token } if token else {}
response = self . client . post ( '/api/health/reboot-after-update' , json = self . body , headers = headers )
self . assertEqual ( response . status_code , 403 if token == 'reader' else 401 )
self . assertEqual ( self . client . get ( '/api/health/reboot-after-update' ) . status_code , 405 )
self . context . assert_not_called (); self . run . assert_not_called ()
self . assertEqual ( self . client . get ( '/api/health/proxmenux-version' ) . status_code , 401 )
self . version . assert_not_called ()
def test_invalid_or_missing_confirmation_and_wrong_host_have_zero_probes ( self ):
for body in ({},{ 'confirm' : 'true' }, dict ( self . body , confirm = False ), dict ( self . body , hostname = 'another-host' )):
self . assertEqual ( self . client . post ( '/api/health/reboot-after-update' , json = body , headers = { 'Authorization' : 'Bearer admin' }) . status_code , 400 )
self . context . assert_not_called (); self . run . assert_not_called ()
def test_busy_current_unknown_and_unconfigured_runner_never_launch ( self ):
for reason in ( 'busy' , 'current' , 'unknown' ):
self . context . return_value = { 'allowed' : False , 'reason' : reason , 'boot_id' : 'fixture-boot' }
self . assertEqual ( self . client . post ( '/api/health/reboot-after-update' , json = self . body , headers = { 'Authorization' : 'Bearer admin' }) . status_code , 409 )
self . app . extensions . clear (); self . context . reset_mock ()
self . assertEqual ( self . client . post ( '/api/health/reboot-after-update' , json = self . body , headers = { 'Authorization' : 'Bearer admin' }) . status_code , 503 )
self . context . assert_not_called (); self . run . assert_not_called ()
def test_stale_or_other_server_boot_identity_never_launches ( self ):
self . assertEqual ( self . client . post ( '/api/health/reboot-after-update' , json = dict ( self . body , boot_id = 'another-boot' ), headers = { 'Authorization' : 'Bearer admin' }) . status_code , 409 )
self . run . assert_not_called ()
def test_allowed_confirmation_queues_only_fixed_guarded_action_and_preserves_existing_api ( self ):
response = self . client . post ( '/api/health/reboot-after-update' , json = self . body , headers = { 'Authorization' : 'Bearer admin' })
self . assertEqual ( response . status_code , 202 ); self . assertTrue ( response . json [ 'accepted' ])
self . run . assert_called_once_with ( controls . UNIT , list ( controls . COMMAND ), description = 'ProxMenux: reboot after host update' , extra_properties = [ 'RuntimeMaxSec=75' ])
self . run . return_value = ( False ,{ 'status' : 409 })
self . assertEqual ( self . client . post ( '/api/health/reboot-after-update' , json = self . body , headers = { 'Authorization' : 'Bearer admin' }) . status_code , 409 )
def test_read_only_version_is_allowed_but_context_and_action_status_are_admin_only ( self ):
self . assertEqual ( self . client . get ( '/api/health/proxmenux-version' , headers = { 'Authorization' : 'Bearer reader' }) . status_code , 200 )
for path in ( 'reboot-context' , 'reboot-after-update/status' ):
self . assertEqual ( self . client . get ( '/api/health/' + path , headers = { 'Authorization' : 'Bearer reader' }) . status_code , 403 )
self . context . assert_not_called (); self . state . assert_not_called (); self . run . assert_not_called ()
if __name__ == '__main__' :
unittest . main ( verbosity = 2 )