Files
framebridge/source/tests/test_package_upgrade.py
FrameBridge release automation cdd980e1eb FrameBridge 0.3.4 corresponding-source release
Snapshot assembled only from the signed corresponding-source archive.
Source archive SHA-256: db91a270cdefc4be79056ed38a0b4a16747e1ab130592645eac40fa3809809df
2026-10-29 11:53:40 -04:00

392 lines
21 KiB
Python

import hashlib
from contextlib import nullcontext
import json
import os
from pathlib import Path
from types import SimpleNamespace
import tempfile
import unittest
from unittest import mock
from framebridge_expanded import package_upgrade as upgrade
class PackageUpgradeTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.root = Path(self.temp.name)
self.original_read = upgrade._read
self.state = self.root / 'profile'
self.state.mkdir(mode=0o700)
self.config = {'role': 'node', 'node_id': 'test-node'}
self.write(self.state / 'config.json', self.config)
self.proc = self.root / '1234'
self.proc.mkdir()
self.identity = {'pid': 1234, 'birth': '99', 'uid': os.getuid()}
self.write(self.state / 'manager.json', self.identity)
self.args = ['/usr/bin/python3', '-m', 'framebridge_expanded.runtime',
'_manager', '--state', str(self.state)]
(self.proc / 'cmdline').write_bytes(b'\0'.join(x.encode() for x in self.args) + b'\0')
self.environ()
(self.proc / 'stat').write_text('1234 (python3) S ' + ' '.join(['0'] * 18 + ['99']))
(self.proc / 'cgroup').write_text('0::/user.slice/user-1000.slice/user@1000.service/app.slice/test.scope\n')
def write(self, path, data):
path.write_text(json.dumps(data))
path.chmod(0o600)
def environ(self, prefix=None):
prefix = upgrade.PREFIX if prefix is None else prefix
(self.proc / 'environ').write_bytes(b'\0'.join(x.encode() for x in [
'FRAMEBRIDGE_EXPANDED_PREFIX=' + prefix,
'FRAMEBRIDGE_EXPANDED_STATE=' + str(self.state),
'PYTHONPATH=' + prefix + '/expanded:' + prefix + '/portable']) + b'\0')
def identify(self):
if os.getuid() == 0:
self.skipTest('Identity fixtures run as the ordinary build owner, not root')
return upgrade.identify(self.proc)
def test_identifies_only_installed_manager_and_exact_owned_record(self):
row = self.identify()
self.assertEqual(row['pid'], 1234)
self.assertEqual(row['state'], str(self.state))
self.assertEqual(row['birth'], '99')
self.assertIsNone(row['unit'])
self.assertNotIn('config', row)
def test_development_clone_is_not_an_installed_service(self):
self.environ('/home/example/source')
self.assertIsNone(self.identify())
def test_arbitrary_python_command_is_ignored(self):
(self.proc / 'cmdline').write_bytes(b'/usr/bin/python3\0-c\0print(1)\0')
self.assertIsNone(upgrade.identify(self.proc))
def test_pid_reuse_is_rejected(self):
self.write(self.state / 'manager.json', dict(self.identity, birth='98'))
if os.getuid() == 0:
self.skipTest('Ordinary-owner fixture required')
with self.assertRaisesRegex(RuntimeError, 'RECORD_MISMATCH'):
upgrade.identify(self.proc)
def test_recognizes_qa_unit_without_selecting_unrelated_services(self):
(self.proc / 'cgroup').write_text('0::/user.slice/user@1000.service/app.slice/framebridge-qa-mint223-private2-c3418304.service\n')
self.assertEqual(self.identify()['unit'], 'framebridge-qa-mint223-private2-c3418304.service')
def test_unrelated_unit_is_not_restarted(self):
(self.proc / 'cgroup').write_text('0::/user.slice/user@1000.service/app.slice/owner-important.service\n')
self.assertIsNone(self.identify()['unit'])
def test_world_readable_metadata_is_refused(self):
path = self.state / 'config.json'
path.chmod(0o644)
with self.assertRaisesRegex(RuntimeError, 'FILE_UNTRUSTED'):
upgrade._read(path, os.getuid())
def test_metadata_symlink_is_refused(self):
path = self.state / 'alias.json'
path.symlink_to(self.state / 'config.json')
with self.assertRaises(OSError):
upgrade._read(path, os.getuid())
def test_shared_profile_directory_is_refused(self):
self.state.chmod(0o755)
with self.assertRaisesRegex(RuntimeError, 'DIRECTORY_UNTRUSTED'):
upgrade._private(self.state, os.getuid())
def root_read(self, path, uid, maximum=upgrade.MAXIMUM):
# Root-owned production intent is represented by the test owner's file.
return self.original_read(path, os.getuid(), maximum)
def pending(self, profiles=None):
value = {'schema': 1, 'transaction': '1' * 16, 'phase': 'CAPTURED', 'profiles': profiles or []}
self.write(self.root / 'pending.json', value)
return value
def test_fresh_install_does_not_schedule_or_start_profiles(self):
with mock.patch.object(upgrade, 'ROOT', self.root), \
mock.patch.object(upgrade, '_root'), \
mock.patch.object(upgrade, '_read', side_effect=self.root_read), \
mock.patch.object(upgrade.subprocess, 'run') as run:
self.assertEqual(upgrade.schedule()['phase'], 'NO_UPGRADE_SNAPSHOT')
run.assert_not_called()
def test_empty_upgrade_stays_stopped(self):
self.pending()
with mock.patch.object(upgrade, 'ROOT', self.root), \
mock.patch.object(upgrade, '_root'), \
mock.patch.object(upgrade, '_read', side_effect=self.root_read), \
mock.patch.object(upgrade.subprocess, 'run') as run:
self.assertEqual(upgrade.schedule()['phase'], 'COMPLETE')
run.assert_not_called()
def test_restart_is_detached_and_delayed_past_package_transaction(self):
self.pending([{'state': str(self.state)}])
with mock.patch.object(upgrade, 'ROOT', self.root), \
mock.patch.object(upgrade, '_root'), \
mock.patch.object(upgrade, '_read', side_effect=self.root_read), \
mock.patch.object(upgrade.subprocess, 'run', return_value=SimpleNamespace(returncode=0)) as run:
self.assertEqual(upgrade.schedule()['phase'], 'SCHEDULED')
args = run.call_args.args[0]
self.assertEqual(args[0], '/usr/bin/systemd-run')
self.assertIn('--on-active=2s', args)
self.assertEqual(args[-2:], [upgrade.MODULE, 'resume'])
def test_scheduling_failure_keeps_captured_intent(self):
self.pending([{'state': str(self.state)}])
with mock.patch.object(upgrade, 'ROOT', self.root), \
mock.patch.object(upgrade, '_root'), \
mock.patch.object(upgrade, '_read', side_effect=self.root_read), \
mock.patch.object(upgrade.subprocess, 'run', return_value=SimpleNamespace(returncode=1)):
with self.assertRaisesRegex(RuntimeError, 'SCHEDULE_FAILED'):
upgrade.schedule()
self.assertEqual(json.loads((self.root / 'pending.json').read_text())['phase'], 'CAPTURED')
def test_repeated_configure_does_not_schedule_second_restart(self):
self.write(self.root / 'pending.json', {'phase': 'SCHEDULED'})
with mock.patch.object(upgrade, 'ROOT', self.root), \
mock.patch.object(upgrade, '_root'), \
mock.patch.object(upgrade, '_read', side_effect=self.root_read), \
mock.patch.object(upgrade.subprocess, 'run') as run:
self.assertEqual(upgrade.schedule()['phase'], 'SCHEDULED')
run.assert_not_called()
def test_changed_manager_is_not_stopped_and_failure_is_recorded(self):
row = {'uid': 1000, 'user': 'owner', 'home': '/home/owner',
'state': str(self.state), 'pid': 1234, 'birth': '99',
'config_sha256': 'a' * 64, 'unit': None, 'phase': 'CAPTURED'}
self.write(self.root / 'pending.json', {'schema': 1, 'phase': 'SCHEDULED', 'profiles': [row]})
with mock.patch.object(upgrade, 'ROOT', self.root), \
mock.patch.object(upgrade, '_root'), \
mock.patch.object(upgrade, '_read', side_effect=self.root_read), \
mock.patch.object(upgrade, '_wait_dpkg', return_value=nullcontext()), \
mock.patch.object(upgrade, '_resume_lock', return_value=nullcontext()), \
mock.patch.object(upgrade, 'identify', return_value=dict(row, birth='100')), \
mock.patch.object(upgrade, '_owner') as owner:
with self.assertRaisesRegex(RuntimeError, 'PROFILE_RESTART_FAILED'):
upgrade.resume()
owner.assert_not_called()
value = json.loads((self.root / 'pending.json').read_text())
self.assertEqual(value['phase'], 'FAILED')
self.assertIn('IDENTITY_CHANGED', value['profiles'][0]['error'])
def test_manual_profile_restart_never_enables_service(self):
row = {'uid': 1000, 'user': 'owner', 'home': '/home/owner',
'state': str(self.state), 'pid': 1234, 'birth': '99',
'config_sha256': 'a' * 64, 'unit': None, 'phase': 'CAPTURED'}
self.write(self.root / 'pending.json', {'schema': 1, 'phase': 'SCHEDULED', 'profiles': [row]})
with mock.patch.object(upgrade, 'ROOT', self.root), \
mock.patch.object(upgrade, '_root'), \
mock.patch.object(upgrade, '_read', side_effect=self.root_read), \
mock.patch.object(upgrade, '_wait_dpkg', return_value=nullcontext()), \
mock.patch.object(upgrade, '_resume_lock', return_value=nullcontext()), \
mock.patch.object(upgrade, 'identify', return_value=row), \
mock.patch.object(upgrade, '_owner', return_value={'ok': True, 'ready': True}) as owner, \
mock.patch.object(upgrade, '_start_manual', return_value='framebridge-profile-' + 'a' * 20 + '.service'), \
mock.patch.object(upgrade.subprocess, 'run') as run:
self.assertEqual(upgrade.resume()['phase'], 'COMPLETE')
self.assertEqual([c.args[1] for c in owner.call_args_list], ['user-desktop', 'user-stop', 'user-ready'])
run.assert_not_called()
def test_snapshot_does_not_stop_the_process_running_apt(self):
row = {'uid': os.getuid(), 'state': str(self.state), 'pid': 1234}
with mock.patch.object(upgrade, 'ROOT', self.root), \
mock.patch.object(upgrade, '_root'), \
mock.patch.object(upgrade, '_read', side_effect=self.root_read), \
mock.patch.object(upgrade, 'identify', return_value=row), \
mock.patch.object(upgrade.subprocess, 'run') as run:
value = upgrade.snapshot(self.root)
self.assertEqual(value['phase'], 'CAPTURED')
run.assert_not_called()
def test_reinstall_after_failure_preserves_receipt_and_keeps_stopped_profiles_stopped(self):
failed = {'schema': 1, 'transaction': 'a' * 16, 'phase': 'FAILED',
'profiles': [{'state': str(self.state), 'phase': 'FAILED',
'error': 'UPGRADE_OWNER_OPERATION_FAILED:user-ready'}]}
self.write(self.root / 'pending.json', failed)
processes = self.root / 'no-running-managers'
processes.mkdir()
marker = self.state / 'manual-stop.json'
marker.write_bytes(b'{"manual_stop": true}\n')
before_marker = marker.read_bytes()
with mock.patch.object(upgrade, 'ROOT', self.root), \
mock.patch.object(upgrade, '_root'), \
mock.patch.object(upgrade, '_read', side_effect=self.root_read), \
mock.patch.object(upgrade.subprocess, 'run') as run:
value = upgrade.snapshot(processes)
self.assertEqual(value['profiles'], [])
self.assertEqual(upgrade.schedule()['phase'], 'COMPLETE')
self.assertEqual(json.loads((self.root / 'previous.json').read_text()), failed)
self.assertEqual(marker.read_bytes(), before_marker)
run.assert_not_called()
def test_reinstall_after_failure_captures_only_current_managers_without_replaying_old_intent(self):
failed = {'schema': 1, 'phase': 'FAILED',
'profiles': [{'state': '/home/owner/previous-profile', 'phase': 'FAILED'}]}
self.write(self.root / 'pending.json', failed)
row = {'uid': 1000, 'state': str(self.state), 'pid': 1234, 'phase': 'CAPTURED'}
with mock.patch.object(upgrade, 'ROOT', self.root), \
mock.patch.object(upgrade, '_root'), \
mock.patch.object(upgrade, '_read', side_effect=self.root_read), \
mock.patch.object(upgrade, 'identify', return_value=row), \
mock.patch.object(upgrade.subprocess, 'run', return_value=SimpleNamespace(returncode=0)) as run:
value = upgrade.snapshot(self.root)
self.assertEqual(value['profiles'], [row])
self.assertEqual(upgrade.schedule()['phase'], 'SCHEDULED')
self.assertEqual(json.loads((self.root / 'previous.json').read_text()), failed)
self.assertEqual(run.call_count, 1)
self.assertEqual(run.call_args.args[0][-2:], [upgrade.MODULE, 'resume'])
def test_reinstall_does_not_replace_a_snapshot_with_a_live_upgrade_worker(self):
for phase in ('SCHEDULED', 'RUNNING'):
with self.subTest(phase=phase):
self.write(self.root / 'pending.json', {'schema': 1, 'phase': phase})
before = (self.root / 'pending.json').read_bytes()
with mock.patch.object(upgrade, 'ROOT', self.root), \
mock.patch.object(upgrade, '_root'), \
mock.patch.object(upgrade, '_read', side_effect=self.root_read), \
mock.patch.object(upgrade, 'identify', return_value=None):
with self.assertRaisesRegex(RuntimeError, 'PREVIOUS_WORKER_PENDING'):
upgrade.snapshot(self.root)
self.assertEqual((self.root / 'pending.json').read_bytes(), before)
self.assertFalse((self.root / 'previous.json').exists())
def test_optional_desktop_migration_failure_does_not_stop_runtime_recovery(self):
row = {'uid': 1000, 'user': 'owner', 'home': '/home/owner',
'state': str(self.state), 'pid': 1234, 'birth': '99',
'config_sha256': 'a' * 64, 'unit': None, 'phase': 'CAPTURED'}
self.write(self.root / 'pending.json', {'schema': 1, 'phase': 'SCHEDULED', 'profiles': [row]})
def owner(profile, operation):
if operation == 'user-desktop':
raise RuntimeError('Legacy console is closed')
return {'ok': True, 'ready': True}
with mock.patch.object(upgrade, 'ROOT', self.root), \
mock.patch.object(upgrade, '_root'), \
mock.patch.object(upgrade, '_read', side_effect=self.root_read), \
mock.patch.object(upgrade, '_wait_dpkg', return_value=nullcontext()), \
mock.patch.object(upgrade, '_resume_lock', return_value=nullcontext()), \
mock.patch.object(upgrade, 'identify', return_value=row), \
mock.patch.object(upgrade, '_owner', side_effect=owner), \
mock.patch.object(upgrade, '_start_manual', return_value='framebridge-profile-' + 'a' * 20 + '.service'):
value = upgrade.resume()
self.assertEqual(value['phase'], 'COMPLETE')
self.assertEqual(value['profiles'][0]['phase'], 'READY')
self.assertFalse(value['profiles'][0]['desktop']['ok'])
def check_stop_failure_retains_marker(self, before):
if os.getuid() == 0:
self.skipTest('Ordinary-owner fixture required')
from framebridge_expanded import runtime, autostart
directory = self.state / 'autostart'
directory.mkdir(mode=0o700)
marker = directory / 'manual-stop.json'
if before is not None:
marker.write_bytes(before)
marker.chmod(0o600)
def fail(state, **kwargs):
marker.write_text('{"temporary":true}')
marker.chmod(0o600)
raise RuntimeError('simulated bounded stop failure')
with mock.patch.object(runtime, 'stop', side_effect=fail), \
mock.patch.object(upgrade, '_birth', return_value='99'), \
mock.patch.object(upgrade, '_background', return_value=None), \
mock.patch.object(runtime, 'start') as start, \
mock.patch.object(autostart, '_configuration_action', side_effect=lambda state, action: action(state)):
with self.assertRaisesRegex(RuntimeError, 'bounded stop failure'):
upgrade.user_operation('user-stop', str(self.state),
hashlib.sha256((self.state / 'config.json').read_bytes()).hexdigest(),
manager_pid=1234, manager_birth='99')
start.assert_not_called()
if before is None:
self.assertFalse(marker.exists())
else:
self.assertEqual(marker.read_bytes(), before)
def test_stop_failure_retains_absent_manual_stop_policy(self):
self.check_stop_failure_retains_marker(None)
def test_stop_failure_retains_existing_manual_stop_bytes(self):
self.check_stop_failure_retains_marker(b'{"manual_stop": true}\n')
def test_user_unit_restart_rejects_account_reassignment(self):
row = {'uid': 1000, 'user': 'owner', 'home': '/home/owner'}
account = SimpleNamespace(pw_name='different', pw_dir='/home/different')
with mock.patch.object(upgrade.pwd, 'getpwuid', return_value=account):
with self.assertRaisesRegex(RuntimeError, 'ACCOUNT_CHANGED'):
upgrade._unit(row)
def test_manual_start_uses_an_independent_user_service_cgroup(self):
row = {'uid': os.getuid(), 'user': 'owner', 'home': '/home/owner', 'state': str(self.state)}
account = SimpleNamespace(pw_name='owner', pw_dir='/home/owner')
with mock.patch.object(upgrade.pwd, 'getpwuid', return_value=account), \
mock.patch.object(upgrade.subprocess, 'run', return_value=SimpleNamespace(returncode=0)) as run:
if os.getuid() == 0:
self.skipTest('Ordinary-owner fixture required')
unit = upgrade._start_manual(row)
args = run.call_args.args[0]
self.assertIn('/usr/bin/systemd-run', args)
self.assertIn('--property=ExitType=cgroup', args)
self.assertIn('--property=KillMode=control-group', args)
self.assertIn('--unit=' + unit, args)
self.assertEqual(args[-5:], ['/usr/bin/fb', '--state-dir', str(self.state), '--json', 'start'])
self.assertNotIn('enable', args)
def test_background_identity_failure_is_refused_without_signaling(self):
from framebridge_expanded import connection_background as background
with mock.patch.object(background, '_directory', return_value=self.state), \
mock.patch.object(background, '_read', return_value={'pid': os.getpid()}), \
mock.patch.object(background, '_valid_identity', return_value=False), \
mock.patch.object(background, 'disconnect') as disconnect:
with self.assertRaisesRegex(RuntimeError, 'BACKGROUND_IDENTITY_UNVERIFIED'):
upgrade._background(self.state)
disconnect.assert_not_called()
def test_verified_background_connection_is_retained_without_reading_credentials(self):
from framebridge_expanded import connection_background as background
record = {'pid': 1234, 'start_ticks': 88}
with mock.patch.object(background, '_directory', return_value=self.state), \
mock.patch.object(background, '_read', return_value=record), \
mock.patch.object(background, '_valid_identity', return_value=True), \
mock.patch.object(background, 'status', return_value={'verified': True}):
value = upgrade._background(self.state)
self.assertTrue(value['preserved_in_memory'])
self.assertFalse(value['process_reloaded'])
self.assertEqual(value['start_ticks'], 88)
def check_runtime_stop_connection(self, preserve):
from framebridge_expanded import runtime, connection_background, tty, autostart
with mock.patch.object(runtime, 'load_config', return_value=self.config), \
mock.patch.object(runtime, '_read', return_value={}), \
mock.patch.object(runtime, '_terminate'), \
mock.patch.object(runtime, '_atomic'), \
mock.patch.object(runtime, 'status', return_value={'running': False}), \
mock.patch.object(tty, 'stop', return_value={'ok': True, 'cleanup_verified': True}), \
mock.patch.object(autostart, 'mark_manual_stop'), \
mock.patch.object(connection_background, 'disconnect', return_value={'ok': True}) as disconnect, \
mock.patch.object(connection_background, 'status', return_value={'ok': True, 'alive': True}) as status:
if preserve:
runtime.stop(self.state, preserve_connection=True)
disconnect.assert_not_called()
status.assert_called_once_with(self.state)
else:
runtime.stop(self.state)
disconnect.assert_called_once_with(self.state)
status.assert_not_called()
def test_normal_runtime_stop_still_disconnects(self):
self.check_runtime_stop_connection(False)
def test_upgrade_runtime_stop_retains_existing_connection(self):
self.check_runtime_stop_connection(True)
if __name__ == '__main__':
unittest.main()