fix(device-test): contain tool processes and add reviewed preview
This commit is contained in:
1 parent
c98d7527a0
commit
7d31e09cb7
59 files changed
+23722
No files matched your search
@@ -0,0 +1,311 @@
|
||||
"""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()
|
||||
Reference in new issue
Block a user