211 lines
11 KiB
Python
211 lines
11 KiB
Python
"""Offline tests for the GUI bridge; serial/device I/O is always mocked."""
|
|||
|
|
import io
|
||
|
|
import json
|
||
|
|
from pathlib import Path
|
||
|
|
import subprocess
|
||
|
|
import sys
|
||
|
|
import tempfile
|
||
|
|
import types
|
||
|
|
import unittest
|
||
|
|
from unittest.mock import patch
|
||
|
|
|
||
|
|
TOOLS = Path(__file__).resolve().parents[1] / "tools"
|
||
|
|
sys.path.insert(0, str(TOOLS))
|
||
|
|
import flash_app_bridge as bridge
|
||
|
|
from test_device_flash import MAC, SimulatedEsptool, make_build
|
||
|
|
|
||
|
|
|
||
|
|
class BridgeTests(unittest.TestCase):
|
||
|
|
def setUp(self):
|
||
|
|
self.temp = tempfile.TemporaryDirectory(prefix=".bridge-test-", dir=Path(__file__).parent)
|
||
|
|
self.addCleanup(self.temp.cleanup)
|
||
|
|
self.root = Path(self.temp.name).resolve()
|
||
|
|
self.build = self.root / "build"
|
||
|
|
make_build(self.build)
|
||
|
|
self.key = "ab" * 32
|
||
|
|
self.password = "private-setup-password"
|
||
|
|
self.credential = self.root / "credential.json"
|
||
|
|
self.info = {"factory_id": "KP01-123456789ABC", "model": "ECHOEAR-S3", "key_version": 1,
|
||
|
|
"origin": "https://example.com", "device_key_hex": self.key}
|
||
|
|
self.credential.write_text(json.dumps(self.info), encoding="utf-8")
|
||
|
|
self.request = {"credential_path": str(self.credential), "build_dir": str(self.build),
|
||
|
|
"mode": "identity", "expected_mac": MAC, "port": "COM6",
|
||
|
|
"output_root": str(self.root / "output")}
|
||
|
|
self.events = []
|
||
|
|
for owner, name, replacement in [
|
||
|
|
(bridge.device_prepare, "private_directory", lambda path: path.mkdir(parents=True, mode=0o700)),
|
||
|
|
(bridge.device_flash, "private_directory", lambda path: path.mkdir(parents=True, mode=0o700)),
|
||
|
|
(bridge.device_prepare, "prepare", self.fake_prepare),
|
||
|
|
]:
|
||
|
|
item = patch.object(owner, name, side_effect=replacement)
|
||
|
|
item.start()
|
||
|
|
self.addCleanup(item.stop)
|
||
|
|
|
||
|
|
def fake_prepare(self, credential, output, expected_mac):
|
||
|
|
info = bridge.device_prepare.read_credential(credential)
|
||
|
|
output.mkdir()
|
||
|
|
data = b"\xab" * bridge.device_flash.FACTORY_SIZE
|
||
|
|
(output / "ble_factory.bin").write_bytes(data)
|
||
|
|
(output / "factory.csv").write_text("device_key," + self.key)
|
||
|
|
(output / "setup-label.json").write_text(json.dumps({"setup_password": self.password}))
|
||
|
|
manifest = {"schema_version": 1, "chip": "esp32s3", "flash_size": bridge.device_flash.FLASH_SIZE,
|
||
|
|
"device_id": info["device_id"], "model": info["model"], "expected_mac": expected_mac,
|
||
|
|
"image": {"file": "ble_factory.bin", "offset": bridge.device_flash.FACTORY_OFFSET,
|
||
|
|
"size": len(data), "sha256": bridge.device_flash.sha256(data)}}
|
||
|
|
(output / "manifest.json").write_text(json.dumps(manifest))
|
||
|
|
return manifest
|
||
|
|
|
||
|
|
def preview(self, **overrides):
|
||
|
|
bridge.preview({**self.request, **overrides}, self.events.append)
|
||
|
|
return self.events[-1]
|
||
|
|
|
||
|
|
def assert_blocked(self, request, code):
|
||
|
|
with patch.object(bridge.device_flash, "execute_plan") as execute:
|
||
|
|
with self.assertRaises(bridge.BridgeError) as raised:
|
||
|
|
bridge.flash(request, self.events.append)
|
||
|
|
self.assertEqual(raised.exception.code, code)
|
||
|
|
execute.assert_not_called()
|
||
|
|
|
||
|
|
def test_preview_is_offline_and_secret_free(self):
|
||
|
|
with patch.object(bridge.device_flash, "execute_plan") as execute, patch.object(bridge, "hidden_run") as run:
|
||
|
|
result = self.preview()
|
||
|
|
execute.assert_not_called()
|
||
|
|
run.assert_not_called()
|
||
|
|
self.assertEqual(result["event"], "preview")
|
||
|
|
summary = result["summary"]
|
||
|
|
self.assertEqual(summary["mode"], "identity")
|
||
|
|
self.assertIsNone(summary["firmware_version"])
|
||
|
|
self.assertEqual(summary["reference_firmware_version"], "1.7.0-controls.28")
|
||
|
|
self.assertEqual({image["name"] for image in summary["regions"]}, {"ble_factory", "reset-runtime"})
|
||
|
|
text = json.dumps(self.events)
|
||
|
|
self.assertNotIn(self.key, text)
|
||
|
|
self.assertNotIn(self.password, text)
|
||
|
|
session = bridge.load_session(Path(result["session_file"]))
|
||
|
|
self.assertEqual(session["port"], "COM6")
|
||
|
|
self.assertEqual(session["expected_mac"], MAC)
|
||
|
|
self.assertNotIn(self.key, json.dumps(session))
|
||
|
|
|
||
|
|
def test_full_preview_has_complete_regions_and_version(self):
|
||
|
|
result = self.preview(mode="full")
|
||
|
|
self.assertEqual(result["summary"]["firmware_version"], "1.7.0-controls.28")
|
||
|
|
self.assertEqual(len(result["summary"]["regions"]), 8)
|
||
|
|
|
||
|
|
def test_bad_build_refuses_to_create_factory(self):
|
||
|
|
(self.build / "app.bin").write_bytes(b"not firmware")
|
||
|
|
with self.assertRaises(bridge.BridgeError) as raised:
|
||
|
|
self.preview()
|
||
|
|
self.assertEqual(raised.exception.code, "invalid_build")
|
||
|
|
self.assertFalse((self.root / "output").exists())
|
||
|
|
|
||
|
|
def test_bad_credential_refuses_to_create_factory(self):
|
||
|
|
self.info["device_key_hex"] = "invalid key"
|
||
|
|
self.credential.write_text(json.dumps(self.info))
|
||
|
|
with self.assertRaises(bridge.BridgeError) as raised:
|
||
|
|
self.preview()
|
||
|
|
self.assertEqual(raised.exception.code, "invalid_credential")
|
||
|
|
self.assertFalse((self.root / "output").exists())
|
||
|
|
|
||
|
|
def test_flash_requires_literal_true_confirmation(self):
|
||
|
|
session = self.preview()["session_file"]
|
||
|
|
for value in (None, False, 1, "true"):
|
||
|
|
with self.subTest(value=value):
|
||
|
|
self.assert_blocked({"session_file": session, "execute": value}, "confirmation_required")
|
||
|
|
|
||
|
|
def test_port_and_mac_bound_to_preview(self):
|
||
|
|
session = self.preview()["session_file"]
|
||
|
|
for override in ({"port": "COM7"}, {"expected_mac": "11:22:33:44:55:66"}):
|
||
|
|
with self.subTest(override=override):
|
||
|
|
self.assert_blocked({"session_file": session, "execute": True, **override}, "port_mismatch")
|
||
|
|
|
||
|
|
def test_frozen_inputs_changes_block_before_hardware(self):
|
||
|
|
for filename in ("credential.json", "build/app.bin", "build/flasher_args.json", "factory/ble_factory.bin", "factory/setup-label.json"):
|
||
|
|
with self.subTest(filename=filename):
|
||
|
|
session_path = Path(self.preview()["session_file"])
|
||
|
|
path = session_path.parent / filename if filename.startswith("factory/") else self.root / filename
|
||
|
|
original = path.read_bytes()
|
||
|
|
try:
|
||
|
|
path.write_bytes(original + b"changed")
|
||
|
|
self.assert_blocked({"session_file": str(session_path), "execute": True}, "stale_preview")
|
||
|
|
finally:
|
||
|
|
path.write_bytes(original)
|
||
|
|
|
||
|
|
def test_expiry_blocks_before_hardware(self):
|
||
|
|
session = self.preview()["session_file"]
|
||
|
|
with patch.object(bridge.time, "time", return_value=bridge.time.time() + bridge.SESSION_TTL + 1):
|
||
|
|
self.assert_blocked({"session_file": session, "execute": True}, "stale_preview")
|
||
|
|
|
||
|
|
def test_modified_session_cannot_change_target(self):
|
||
|
|
session = Path(self.preview()["session_file"])
|
||
|
|
value = json.loads(session.read_text())
|
||
|
|
value["port"] = "COM42"
|
||
|
|
session.write_text(json.dumps(value))
|
||
|
|
self.assert_blocked({"session_file": str(session), "execute": True}, "stale_preview")
|
||
|
|
|
||
|
|
def test_cannot_supply_arbitrary_command_or_runner(self):
|
||
|
|
session = self.preview()["session_file"]
|
||
|
|
for field in ("command", "python", "baud", "factory_dir", "backup_dir"):
|
||
|
|
self.assert_blocked({"session_file": session, "execute": True, field: "evil"}, "invalid_request")
|
||
|
|
|
||
|
|
def test_success_runs_existing_backup_write_verify_and_consumes_session(self):
|
||
|
|
session = self.preview()["session_file"]
|
||
|
|
runner = SimulatedEsptool()
|
||
|
|
with patch.object(bridge, "hidden_run", side_effect=runner):
|
||
|
|
bridge.flash({"session_file": session, "execute": True}, self.events.append)
|
||
|
|
self.assertEqual(runner.commands, ["version", "read_mac", "flash_id", "get_security_info", "read_flash", "write_flash", "verify_flash"])
|
||
|
|
self.assertEqual(self.events[-1]["event"], "complete")
|
||
|
|
self.assertTrue((Path(session).parent / "backup" / "before-16mb.bin").is_file())
|
||
|
|
self.assertIn("backup", [item.get("stage") for item in self.events])
|
||
|
|
self.assertIn("writing", [item.get("stage") for item in self.events])
|
||
|
|
self.assertIn("verifying", [item.get("stage") for item in self.events])
|
||
|
|
self.assert_blocked({"session_file": session, "execute": True}, "stale_preview")
|
||
|
|
|
||
|
|
def test_device_mac_mismatch_never_writes(self):
|
||
|
|
session = self.preview()["session_file"]
|
||
|
|
runner = SimulatedEsptool(mac="11:22:33:44:55:66")
|
||
|
|
with patch.object(bridge, "hidden_run", side_effect=runner):
|
||
|
|
with self.assertRaises(bridge.BridgeError) as raised:
|
||
|
|
bridge.flash({"session_file": session, "execute": True}, self.events.append)
|
||
|
|
self.assertEqual(raised.exception.code, "flash_failed")
|
||
|
|
self.assertNotIn("write_flash", runner.commands)
|
||
|
|
|
||
|
|
def test_backup_failure_never_writes(self):
|
||
|
|
session = self.preview()["session_file"]
|
||
|
|
runner = SimulatedEsptool(short_backup=True)
|
||
|
|
with patch.object(bridge, "hidden_run", side_effect=runner):
|
||
|
|
with self.assertRaises(bridge.BridgeError):
|
||
|
|
bridge.flash({"session_file": session, "execute": True}, self.events.append)
|
||
|
|
self.assertNotIn("write_flash", runner.commands)
|
||
|
|
|
||
|
|
def test_main_outputs_json_lines_and_redacts_exception(self):
|
||
|
|
session = self.preview()["session_file"]
|
||
|
|
request_file = self.root / "request.json"
|
||
|
|
request_file.write_text(json.dumps({"session_file": session, "execute": True}))
|
||
|
|
output = io.StringIO()
|
||
|
|
with patch.object(bridge.device_flash, "execute_plan", side_effect=RuntimeError(self.key + self.password)):
|
||
|
|
code = bridge.main(["flash", "--request", str(request_file)], output=output)
|
||
|
|
self.assertEqual(code, 2)
|
||
|
|
rows = [json.loads(line) for line in output.getvalue().splitlines()]
|
||
|
|
self.assertEqual(rows[-1]["code"], "flash_failed")
|
||
|
|
self.assertNotIn(self.key, output.getvalue())
|
||
|
|
self.assertNotIn(self.password, output.getvalue())
|
||
|
|
|
||
|
|
def test_ports_enumeration_does_not_open_serial(self):
|
||
|
|
import serial
|
||
|
|
from serial.tools import list_ports
|
||
|
|
port = types.SimpleNamespace(device="COM6", description="USB", hwid="USB VID:PID", serial_number="SERIAL")
|
||
|
|
with patch.object(serial, "Serial") as open_serial, patch.object(list_ports, "comports", return_value=[port]):
|
||
|
|
bridge.enumerate_ports({}, self.events.append)
|
||
|
|
open_serial.assert_not_called()
|
||
|
|
self.assertEqual(self.events[-1]["ports"][0]["serial_number"], "SERIAL")
|
||
|
|
|
||
|
|
def test_windows_child_processes_are_hidden(self):
|
||
|
|
if bridge.os.name != "nt":
|
||
|
|
self.skipTest("Windows-only subprocess flag")
|
||
|
|
with patch.object(bridge.subprocess, "run") as run:
|
||
|
|
bridge.hidden_run(["example"], capture_output=True)
|
||
|
|
self.assertTrue(run.call_args.kwargs["creationflags"] & subprocess.CREATE_NO_WINDOW)
|
||
|
|
|
||
|
|
|
||
|
|
if __name__ == "__main__":
|
||
|
|
unittest.main()
|