Files
StarPilot/openpilot/starpilot/galaxy/tests/test_software_status.py
T
firestar5683 80fbb4f6db Feature registration
Register vehicle controls, planner modes, UI actions and Galaxy services after their providers exist. Wire the shared runtime entrypoints and cover their combined behavior.

Co-authored-by: Adeeb Shihadeh <adeebshihadeh@gmail.com>
Co-authored-by: Daniel Koepping <elkoled@gmail.com>
Co-authored-by: Danny <dannyirwin31@yahoo.com>
Co-authored-by: DimitriChrysafis <Dimitri.chrysafis@gmail.com>
Co-authored-by: Dom <168790843+firestar5683@users.noreply.github.com>
Co-authored-by: Harald Schäfer <harald.the.engineer@gmail.com>
Co-authored-by: Kacper Rączy <gfw.kra@gmail.com>
Co-authored-by: Prabhaav Pillai <prabhaav@msu.edu>
Co-authored-by: dirwin31 <83434411+dirwin31@users.noreply.github.com>
Co-authored-by: firestarsdog <229254897+firestarsdog@users.noreply.github.com>
Co-authored-by: stef <19478336+stefpi@users.noreply.github.com>
Co-authored-by: whoisdomi <dcantu83@gmail.com>

Co-authored-by: Intelli <contact@intelli.software>
Co-authored-by: haraschax <6804392+haraschax@users.noreply.github.com>
Co-authored-by: prabhaavp <prabhaav@msu.edu>
2026-10-14 20:02:39 -05:00

321 lines
16 KiB
Python

"""Software status from actual temporary Params and authenticated loopback HTTP."""
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime
import http.client
import json
from pathlib import Path
import tempfile
import threading
import unittest
from unittest import mock
from openpilot.common.params import Params
from openpilot.starpilot.ui.brand import DISPLAY_VERSION
from openpilot.starpilot.galaxy.access import GalaxyAccessOwner
from openpilot.starpilot.galaxy.server import make_server
from openpilot.starpilot.galaxy.software_status import SoftwareStatus, SoftwareUnavailable, MAX_FIELD_BYTES
from openpilot.starpilot.galaxy.software_operations import SoftwareOperationError
class SoftwareReaderTest(unittest.TestCase):
def setUp(self):
temporary = tempfile.TemporaryDirectory()
self.addCleanup(temporary.cleanup)
self.params = Params(temporary.name)
self.reader = SoftwareStatus(self.params)
def raw(self, key: str, value: bytes):
Path(self.params.get_param_path(key)).write_bytes(value)
def test_real_params_encodings_and_distinct_update_states(self):
self.params.put("Version", "0.9.0", block=True)
self.params.put("GitBranch", "Dom", block=True)
self.params.put("GitCommit", "a" * 40, block=True)
self.params.put("UpdaterState", "idle", block=True)
self.params.put("UpdaterTargetBranch", "Dom", block=True)
self.params.put("LastUpdateTime", datetime(2026, 9, 20, 12, 30), block=True)
self.params.put("UpdaterLastCheckTime", datetime(2026, 9, 21, 9, 15), block=True)
self.params.put("UpdaterLastFetchTime", datetime(2026, 9, 19, 10, 0), block=True)
self.params.put_bool("UpdaterFetchAvailable", True, block=True)
self.params.put_bool("UpdateAvailable", False, block=True)
self.params.put("UpdateFailedCount", 2, block=True)
result = self.reader.snapshot()
self.assertEqual(result["installed"], {"version": "0.9.0", "displayVersion": DISPLAY_VERSION, "branch": "Dom", "commit": "a" * 40})
self.assertEqual(result["updater"]["lastSuccessAt"], "2026-09-20T12:30:00Z")
self.assertEqual(result["updater"]["lastCheckedAt"], "2026-09-21T09:15:00Z")
self.assertEqual(result["updater"]["lastFetchAt"], "2026-09-19T10:00:00Z")
self.assertTrue(result["updater"]["targetChangeFound"])
self.assertFalse(result["updater"]["finalizedUpdateReady"])
self.assertEqual(result["updater"]["failedCount"], 2)
self.params.put_bool("UpdateAvailable", True, block=True)
self.assertTrue(self.reader.snapshot()["updater"]["finalizedUpdateReady"])
def test_os_update_requires_valid_branch_and_boolean(self):
for value, expected in (({"branch": "Dom", "required": True}, {"branch": "Dom", "required": True}),
({"branch": "Dom", "required": "yes"}, {}), ([], {}), (None, {})):
with self.subTest(value=value):
self.raw("UpdaterOSUpdate", json.dumps(value).encode())
self.assertEqual(self.reader.snapshot()["updater"]["osUpdate"], expected)
def test_absent_and_malformed_never_imply_up_to_date(self):
result = self.reader.snapshot()
self.assertIsNone(result["updater"]["targetChangeFound"])
self.assertIsNone(result["updater"]["finalizedUpdateReady"])
self.raw("UpdaterFetchAvailable", b"false")
self.raw("UpdateAvailable", b"\xff")
self.raw("UpdaterLastFetchTime", b"not-a-date")
self.raw("UpdateFailedCount", b"-1")
self.raw("UpdaterState", b"\xff")
corrupt = self.reader.snapshot()["updater"]
self.assertIsNone(corrupt["targetChangeFound"])
self.assertIsNone(corrupt["finalizedUpdateReady"])
self.assertIsNone(corrupt["lastFetchAt"])
self.assertIsNone(corrupt["failedCount"])
self.assertIsNone(corrupt["state"])
self.raw("UpdateFailedCount", b"00")
self.assertIsNone(self.reader.snapshot()["updater"]["failedCount"])
def test_fast_progress_supports_full_unicode_detail_and_remains_bounded(self):
detail = "\U0001f680" * 512
self.params.put("UpdaterFastState", {"version": 1, "stage": "fetching", "detail": detail, "branch": "a" * 128}, block=True)
self.assertEqual(self.reader.snapshot()["updater"]["fast"], {"stage": "fetching", "detail": detail})
self.raw("UpdaterFastState", b"x" * 8193)
with self.assertRaises(SoftwareUnavailable):
self.reader.snapshot()
def test_oversized_symlink_and_replacement_fail_closed(self):
path = Path(self.params.get_param_path("Version"))
path.write_bytes(b"x" * (MAX_FIELD_BYTES + 1))
with self.assertRaises(SoftwareUnavailable):
self.reader.snapshot()
path.unlink()
outside = path.parent.parent / "outside"
outside.write_text("secret")
path.symlink_to(outside)
with self.assertRaises(SoftwareUnavailable):
self.reader.snapshot()
path.unlink()
path.write_text("before")
original = SoftwareStatus._read
def replaced(fd, key):
value = original(fd, key)
if key == "Version":
path.rename(path.parent / "old-version")
path.write_text("after")
return value
with mock.patch.object(SoftwareStatus, "_read", side_effect=replaced):
with self.assertRaises(SoftwareUnavailable):
self.reader.snapshot()
def test_missing_default_store_is_not_created(self):
with tempfile.TemporaryDirectory() as empty, mock.patch.dict("os.environ", {"PARAMS_ROOT": str(Path(empty) / "absent"),
"OPENPILOT_PREFIX": "d"}):
with self.assertRaises(SoftwareUnavailable):
SoftwareStatus().snapshot()
self.assertFalse((Path(empty) / "absent").exists())
def test_default_reader_respects_selected_params_prefix(self):
with tempfile.TemporaryDirectory() as temporary:
with mock.patch.dict("os.environ", {"PARAMS_ROOT": temporary, "OPENPILOT_PREFIX": "d"}):
standard = Params(temporary)
standard.put("Version", "standard-build", block=True)
with mock.patch.dict("os.environ", {"PARAMS_ROOT": temporary, "OPENPILOT_PREFIX": "review"}):
selected = Params(temporary)
selected.put("Version", "selected-build", block=True)
self.assertEqual(SoftwareStatus().snapshot()["installed"]["version"], "selected-build")
with mock.patch.dict("os.environ", {"PARAMS_ROOT": temporary, "OPENPILOT_PREFIX": "d"}):
self.assertEqual(SoftwareStatus().snapshot()["installed"]["version"], "standard-build")
with mock.patch.dict("os.environ", {"PARAMS_ROOT": temporary, "OPENPILOT_PREFIX": "absent"}):
with self.assertRaises(SoftwareUnavailable):
SoftwareStatus().snapshot()
self.assertFalse((Path(temporary) / "absent").exists())
with mock.patch.dict("os.environ", {"PARAMS_ROOT": temporary, "OPENPILOT_PREFIX": "../d"}):
with self.assertRaises(SoftwareUnavailable):
SoftwareStatus()
def test_active_params_link_cannot_leave_store(self):
root = Path(self.params.get_param_path("Version")).parent
with tempfile.TemporaryDirectory() as outside:
(Path(outside) / "Version").write_text("secret")
root.unlink()
root.symlink_to(outside)
with self.assertRaises(SoftwareUnavailable):
self.reader.snapshot()
def test_retained_sibling_namespace_is_read_without_following_escape_or_changed_link(self):
root = Path(self.params.get_param_path("Version")).parent
active = Path(root.readlink())
retained = root.parent / "retained-20260924"
self.params.put("Version", "retained-build", block=True)
active.rename(retained)
root.unlink()
root.symlink_to(retained)
self.assertEqual(self.reader.snapshot()["installed"]["version"], "retained-build")
other = root.parent / "other-namespace"
other.mkdir()
(other / "Version").write_text("other-build")
original_read = SoftwareStatus._read
def changed(fd, key):
result = original_read(fd, key)
if key == "Version":
root.unlink()
root.symlink_to(other)
return result
with mock.patch.object(SoftwareStatus, "_read", side_effect=changed):
with self.assertRaises(SoftwareUnavailable):
self.reader.snapshot()
root.unlink()
root.symlink_to(root.parent.parent / "outside")
with self.assertRaises(SoftwareUnavailable):
self.reader.snapshot()
class SoftwareHTTPTest(unittest.TestCase):
def setUp(self):
temporary = tempfile.TemporaryDirectory()
self.addCleanup(temporary.cleanup)
root = Path(temporary.name)
self.params = Params(str(root / "params"))
self.params.put("Version", "fixture-build", block=True)
self.source = SoftwareStatus(self.params)
self.owner = GalaxyAccessOwner(root / "access")
self.operations = self.make_operations()
self.server = make_server(port=0, owner=self.owner, software=self.source, software_operations=self.operations)
self.thread = threading.Thread(target=self.server.serve_forever, kwargs={"poll_interval": 0.01}, daemon=True)
self.thread.start()
self.addCleanup(self.close_server)
def make_operations(self):
return None
def close_server(self):
self.server.shutdown()
self.thread.join(timeout=2)
self.server.server_close()
def request(self, path, *, method="GET", body=None, headers=None):
# Exercise the password-backed forwarded path.
connection = http.client.HTTPConnection("127.0.0.1", self.server.server_port, timeout=2)
try:
connection.request(method, path, body=body, headers={'Forwarded': 'for=203.0.113.8', **(headers or {})})
response = connection.getresponse()
return response.status, response.read(), dict(response.getheaders())
finally:
connection.close()
def login(self):
payload = json.dumps({"password": "password123"})
status, _, headers = self.request("/api/auth/login", method="POST", body=payload,
headers={"Content-Type": "application/json", "Origin": f"http://127.0.0.1:{self.server.server_port}"})
self.assertEqual(status, 200)
return headers["Set-Cookie"].split(";", 1)[0]
def test_auth_before_io_and_get_only(self):
with mock.patch.object(self.source, "snapshot", wraps=self.source.snapshot) as read:
self.assertEqual(self.request("/api/software/status")[0], 503)
self.owner.configure("password123", lambda: True)
self.assertEqual(self.request("/api/software/status")[0], 401)
read.assert_not_called()
cookie = self.login()
status, body, headers = self.request("/api/software/status", headers={"Cookie": cookie})
self.assertEqual(status, 200)
self.assertEqual(headers["Cache-Control"], "no-store")
self.assertEqual(json.loads(body)["installed"]["version"], "fixture-build")
self.assertEqual(self.request("/api/software/status", headers={"Cookie": cookie, "Host": "other.example"})[0], 403)
origin = f"http://127.0.0.1:{self.server.server_port}"
self.assertEqual(self.request("/api/software/status", method="POST", body="{}",
headers={"Cookie": cookie, "Origin": origin, "Content-Type": "application/json"})[0], 405)
def test_logout_and_credential_change_during_read_suppress_result(self):
self.owner.configure("password123", lambda: True)
cookie = self.login()
for operation in ("logout", "remove"):
with self.subTest(operation=operation):
entered, release = threading.Event(), threading.Event()
original = self.source.snapshot
def paused(entered=entered, release=release, original=original):
entered.set()
if not release.wait(2):
raise TimeoutError("fixture release missing")
return original()
with mock.patch.object(self.source, "snapshot", side_effect=paused), ThreadPoolExecutor(max_workers=1) as pool:
pending = pool.submit(self.request, "/api/software/status", headers={"Cookie": cookie})
self.assertTrue(entered.wait(1))
if operation == "logout":
origin = f"http://127.0.0.1:{self.server.server_port}"
self.assertEqual(self.request("/api/auth/logout", method="POST", body="{}",
headers={"Cookie": cookie, "Origin": origin, "Content-Type": "application/json"})[0], 200)
else:
self.assertTrue(self.owner.remove(lambda: True))
release.set()
status, body, _ = pending.result(timeout=2)
self.assertEqual(status, 401 if operation == "logout" else 503)
self.assertNotIn(b"fixture-build", body)
if operation == "logout":
cookie = self.login()
class SoftwareOperationsHTTPTest(SoftwareHTTPTest):
def make_operations(self):
operations = mock.Mock()
operations.snapshot.return_value = {"parked": True, "availableBranches": ["Dom"], "selectedTarget": "Dom", "request": None,
"canCheck": True, "canSelect": True, "canDownload": False, "canInstall": False}
operations.action.return_value = operations.snapshot.return_value
return operations
def post(self, payload, cookie=None, origin=None):
headers = {"Content-Type": "application/json", "Origin": origin or f"http://127.0.0.1:{self.server.server_port}"}
if cookie is not None:
headers["Cookie"] = cookie
return self.request("/api/software/action", method="POST", body=json.dumps(payload), headers=headers)
def test_actions_require_session_and_same_origin_before_owner(self):
self.assertEqual(self.post({"action": "check"})[0], 503)
self.owner.configure("password123", lambda: True)
self.assertEqual(self.post({"action": "check"})[0], 401)
cookie = self.login()
self.assertEqual(self.post({"action": "check"}, cookie, "http://other.example")[0], 403)
self.operations.action.assert_not_called()
self.assertEqual(self.post([], cookie)[0], 400)
self.operations.action.assert_not_called()
def test_actions_are_forwarded_once_and_status_stays_read_only(self):
self.owner.configure("password123", lambda: True)
cookie = self.login()
status, body, _ = self.request("/api/software/status", headers={"Cookie": cookie})
self.assertEqual(status, 200)
self.assertEqual(json.loads(body)["operations"]["availableBranches"], ["Dom"])
self.operations.action.assert_not_called()
for action in ("check", "select", "download", "install"):
self.operations.action.reset_mock()
payload = {"action": action} if action == "check" else {"action": action, "branch": "Dom"}
def accepted(operation, request, *, authorized, expected_action=action, expected_payload=payload):
self.assertEqual((operation, request), (expected_action, expected_payload))
self.assertTrue(authorized())
return self.operations.snapshot.return_value
self.operations.action.side_effect = accepted
status, body, headers = self.post(payload, cookie)
self.assertEqual(status, 200)
self.assertEqual(headers["Cache-Control"], "no-store")
self.assertEqual(json.loads(body)["installed"]["version"], "fixture-build")
self.operations.action.assert_called_once()
def test_rejection_and_mid_operation_revocation_are_not_success(self):
self.owner.configure("password123", lambda: True)
cookie = self.login()
self.operations.action.side_effect = SoftwareOperationError("Target changed", status=409)
self.assertEqual(self.post({"action": "install", "branch": "Dom"}, cookie)[0], 409)
effects = []
def revoked(operation, payload, *, authorized):
self.assertTrue(authorized())
self.assertTrue(self.owner.remove(lambda: True))
if not authorized():
raise SoftwareOperationError("Session ended", status=403)
effects.append(operation)
return self.operations.snapshot.return_value
self.operations.action.side_effect = revoked
self.assertEqual(self.post({"action": "download", "branch": "Dom"}, cookie)[0], 401)
self.assertEqual(effects, [])