import importlib.util import json import os from pathlib import Path import subprocess import tempfile import io from contextlib import redirect_stdout import unittest from unittest.mock import patch from test_room import ROOT def module(name): spec=importlib.util.spec_from_file_location(name,ROOT/'tools/launch'/f'{name}.py') loaded=importlib.util.module_from_spec(spec) spec.loader.exec_module(loaded) return loaded remote=module('remote_cabinets') service=module('pi_service') class RemoteTests(unittest.TestCase): def config(self,folder,items,enabled=True): path=Path(folder)/'launcher.ini' path.write_text('[remote]\nenabled = '+str(enabled)+'\ncabinets = '+json.dumps(items)) return path def test_opt_in_and_cabinet_mode_do_not_start_remotes(self): self.assertEqual(remote.load_targets('missing.ini','cabinet'),[]) with tempfile.TemporaryDirectory() as folder: path=self.config(folder,[],False) self.assertEqual(remote.load_targets(path,'controller'),[]) def test_safe_idempotent_command(self): with tempfile.TemporaryDirectory() as folder,patch.object(remote.shutil,'which',return_value='ssh'): path=self.config(folder,[{'id':'cabinet_01','host':'10.0.0.12','user':'arcade'}]) target=remote.load_targets(path,'controller')[0] self.assertEqual(target['command'][-5:],['arcade@10.0.0.12','systemctl','--user','start','arcade-cabinet.service']) self.assertIn('StrictHostKeyChecking=yes',target['command']) self.assertIn('BatchMode=yes',target['command']) self.assertNotIn('restart',target['command']) def test_invalid_hosts_and_duplicates_rejected(self): with tempfile.TemporaryDirectory() as folder,patch.object(remote.shutil,'which',return_value='ssh'): for host in ['-oProxyCommand=bad','host;reboot','user@host','host name']: with self.assertRaises(ValueError): remote.load_targets(self.config(folder,[{'id':'cabinet_01','host':host,'user':'arcade'}]),'controller') item={'id':'cabinet_01','host':'pi-one','user':'arcade'} with self.assertRaisesRegex(ValueError,'Duplicate'): remote.load_targets(self.config(folder,[item,item]),'controller') def test_ssh_failure_is_reported_not_retried(self): target={'id':'cabinet_01','command':['ssh','example']} with patch.object(remote.subprocess,'run',return_value=subprocess.CompletedProcess([],255,'','Permission denied')) as run: cabinet,ok,message=remote.start_one(target) self.assertFalse(ok) self.assertIn('Permission denied',message) run.assert_called_once() with patch.object(remote.subprocess,'run',side_effect=subprocess.TimeoutExpired('ssh',15)): self.assertFalse(remote.start_one(target)[1]) def test_service_has_desktop_environment_and_no_restart_loop(self): unit=service.render_unit('/usr/bin/python3','/home/arcade/room/tools/launch/launch.py','/home/arcade/room/tools/launch/pi.ini') self.assertIn('EnvironmentFile=%t/arcade-cabinet-display.env',unit) self.assertIn('Restart=no',unit) self.assertIn('KillMode=mixed',unit) self.assertIn('UnsetEnvironment=ARCADE_ROOM_URL',unit) self.assertNotIn('[Install]',unit) self.assertIn('%%',service.quoted('/folder/100%/file')) self.assertIn('refresh-session',service.render_desktop('/usr/bin/python3','/home/arcade/pi_service.py')) def test_working_directory_is_not_shell_quoted(self): launcher = Path('/home/arcade/room with spaces/100%/tools/launch/launch.py') unit = service.render_unit('/usr/bin/python3', launcher, '/tmp/pi.ini') line = next(line for line in unit.splitlines() if line.startswith('WorkingDirectory=')) expected = str(launcher.resolve().parents[2]).replace('%', '%%') self.assertEqual(line, 'WorkingDirectory=' + expected) self.assertEqual(service.working_directory('/home/arcade/room with spaces'), '/home/arcade/room with spaces') with self.assertRaises(ValueError): service.working_directory('/home/arcade/room\nRestart=always') def test_installer_checks_systemd_load_state(self): for state in ('bad-setting', 'error', 'not-found'): with self.subTest(state=state), patch.object(service.subprocess, 'run', return_value=subprocess.CompletedProcess([], 0, state + '\n', '')): with self.assertRaisesRegex(ValueError, 'systemd rejected'): service.reload_service() with patch.object(service.subprocess, 'run', return_value=subprocess.CompletedProcess([], 0, 'loaded\n', '')) as run: service.reload_service() self.assertEqual(run.call_count, 2) self.assertIn('--property=LoadState', run.call_args.args[0]) def test_remote_install_waits_for_desktop_without_guessing_display(self): import sys with tempfile.TemporaryDirectory() as folder: from types import SimpleNamespace with patch.object(service.sys, 'argv', ['pi_service.py', 'install', '--remote']), \ patch.object(service.sys, 'platform', 'linux'), \ patch.object(service.os, 'getuid', return_value=1000, create=True), \ patch.dict(service.os.environ, {}, clear=True), \ patch.dict(sys.modules, {'launch': SimpleNamespace(plan=lambda *args: None)}), \ patch.object(service.Path, 'home', return_value=Path(folder)), \ patch.object(service, 'reload_service'), patch.object(service, 'refresh_session') as refresh: self.assertEqual(service.main(), 0) refresh.assert_not_called() self.assertTrue((Path(folder) / '.config/systemd/user/arcade-cabinet.service').exists()) self.assertTrue((Path(folder) / '.config/autostart/arcade-cabinet-display.desktop').exists()) def test_installer_refuses_unrelated_files(self): with tempfile.TemporaryDirectory() as folder: target=Path(folder)/'service' target.write_text('User-owned settings') with self.assertRaisesRegex(ValueError,'unrelated'): service.write_managed(target,'replacement') self.assertEqual(target.read_text(),'User-owned settings') def test_online_confirmation_is_separate_from_ssh_success(self): target={'id':'cabinet_01','host':'pi-one','command':['ssh']} controller={'ready':'http://127.0.0.1:8766/','env':{'ARCADE_OPERATOR_TOKEN':'test-token'}} response=io.BytesIO(json.dumps({'cabinets':{'cabinet_01':{'online':True}}}).encode()) output=io.StringIO() with patch.object(remote,'start_one',return_value=('cabinet_01',True,'accepted')),patch.object(remote.urllib.request,'urlopen',return_value=response),redirect_stdout(output): remote.start_and_check([target],controller) self.assertIn('SSH OK',output.getvalue()) self.assertIn('ONLINE in room controller',output.getvalue()) self.assertNotIn('test-token',output.getvalue()) def test_offline_target_does_not_raise_or_stop_controller(self): target={'id':'cabinet_01','host':'pi-one','command':['ssh']} controller={'ready':'http://127.0.0.1:8766/','env':{'ARCADE_OPERATOR_TOKEN':'test-token'}} output=io.StringIO() with patch.object(remote,'start_one',return_value=('cabinet_01',False,'unreachable')),patch.object(remote.time,'monotonic',side_effect=[0,16]),redirect_stdout(output): remote.start_and_check([target],controller) self.assertIn('Controller remains running',output.getvalue())