"""Run the actual legacy V1 sender against offline, fragmented socket fixtures.""" from __future__ import annotations import contextlib import hashlib import importlib.util import io import json from pathlib import Path import struct import tempfile import unittest from unittest.mock import patch ROOT = Path(__file__).resolve().parents[1] SN = "SYNTHETIC-V1-UPGRADE-SN-0001" MODEL = "SYNTHETIC-V1-MODEL" HOST = "192.0.2.10" CMD = 0x5A0E2170 INFO = 0x5A0E2104 def packet(command=CMD, body=None): data = json.dumps({"code": 0} if body is None else body).encode("utf-8") return struct.pack("!II", command, len(data)) + data class Clock: value = 0.0 def __call__(self): return self.value class UploadSocket: def __init__(self, reply=None, stop_after=None, clock=None, step=0, on_send=None, send_error=None, recv_error=None): self.pending = bytearray(packet() if reply is None else reply) self.sent = bytearray() self.timeouts = [] self.closed = False self.stop_after, self.clock, self.step, self.on_send = stop_after, clock, step, on_send self.send_error, self.recv_error = send_error, recv_error def __enter__(self): return self def __exit__(self, *_args): self.closed = True def settimeout(self, value): self.timeouts.append(value) def send(self, data): if self.send_error: raise self.send_error if self.on_send: action, self.on_send = self.on_send, None action() if self.clock: self.clock.value += self.step count = min(len(data), 7) if self.stop_after is not None: count = min(count, max(0, self.stop_after - len(self.sent))) self.sent.extend(data[:count]) return count def recv(self, count): if self.recv_error: raise self.recv_error if self.clock: self.clock.value += self.step count = min(count, 3, len(self.pending)) chunk = bytes(self.pending[:count]) del self.pending[:count] return chunk class InfoSocket(UploadSocket): def __init__(self, **changes): body = dict(code=0, sn=SN, dev_type=MODEL, firmware="OLD", dev_ver="OLD-BUILD") body.update(changes) super().__init__(reply=packet(INFO, body)) def sendall(self, data): self.sent.extend(data) class V1UpgradeTests(unittest.TestCase): def setUp(self): spec = importlib.util.spec_from_file_location("v1_upgrade_test_adapter", ROOT / "scripts" / "device_adapter.py") self.adapter = importlib.util.module_from_spec(spec) spec.loader.exec_module(self.adapter) self.v1 = self.adapter.v1 self.logger = self.adapter.EventCollector() self.temp = tempfile.TemporaryDirectory() self.addCleanup(self.temp.cleanup) self.rom = Path(self.temp.name) / "fixture.bin" self.content = b"abc" self.rom.write_bytes(self.content) self.sha = hashlib.sha256(self.content).hexdigest() def transfer(self, sock=None, **changes): sock = sock or UploadSocket() options = dict(host=HOST, rom_path=self.rom, rom_size=len(self.content), connect_timeout=5, stall_timeout=15, total_timeout=60, chunk_size=2, logger=self.logger, expected_sha256=self.sha) options.update(changes) with patch.object(self.v1.socket, "create_connection", return_value=sock) as connect: result = self.v1.transfer_rom(**options) self.assertEqual(connect.call_count, 1) self.assertEqual(connect.call_args.args[0], (HOST, 4941)) self.assertTrue(sock.closed) return result, sock def args(self): return ["upgrade", "--protocol", "v1", "--host", HOST, "--expected-sn", SN, "--expected-model", MODEL, "--rom", str(self.rom), "--target-version", "NEW", "--version-field", "outer.version", "--execute", "--rom-sha256", self.sha] def cli(self, args=None, info=None, upload=None): info, upload = info or InfoSocket(), upload or UploadSocket() connections = [] def connect(address, timeout): connections.append(address) self.assertEqual(address, (HOST, 4941)) return info if len(connections) == 1 else upload output = io.StringIO() with patch.object(self.v1.socket, "create_connection", side_effect=connect), \ patch.object(self.adapter.protocol, "query_device_info", side_effect=AssertionError("No V2 query")), \ patch.object(self.adapter.protocol, "transfer_rom", side_effect=AssertionError("No V2 transfer")), \ contextlib.redirect_stdout(output): code = self.adapter.main(self.args() if args is None else args) return code, json.loads(output.getvalue()), connections, upload def test_v1_original_changed_on_connect_sends_approved_snapshot(self): for replacement in (b"xyz", b"x", b"longer"): self.rom.write_bytes(self.content) info, upload = InfoSocket(), UploadSocket() count = 0 def connect(address, timeout): nonlocal count count += 1 if count == 1: return info self.rom.write_bytes(replacement) return upload output = io.StringIO() with patch.object(self.v1.socket, "create_connection", side_effect=connect), \ contextlib.redirect_stdout(output): code = self.adapter.main(self.args()) self.assertEqual(code, 0, output.getvalue()) self.assertEqual(bytes(upload.sent)[40:], self.content) def test_known_md5_and_exact_big_endian_frame_with_fragmented_io(self): result, sock = self.transfer(UploadSocket(packet(body={"code": 0, "desc": "successed", "other": "discard"}))) expected = bytes.fromhex("5a0e217000000023") + b"900150983cd24fb0d6963f7d28e17f72" + b"abc" self.assertEqual(bytes(sock.sent), expected) self.assertEqual(result["bytes_sent"], 3) self.assertEqual(result["wire_bytes_sent"], 43) self.assertTrue(result["acknowledged"]) self.assertEqual(result["response_code"], 0) self.assertNotIn("other", result["response"]) self.assertNotIn("discard", json.dumps(self.logger.events)) def test_invalid_ack_frames_fail_and_close_without_reconnect(self): replies = [b"", packet()[:-1], packet(CMD + 1), struct.pack("!II", CMD, 0), struct.pack("!II", CMD, self.v1.MAX_JSON_SIZE + 1), struct.pack("!II", CMD, 1) + b"{", packet(body=[]), packet(body={}), packet(body={"code": False}), packet(body={"code": "0"}), packet(body={"code": 1}), packet(body={"code": 0, "desc": []})] for reply in replies: with self.subTest(reply=reply[:20]): sock = UploadSocket(reply) with patch.object(self.v1.socket, "create_connection", return_value=sock) as connect, \ self.assertRaises(self.v1.ProtocolError): self.v1.transfer_rom(HOST, self.rom, 3, 5, 15, 60, 2, self.logger, self.sha) self.assertEqual(connect.call_count, 1) self.assertTrue(sock.closed) def test_size_and_digest_mismatch_stop_before_network(self): for changes in [{"rom_size": 4}, {"rom_size": 2}, {"rom_size": 0}, {"rom_size": 0xFFFFFFFF}, {"expected_sha256": "0" * 64}, {"expected_sha256": "bad"}]: with self.subTest(changes=changes), \ patch.object(self.v1.socket, "create_connection", side_effect=AssertionError("No network")), \ self.assertRaises(self.v1.ProtocolError): options = dict(host=HOST, rom_path=self.rom, rom_size=3, connect_timeout=5, stall_timeout=15, total_timeout=60, chunk_size=2, logger=self.logger, expected_sha256=self.sha) options.update(changes) self.v1.transfer_rom(**options) def test_stalled_partial_write_is_not_acknowledged_or_retried(self): self.content = b"x" * 100 self.rom.write_bytes(self.content) self.sha = hashlib.sha256(self.content).hexdigest() sock = UploadSocket(stop_after=49) with self.assertRaises(self.v1.ProtocolError): self.transfer(sock) self.assertTrue(sock.closed) error = self.logger.events[-1] self.assertEqual(error["bytes_sent"], 9) self.assertEqual(error["wire_bytes_sent"], 49) self.assertFalse(error["automatic_retry"]) def test_total_deadline_is_not_reset_by_partial_write_progress(self): clock = Clock() sock = UploadSocket(clock=clock, step=0.3) with patch.object(self.v1.time, "monotonic", side_effect=clock), self.assertRaises(TimeoutError): self.transfer(sock, total_timeout=1) self.assertTrue(sock.closed) self.assertLess(len(sock.sent), 43) self.assertLessEqual(max(sock.timeouts), 1) def test_fragmented_ack_also_obeys_total_deadline(self): clock = Clock() sock = UploadSocket(clock=clock, step=0.1) with patch.object(self.v1.time, "monotonic", side_effect=clock), self.assertRaises(TimeoutError): self.transfer(sock, total_timeout=1.05) self.assertEqual(len(sock.sent), 43) self.assertTrue(sock.closed) def test_socket_stall_and_connect_timeouts_are_bounded_and_not_retried(self): for sock in [UploadSocket(send_error=TimeoutError("write stalled")), UploadSocket(recv_error=TimeoutError("reply stalled"))]: with self.subTest(stage=bool(sock.send_error)), self.assertRaises(TimeoutError): self.transfer(sock, connect_timeout=2, stall_timeout=0.25) self.assertTrue(sock.closed) self.assertTrue(all(0 < value <= 0.25 for value in sock.timeouts)) def test_modified_open_rom_during_upload_cannot_report_success(self): sock = UploadSocket(on_send=lambda: self.rom.write_bytes(b"xyz")) with self.assertRaisesRegex(self.v1.ProtocolError, "changed during upload"): self.transfer(sock) self.assertTrue(sock.closed) self.assertFalse(any(e["event"] == "v1_upgrade_acknowledged" for e in self.logger.events)) def test_execute_routes_real_v1_sender_and_requires_independent_acceptance(self): code, result, connections, sock = self.cli() self.assertEqual(code, 0) self.assertEqual(connections, [(HOST, 4941), (HOST, 4941)]) self.assertEqual(result["status"], "PROTOCOL_SUCCESS_POSTCHECK_REQUIRED") self.assertTrue(result["write_executed"]) self.assertTrue(result["protocol_success"]) self.assertFalse(result["firmware_verified"]) self.assertTrue(result["postcheck_required"]) self.assertFalse(result["selected_version_field_available"]) self.assertEqual(bytes(sock.sent[40:]), b"abc") def test_bad_digest_and_non_numeric_target_block_before_query(self): for flag, value in [("--rom-sha256", "0" * 64), ("--host", "synthetic.invalid")]: args = self.args() args[args.index(flag) + 1] = value code, result, connections, _ = self.cli(args) self.assertEqual(code, 1) self.assertEqual(connections, []) self.assertFalse(result["write_may_have_started"]) def test_exact_sn_and_model_mismatch_stop_after_only_info(self): for changes in [{"sn": "0001"}, {"dev_type": "WRONG"}]: code, result, connections, sock = self.cli(info=InfoSocket(**changes)) self.assertEqual(code, 1) self.assertEqual(connections, [(HOST, 4941)]) self.assertFalse(result["write_may_have_started"]) self.assertFalse(sock.sent) def test_rom_is_rechecked_after_network_preflight_before_write(self): info = InfoSocket() original = info.recv def receive(count): data = original(count) if not info.pending: self.rom.write_bytes(b"xyz") return data info.recv = receive code, result, connections, sock = self.cli(info=info) self.assertEqual(code, 1) self.assertEqual(connections, [(HOST, 4941)]) self.assertFalse(result["write_may_have_started"]) self.assertFalse(sock.sent) def test_transfer_failure_records_uncertain_result_without_retry(self): code, result, connections, sock = self.cli(upload=UploadSocket(stop_after=41)) self.assertEqual(code, 1) self.assertEqual(len(connections), 2) self.assertTrue(result["write_may_have_started"]) self.assertTrue(result["postcheck_required"]) self.assertFalse(result["automatic_retry"]) self.assertTrue(sock.closed) def test_success_code_cannot_override_incomplete_upload_or_bad_ack(self): base = {"acknowledged": True, "response_command": CMD, "response_code": 0, "bytes_sent": 3} for changes in [{"bytes_sent": 0}, {"bytes_sent": 2}, {"bytes_sent": True}, {"response_code": False}, {"response_command": INFO}, {"acknowledged": False}]: with self.subTest(changes=changes), \ patch.object(self.v1, "transfer_rom", return_value=dict(base, **changes)) as transfer: code, result, connections, _ = self.cli() self.assertEqual(code, 1) self.assertEqual(result["status"], "ERROR") self.assertFalse(result["firmware_verified"]) self.assertEqual(transfer.call_count, 1) self.assertEqual(len(connections), 1) if __name__ == "__main__": unittest.main()