Files
Artemis-Plugins/plugins/artemis-device-test/skill/tests/test_v1_upgrade.py
T

312 lines
14 KiB
Python

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