376 lines
20 KiB
Python
376 lines
20 KiB
Python
import hashlib
|
|
import json
|
|
from pathlib import Path
|
|
import subprocess
|
|
import tempfile
|
|
import unittest
|
|
from unittest import mock
|
|
|
|
from framebridge_expanded import autostart, config, lifecycle, runtime, connection_background
|
|
|
|
|
|
class LifecycleContractTests(unittest.TestCase):
|
|
def setUp(self):
|
|
self.tmp = tempfile.TemporaryDirectory()
|
|
self.addCleanup(self.tmp.cleanup)
|
|
self.home = Path(self.tmp.name) / "home"
|
|
self.home.mkdir(mode=0o700)
|
|
self.state = self.home / "profile"
|
|
self.addCleanup(mock.patch.stopall)
|
|
mock.patch.object(Path, "home", return_value=self.home).start()
|
|
self.settings = config.default_config()
|
|
self.settings["tunnel_id"] = "tunnel_0123456789abcdef0123456789abcdef"
|
|
config.save_config(self.state, self.settings)
|
|
_, self.directory, self.unit = autostart._paths(self.state)
|
|
self.unit.parent.mkdir(mode=0o700, parents=True)
|
|
unit = autostart.unit_text(self.state, self.directory / "credential.cred").encode()
|
|
autostart._write(self.unit, unit)
|
|
cipher = b"SYNTHETIC-ENCRYPTED-FIXTURE-NOT-A-CREDENTIAL"
|
|
autostart._write(self.directory / "credential.cred", cipher)
|
|
self.record = {"schema": 1, "unit": self.unit.name,
|
|
"unit_sha256": hashlib.sha256(unit).hexdigest(),
|
|
"credential_sha256": hashlib.sha256(cipher).hexdigest(),
|
|
"connection_settings_sha256": autostart._connection_public_digest(self.settings),
|
|
"node": False, "headless_changed": False}
|
|
autostart._json(self.directory / "installation.json", self.record)
|
|
self.enabled = True
|
|
self.active = True
|
|
self.calls = []
|
|
self.system = mock.patch.object(autostart, "_systemctl", side_effect=self.systemctl).start()
|
|
self.command = mock.patch.object(autostart, "_command", side_effect=AssertionError("No OS authentication or encryption command belongs in these unit fixtures.")).start()
|
|
mock.patch.object(autostart, "_linger", return_value=True).start()
|
|
self.runtime_start = mock.patch.object(runtime, "start", return_value={"running": True, "ready": True}).start()
|
|
self.runtime_status = mock.patch.object(runtime, "status", return_value={"running": True, "ready": True}).start()
|
|
self.runtime_stop = mock.patch.object(runtime, "stop", return_value={"ok": True, "running": False}).start()
|
|
self.background_status = mock.patch.object(connection_background, "status", return_value={"ok": True, "alive": False, "verified": False, "state": "stopped"}).start()
|
|
self.background_stop = mock.patch.object(connection_background, "disconnect", return_value={"ok": True, "alive": False, "state": "stopped"}).start()
|
|
|
|
def systemctl(self, *args, **kwargs):
|
|
self.calls.append(args)
|
|
if args[0] == "enable":
|
|
self.enabled = True
|
|
if args[0] == "disable":
|
|
self.enabled = False
|
|
if args[0] == "start" or "--now" in args and args[0] == "enable":
|
|
self.active = True
|
|
if args[0] == "stop" or "--now" in args and args[0] == "disable":
|
|
self.active = False
|
|
body = ("ActiveState=" + ("active" if self.active else "inactive")
|
|
+ "\nSubState=" + ("running" if self.active else "dead")
|
|
+ "\nUnitFileState=" + ("enabled" if self.enabled else "disabled") + "\n").encode()
|
|
return subprocess.CompletedProcess(args, 0, body, b"")
|
|
|
|
def test_boot_disable_preserves_execution_cipher_and_profile(self):
|
|
paths = [self.state / "config.json", self.directory / "credential.cred", self.directory / "installation.json", self.unit]
|
|
before = {p: p.read_bytes() for p in paths}
|
|
result = lifecycle.boot_disable(self.state)
|
|
self.assertEqual("boot_disabled", result["state"])
|
|
self.assertTrue(self.active)
|
|
self.assertFalse(self.enabled)
|
|
self.assertEqual(before, {p: p.read_bytes() for p in paths})
|
|
self.assertIn(("disable", self.unit.name), self.calls)
|
|
self.runtime_stop.assert_not_called()
|
|
self.background_stop.assert_not_called()
|
|
|
|
def test_boot_enable_does_not_start_an_inactive_stack(self):
|
|
self.active = self.enabled = False
|
|
result = lifecycle.boot_enable(self.state)
|
|
self.assertTrue(result["current_execution_unchanged"])
|
|
self.assertTrue(self.enabled)
|
|
self.assertFalse(self.active)
|
|
self.assertIn(("enable", self.unit.name), self.calls)
|
|
self.runtime_start.assert_not_called()
|
|
|
|
def test_service_stop_preserves_boot_and_material(self):
|
|
before = (self.directory / "credential.cred").read_bytes()
|
|
result = lifecycle.service_stop(self.state)
|
|
self.assertTrue(result["ok"])
|
|
self.assertTrue(self.enabled)
|
|
self.assertFalse(self.active)
|
|
self.assertEqual(before, (self.directory / "credential.cred").read_bytes())
|
|
self.assertTrue(autostart.manual_stop_requested(self.state))
|
|
self.assertTrue(autostart.connection_requested(self.state))
|
|
self.assertNotIn(("disable", self.unit.name), self.calls)
|
|
|
|
def test_service_start_retains_disabled_boot_policy(self):
|
|
self.active = self.enabled = False
|
|
autostart.mark_manual_stop(self.state)
|
|
result = lifecycle.service_start(self.state)
|
|
self.assertEqual("start_requested", result["state"])
|
|
self.assertFalse(self.enabled)
|
|
self.assertTrue(self.active)
|
|
self.assertFalse(autostart.manual_stop_requested(self.state))
|
|
self.assertNotIn(("enable", self.unit.name), self.calls)
|
|
|
|
def test_disconnect_persists_intent_without_stopping_local_tools(self):
|
|
result = lifecycle.disconnect(self.state)
|
|
self.assertTrue(result["ok"])
|
|
self.assertFalse(autostart.connection_requested(self.state))
|
|
self.assertEqual("until_explicit_connect", result["scope"])
|
|
self.runtime_stop.assert_not_called()
|
|
self.assertFalse(any(x[0] in {"stop", "disable"} for x in self.calls))
|
|
self.assertEqual("disconnected", lifecycle.status(self.state)["desired_connection"])
|
|
|
|
def test_connection_intent_is_not_tied_to_a_boot_identifier(self):
|
|
lifecycle.disconnect(self.state)
|
|
record = json.loads((self.directory / "connection-intent.json").read_text())
|
|
self.assertNotIn("boot_id", record)
|
|
self.assertFalse(autostart.connection_requested(self.state))
|
|
revision = autostart.connection_request_revision(self.state)
|
|
autostart._set_connection_requested(self.state, True)
|
|
self.assertTrue(autostart.connection_requested(self.state))
|
|
self.assertGreater(autostart.connection_request_revision(self.state), revision)
|
|
|
|
def test_saved_resume_wakes_transport_without_stopping_local_tools(self):
|
|
lifecycle.disconnect(self.state)
|
|
self.background_stop.reset_mock()
|
|
self.calls.clear()
|
|
current = {"configured": True, "enabled": True, "service_active": True,
|
|
"runtime": {"running": True, "ready": True},
|
|
"connection": {"alive": False, "verified": False},
|
|
"worker": {"state": "disconnected"}}
|
|
with mock.patch.object(autostart, "status", return_value=current):
|
|
autostart.resume_connection(self.state)
|
|
self.assertTrue(autostart.connection_requested(self.state))
|
|
self.assertFalse(any(x[0] in {"stop", "start"} for x in self.calls))
|
|
self.background_stop.assert_not_called()
|
|
self.runtime_stop.assert_not_called()
|
|
self.command.assert_not_called()
|
|
|
|
def test_existing_legacy_enable_repairs_a_disabled_unit(self):
|
|
self.enabled = self.active = False
|
|
result = autostart.enable(self.state)
|
|
self.assertTrue(self.enabled)
|
|
self.assertTrue(self.active)
|
|
self.assertIn(("enable", "--now", self.unit.name), self.calls)
|
|
|
|
def test_changed_unit_is_rejected_before_boot_or_stop_actions(self):
|
|
self.unit.write_text("changed")
|
|
self.unit.chmod(0o600)
|
|
self.calls.clear()
|
|
with self.assertRaisesRegex(RuntimeError, "changed outside"):
|
|
lifecycle.boot_disable(self.state)
|
|
with self.assertRaisesRegex(RuntimeError, "changed outside"):
|
|
lifecycle.service_stop(self.state)
|
|
self.assertEqual([], self.calls)
|
|
|
|
def test_forget_removes_managed_connection_but_retains_profile_and_linger(self):
|
|
before = (self.state / "config.json").read_bytes()
|
|
autostart._set_connection_requested(self.state, False)
|
|
result = lifecycle.forget_credentials(self.state)
|
|
self.assertTrue(result["profile_preserved"])
|
|
self.assertTrue(result["linger_preserved"])
|
|
self.assertEqual(before, (self.state / "config.json").read_bytes())
|
|
self.assertFalse(self.unit.exists())
|
|
self.assertFalse((self.directory / "credential.cred").exists())
|
|
self.assertFalse((self.directory / "installation.json").exists())
|
|
self.assertFalse((self.directory / "connection-intent.json").exists())
|
|
self.assertEqual("not_configured", lifecycle.status(self.state)["desired_connection"])
|
|
self.command.assert_not_called()
|
|
|
|
def test_forget_refuses_changed_cipher_without_removing_anything(self):
|
|
p = self.directory / "credential.cred"
|
|
p.write_bytes(b"changed")
|
|
p.chmod(0o600)
|
|
self.calls.clear()
|
|
with self.assertRaisesRegex(RuntimeError, "changed outside"):
|
|
lifecycle.forget_credentials(self.state)
|
|
self.assertTrue(self.unit.exists())
|
|
self.assertTrue(p.exists())
|
|
self.assertTrue((self.directory / "installation.json").exists())
|
|
self.assertEqual([], self.calls)
|
|
|
|
def test_paused_worker_never_decrypts_or_starts_a_transport(self):
|
|
lifecycle.disconnect(self.state)
|
|
with mock.patch.object(autostart, "_credential") as credential, \
|
|
mock.patch.object(connection_background, "start") as launch, \
|
|
mock.patch.object(autostart.time, "sleep", side_effect=lambda _: setattr(autostart, "_STOP", True)):
|
|
self.assertEqual(0, autostart.worker(self.state))
|
|
credential.assert_not_called()
|
|
launch.assert_not_called()
|
|
self.assertFalse(autostart.connection_requested(self.state))
|
|
|
|
def test_explicit_connect_resumes_paused_worker_without_runtime_restart(self):
|
|
lifecycle.disconnect(self.state)
|
|
sleeps = 0
|
|
def sleep(_):
|
|
nonlocal sleeps
|
|
sleeps += 1
|
|
if sleeps == 1:
|
|
autostart._set_connection_requested(self.state, True)
|
|
else:
|
|
autostart._STOP = True
|
|
with mock.patch.object(autostart, "_credential", return_value="SYNTHETIC-NOT-A-REAL-KEY") as credential, \
|
|
mock.patch.object(connection_background, "start") as launch, \
|
|
mock.patch.object(autostart.time, "sleep", side_effect=sleep):
|
|
self.assertEqual(0, autostart.worker(self.state))
|
|
self.assertEqual(1, launch.call_count)
|
|
self.assertEqual(1, credential.call_count)
|
|
self.assertEqual(1, self.runtime_start.call_count)
|
|
self.assertEqual(1, self.runtime_stop.call_count) # Final simulated unit stop only.
|
|
|
|
def test_legacy_current_only_disconnect_is_not_restarted_in_same_worker(self):
|
|
sleeps = 0
|
|
def sleep(_):
|
|
nonlocal sleeps
|
|
sleeps += 1
|
|
if sleeps == 2:
|
|
autostart._STOP = True
|
|
states = [{"alive": False, "state": "stopped"},
|
|
{"alive": True, "verified": True, "state": "ready"},
|
|
{"alive": False, "state": "stopped"}]
|
|
self.background_status.side_effect = states
|
|
with mock.patch.object(autostart, "_credential", return_value="SYNTHETIC-NOT-A-REAL-KEY"), \
|
|
mock.patch.object(connection_background, "start") as launch, \
|
|
mock.patch.object(autostart.time, "sleep", side_effect=sleep):
|
|
self.assertEqual(0, autostart.worker(self.state))
|
|
self.assertEqual(1, launch.call_count)
|
|
self.assertEqual(1, self.runtime_start.call_count)
|
|
|
|
def test_transport_error_retries_without_restarting_local_runtime(self):
|
|
self.background_status.return_value = {"alive": False, "state": "error"}
|
|
sleeps = 0
|
|
clock = [0.0]
|
|
def sleep(_):
|
|
nonlocal sleeps
|
|
sleeps += 1
|
|
clock[0] += 15
|
|
if sleeps == 2:
|
|
autostart._STOP = True
|
|
with mock.patch.object(autostart, "_credential", return_value="SYNTHETIC-NOT-A-REAL-KEY"), \
|
|
mock.patch.object(connection_background, "start") as launch, \
|
|
mock.patch.object(autostart.time, "sleep", side_effect=sleep), \
|
|
mock.patch.object(autostart.time, "monotonic", side_effect=lambda: clock[0]):
|
|
self.assertEqual(0, autostart.worker(self.state))
|
|
self.assertEqual(2, launch.call_count)
|
|
self.assertEqual(1, self.runtime_start.call_count)
|
|
|
|
|
|
def test_connect_respects_an_explicit_disabled_boot_policy(self):
|
|
lifecycle.boot_disable(self.state)
|
|
self.calls.clear()
|
|
current = {"configured": True, "enabled": False, "service_active": True,
|
|
"runtime": {"running": True, "ready": True},
|
|
"connection": {"alive": False, "verified": False},
|
|
"worker": {"state": "disconnected"}}
|
|
with mock.patch.object(autostart, "status", return_value=current):
|
|
autostart.resume_connection(self.state)
|
|
self.assertFalse(autostart.boot_requested(self.state))
|
|
self.assertFalse(any(x[0] == "enable" for x in self.calls))
|
|
|
|
def test_connect_starts_disabled_inactive_service_without_enabling_boot(self):
|
|
lifecycle.boot_disable(self.state)
|
|
self.active = False
|
|
self.calls.clear()
|
|
current = {"configured": True, "enabled": False, "service_active": False,
|
|
"runtime": {"running": False, "ready": False}, "worker": {"state": "stopped"}}
|
|
with mock.patch.object(autostart, "status", return_value=current):
|
|
autostart.resume_connection(self.state)
|
|
self.assertIn(("start", self.unit.name), self.calls)
|
|
self.assertFalse(self.enabled)
|
|
self.assertFalse(any(x[0] == "enable" for x in self.calls))
|
|
|
|
def test_failed_boot_policy_action_restores_previous_intent(self):
|
|
lifecycle.boot_enable(self.state)
|
|
before = (self.directory / "boot-policy.json").read_bytes()
|
|
with mock.patch.object(autostart, "_systemctl", side_effect=RuntimeError("synthetic service failure")):
|
|
with self.assertRaisesRegex(RuntimeError, "synthetic"):
|
|
lifecycle.boot_disable(self.state)
|
|
self.assertEqual(before, (self.directory / "boot-policy.json").read_bytes())
|
|
self.assertTrue(autostart.boot_requested(self.state))
|
|
|
|
def _new_node(self):
|
|
state = self.home / "new-node"
|
|
settings = config.default_config(role="node")
|
|
settings.update(headless="auto", broker_url="https://example.test:20878")
|
|
config.save_config(state, settings)
|
|
self.active = self.enabled = False
|
|
def command(args, **kwargs):
|
|
return subprocess.CompletedProcess(args, 0, b"systemd 257\n" if args[:2] == ["systemd-creds", "--version"] else b"", b"")
|
|
self.command.side_effect = command
|
|
return state
|
|
|
|
def test_initial_node_service_start_needs_no_chatgpt_key_or_extra_question(self):
|
|
state = self._new_node()
|
|
with mock.patch("builtins.input", side_effect=AssertionError("No redundant startup question")), \
|
|
mock.patch.object(autostart.getpass, "getpass", side_effect=AssertionError("Node has no ChatGPT key")), \
|
|
mock.patch.object(autostart, "_enabled_units", return_value=[]):
|
|
result = lifecycle.service_start(state)
|
|
_, directory, unit = autostart._paths(state)
|
|
self.assertTrue(result["initial_configuration"])
|
|
self.assertTrue(result["boot_policy_changed"])
|
|
self.assertIn(("enable", "--now", unit.name), self.calls)
|
|
self.assertFalse((directory / "credential.cred").exists())
|
|
self.assertTrue(json.loads((directory / "installation.json").read_text())["node"])
|
|
|
|
def test_initial_node_boot_enable_does_not_start_current_execution(self):
|
|
state = self._new_node()
|
|
with mock.patch("builtins.input", side_effect=AssertionError("No redundant startup question")), \
|
|
mock.patch.object(autostart.getpass, "getpass", side_effect=AssertionError("Node has no ChatGPT key")), \
|
|
mock.patch.object(autostart, "_enabled_units", return_value=[]):
|
|
result = lifecycle.boot_enable(state)
|
|
_, directory, unit = autostart._paths(state)
|
|
self.assertTrue(result["initial_configuration"])
|
|
self.assertTrue(result["current_execution_unchanged"])
|
|
self.assertTrue(autostart.boot_requested(state))
|
|
self.assertFalse(self.active)
|
|
self.assertNotIn(("enable", "--now", unit.name), self.calls)
|
|
self.assertFalse((directory / "credential.cred").exists())
|
|
|
|
|
|
|
|
def test_status_handles_a_profile_with_an_empty_autostart_directory(self):
|
|
state = self.home / "not-configured"
|
|
config.save_config(state, config.default_config())
|
|
(state / "autostart").mkdir(mode=0o700)
|
|
result = lifecycle.status(state)
|
|
self.assertTrue(result["ok"])
|
|
self.assertFalse(result["boot"]["configured"])
|
|
self.assertEqual("not_configured", result["desired_connection"])
|
|
self.assertEqual("not_configured", autostart.status(state)["desired_connection"])
|
|
|
|
def test_persistent_transport_error_respects_retry_delay(self):
|
|
self.background_status.return_value = {"alive": False, "state": "error"}
|
|
sleeps = 0
|
|
def sleep(_):
|
|
nonlocal sleeps
|
|
sleeps += 1
|
|
if sleeps == 2:
|
|
autostart._STOP = True
|
|
with mock.patch.object(autostart, "_credential", return_value="SYNTHETIC-NOT-A-REAL-KEY"), \
|
|
mock.patch.object(connection_background, "start") as launch, \
|
|
mock.patch.object(autostart.time, "sleep", side_effect=sleep), \
|
|
mock.patch.object(autostart.time, "monotonic", return_value=0.0):
|
|
self.assertEqual(0, autostart.worker(self.state))
|
|
self.assertEqual(1, launch.call_count)
|
|
self.assertEqual(1, self.runtime_start.call_count)
|
|
|
|
|
|
|
|
def test_service_stop_does_not_confuse_query_success_with_termination(self):
|
|
results = [{"ok": True, "running": True, "processes": []},
|
|
{"ok": True, "running": False, "processes": [{"alive": True}]}]
|
|
for observed in results:
|
|
with self.subTest(observed=observed), \
|
|
mock.patch.object(runtime, "stop", return_value=observed), \
|
|
mock.patch.object(lifecycle.time, "monotonic", side_effect=[0.0, 3.0]):
|
|
result = lifecycle.service_stop(self.state)
|
|
self.assertFalse(result["ok"])
|
|
self.assertEqual("stop_incomplete", result["state"])
|
|
self.assertTrue((self.directory / "credential.cred").exists())
|
|
|
|
def test_forget_retains_material_if_local_tools_remain_running(self):
|
|
before = (self.directory / "credential.cred").read_bytes()
|
|
with mock.patch.object(runtime, "stop", return_value={"ok": True, "running": True}), \
|
|
mock.patch.object(lifecycle.time, "monotonic", side_effect=[0.0, 3.0]):
|
|
with self.assertRaisesRegex(RuntimeError, "material was retained"):
|
|
lifecycle.forget_credentials(self.state)
|
|
self.assertEqual(before, (self.directory / "credential.cred").read_bytes())
|
|
self.assertTrue(self.unit.exists())
|
|
self.assertTrue((self.directory / "installation.json").exists())
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|