mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-11-06 20:43:46 +08:00
80fbb4f6db
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>
321 lines
16 KiB
Python
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, [])
|