Files
framebridge/source/tests/test_lifecycle_contract.py
T

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()