From e322a60262e0ecaf13ea2012b57c7c8c9a5f5871 Mon Sep 17 00:00:00 2001 From: Marc Leinen <34336531+MarcLeinenDE@users.noreply.github.com> Date: Mon, 21 Sep 2026 08:24:59 +0200 Subject: [PATCH 1/9] feat: add binary file CGI transport --- src/nr2301/transport.py | 43 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/src/nr2301/transport.py b/src/nr2301/transport.py index d967566..dc58572 100644 --- a/src/nr2301/transport.py +++ b/src/nr2301/transport.py @@ -73,6 +73,49 @@ def multicall( ) return self._decode_json(response, method_id="multicall") + @property + def file_url(self) -> str: + return f"{self.base_url}/file.cgi" + + def file_download( + self, + *, + params: Mapping[str, Any], + timeout: float | None = None, + ) -> bytes: + """Download opaque bytes from the authenticated `/file.cgi` family.""" + + effective_timeout = self.timeout if timeout is None else timeout + response = self._request( + "GET", + self.file_url, + params=dict(params), + timeout=effective_timeout, + ) + return bytes(response.content) + + def file_upload( + self, + data: bytes, + *, + params: Mapping[str, Any], + timeout: float | None = None, + ) -> bytes: + """POST one raw binary chunk to the authenticated `/file.cgi` family.""" + + if not isinstance(data, bytes): + raise TypeError("file upload data must be bytes") + effective_timeout = self.timeout if timeout is None else timeout + response = self._request( + "POST", + self.file_url, + params=dict(params), + data=data, + headers={"Content-Type": "application/octet-stream"}, + timeout=effective_timeout, + ) + return bytes(response.content) + def close(self) -> None: self.session.close() From 66729b04f459e092213f47e4511f5ae36a7d0bca Mon Sep 17 00:00:00 2001 From: Marc Leinen <34336531+MarcLeinenDE@users.noreply.github.com> Date: Mon, 21 Sep 2026 08:25:06 +0200 Subject: [PATCH 2/9] feat: add stock configuration backup and restore --- src/nr2301/namespaces/maintenance.py | 163 +++++++++++++++++++++++++++ 1 file changed, 163 insertions(+) diff --git a/src/nr2301/namespaces/maintenance.py b/src/nr2301/namespaces/maintenance.py index ef926a1..63868ac 100644 --- a/src/nr2301/namespaces/maintenance.py +++ b/src/nr2301/namespaces/maintenance.py @@ -29,7 +29,9 @@ class MaintenanceRecoveryResult(TypedDict, total=False): action_response: dict[str, Any] boot_time_after: int boot_time_before: int + chunk_count: int outage_observed: bool + uploaded_bytes: int class MaintenanceNamespace: @@ -92,6 +94,167 @@ def backup_config( ) return response + def download_config_backup( + self, + *, + timeout: float | None = None, + ) -> bytes: + """Download the current stock-WebUI configuration backup bytes. + + The returned blob is secret-bearing and may contain credentials. The + SDK intentionally does not log, parse or persist it. + """ + + payload = self._client.transport.file_download( + params={ + "Action": "Download", + "file": "backup_config", + "dl": "1", + }, + timeout=timeout, + ) + if not payload: + raise ProtocolError("configuration backup download returned no data") + return payload + + def restore_config_backup( + self, + backup: bytes | bytearray | memoryview, + *, + chunk_size: int = 1024 * 1024, + action_timeout: float = 20.0, + recovery_attempts: int = 120, + recovery_delay: float = 1.0, + recovery_timeout: float = 3.0, + initial_delay: float = 1.0, + ) -> MaintenanceRecoveryResult: + """Restore a trusted configuration backup through the stock file CGI. + + ACIY.3's frontend sends sequential raw `application/octet-stream` + POSTs to `/file.cgi?Action=Upload&file=restore_config`, using 1 MiB + chunks and no multipart wrapper or Content-Range header. + + Configuration data is secret-bearing. The SDK never includes backup + bytes in diagnostics. A response containing `other error` is treated + as failure. The final upload is considered successful only after + management recovers and `boot_time` proves a new boot. + """ + + if not isinstance(backup, (bytes, bytearray, memoryview)): + raise TypeError("backup must be bytes-like") + payload = bytes(backup) + if not payload: + raise ValueError("backup must not be empty") + if len(payload) > 200 * 1024 * 1024: + raise ValueError("backup exceeds the stock frontend 200 MiB limit") + if isinstance(chunk_size, bool) or not isinstance(chunk_size, int): + raise TypeError("chunk_size must be an int") + if chunk_size <= 0: + raise ValueError("chunk_size must be greater than zero") + + self._validate_recovery_args( + action_timeout=action_timeout, + recovery_attempts=recovery_attempts, + recovery_delay=recovery_delay, + recovery_timeout=recovery_timeout, + initial_delay=initial_delay, + ) + before = self._read_boot_time(timeout=recovery_timeout) + params = {"Action": "Upload", "file": "restore_config"} + chunk_count = (len(payload) + chunk_size - 1) // chunk_size + final_action_error: NR2301Error | None = None + + for index in range(chunk_count): + start = index * chunk_size + end = min(len(payload), start + chunk_size) + chunk = payload[start:end] + final_chunk = index + 1 == chunk_count + try: + response = self._client.transport.file_upload( + chunk, + params=params, + timeout=action_timeout, + ) + except TransportError as exc: + if not final_chunk: + raise APIError( + "configuration restore upload failed before final chunk", + method_id="file.cgi/restore_config", + response={ + "chunk_index": index, + "chunk_count": chunk_count, + "uploaded_bytes_before_failure": start, + "error": type(exc).__name__, + }, + ) from exc + final_action_error = exc + break + + if b"other error" in response.lower(): + raise APIError( + "configuration restore was rejected by file.cgi", + method_id="file.cgi/restore_config", + response={ + "chunk_index": index, + "chunk_count": chunk_count, + "uploaded_bytes_before_failure": start, + }, + ) + + started = time.monotonic() + if initial_delay: + time.sleep(initial_delay) + + last_boot: int | None = None + last_error: NR2301Error | None = None + outage_observed = final_action_error is not None + + for attempt in range(recovery_attempts): + try: + current = self._read_boot_time(timeout=recovery_timeout) + last_boot = current + elapsed = time.monotonic() - started + if current < before or ( + outage_observed + and current <= int(elapsed) + 10 + and before <= int(elapsed) + 10 + ): + result: MaintenanceRecoveryResult = { + "boot_time_before": before, + "boot_time_after": current, + "outage_observed": outage_observed, + "uploaded_bytes": len(payload), + "chunk_count": chunk_count, + } + if final_action_error is not None: + result["action_error"] = type(final_action_error).__name__ + return result + except NR2301Error as exc: + outage_observed = True + last_error = exc + last_error = self._try_relogin(last_error) + + if attempt + 1 < recovery_attempts and recovery_delay: + time.sleep(recovery_delay) + + details: dict[str, Any] = { + "boot_time_before": before, + "boot_time_after": last_boot, + "outage_observed": outage_observed, + "uploaded_bytes": len(payload), + "chunk_count": chunk_count, + } + if final_action_error is not None: + details["action_error"] = type(final_action_error).__name__ + if last_error is not None: + details["last_recovery_error"] = type(last_error).__name__ + + raise APIError( + "configuration restore could not be verified by recovery and boot_time reset", + method_id="file.cgi/restore_config", + response=details, + ) + def restart_web_server( self, *, From f7d4a643e345af37f2455624090f3ed19ca827e2 Mon Sep 17 00:00:00 2001 From: Marc Leinen <34336531+MarcLeinenDE@users.noreply.github.com> Date: Mon, 21 Sep 2026 08:25:50 +0200 Subject: [PATCH 3/9] test: support binary and transport-error fake responses --- tests/conftest.py | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 17abc3b..01b7222 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -6,10 +6,22 @@ class FakeResponse: - def __init__(self, payload: Any, *, status_code: int = 200, on_json=None) -> None: + def __init__( + self, + payload: Any = None, + *, + status_code: int = 200, + on_json=None, + content: bytes | None = None, + headers: dict[str, str] | None = None, + ) -> None: self._payload = payload self.status_code = status_code self._on_json = on_json + if content is None: + content = payload if isinstance(payload, bytes) else b"" + self.content = bytes(content) + self.headers = dict(headers or {}) def raise_for_status(self) -> None: if self.status_code >= 400: @@ -24,7 +36,7 @@ def json(self) -> Any: class FakeSession: - def __init__(self, responses: list[FakeResponse]) -> None: + def __init__(self, responses: list[Any]) -> None: self.responses = list(responses) self.calls: list[tuple[str, str, dict[str, Any]]] = [] self.cookies = requests.cookies.RequestsCookieJar() @@ -34,7 +46,10 @@ def request(self, method: str, url: str, **kwargs: Any) -> FakeResponse: self.calls.append((method, url, kwargs)) if not self.responses: raise AssertionError("unexpected request") - return self.responses.pop(0) + response = self.responses.pop(0) + if isinstance(response, Exception): + raise response + return response def close(self) -> None: self.closed = True From e33a521dc29591a2d380bec8ae506fafdeeb7ca4 Mon Sep 17 00:00:00 2001 From: Marc Leinen <34336531+MarcLeinenDE@users.noreply.github.com> Date: Mon, 21 Sep 2026 08:25:59 +0200 Subject: [PATCH 4/9] test: cover binary file CGI transport --- tests/test_file_transport.py | 69 ++++++++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 tests/test_file_transport.py diff --git a/tests/test_file_transport.py b/tests/test_file_transport.py new file mode 100644 index 0000000..fbae71b --- /dev/null +++ b/tests/test_file_transport.py @@ -0,0 +1,69 @@ +import requests + +from nr2301.transport import HTTPTransport + +from conftest import FakeResponse, FakeSession + + +def test_file_download_uses_authenticated_file_cgi_get_and_returns_bytes(): + session = FakeSession([FakeResponse(content=b"CONFIG-BYTES")]) + session.cookies.set("CGISID", "session-123") + transport = HTTPTransport("http://router.test", session=session) + + result = transport.file_download( + params={"Action": "Download", "file": "backup_config", "dl": "1"}, + timeout=7.5, + ) + + assert result == b"CONFIG-BYTES" + assert len(session.calls) == 1 + method, url, kwargs = session.calls[0] + assert method == "GET" + assert url == "http://router.test/file.cgi" + assert kwargs["params"] == { + "Action": "Download", + "file": "backup_config", + "dl": "1", + } + assert kwargs["timeout"] == 7.5 + assert "json" not in kwargs + assert "data" not in kwargs + + +def test_file_upload_posts_raw_octet_stream_without_multipart_or_range_headers(): + session = FakeSession([FakeResponse(content=b"OK")]) + transport = HTTPTransport("http://router.test", session=session) + + result = transport.file_upload( + b"\x00\x01\xffCONFIG", + params={"Action": "Upload", "file": "restore_config"}, + timeout=9, + ) + + assert result == b"OK" + method, url, kwargs = session.calls[0] + assert method == "POST" + assert url == "http://router.test/file.cgi" + assert kwargs["params"] == { + "Action": "Upload", + "file": "restore_config", + } + assert kwargs["data"] == b"\x00\x01\xffCONFIG" + assert kwargs["headers"] == {"Content-Type": "application/octet-stream"} + assert "files" not in kwargs + assert "json" not in kwargs + assert "Content-Range" not in kwargs["headers"] + + +def test_file_transport_wraps_requests_errors(): + session = FakeSession([requests.ConnectionError("gone")]) + transport = HTTPTransport("http://router.test", session=session) + + from nr2301 import TransportError + + try: + transport.file_download(params={"Action": "Download"}) + except TransportError: + pass + else: + raise AssertionError("TransportError not raised") From ffbdb54033945014eb7fca496ab44d112ad16edc Mon Sep 17 00:00:00 2001 From: Marc Leinen <34336531+MarcLeinenDE@users.noreply.github.com> Date: Mon, 21 Sep 2026 08:26:40 +0200 Subject: [PATCH 5/9] refactor: name stock configuration restore limits --- src/nr2301/namespaces/maintenance.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/nr2301/namespaces/maintenance.py b/src/nr2301/namespaces/maintenance.py index 63868ac..d781c72 100644 --- a/src/nr2301/namespaces/maintenance.py +++ b/src/nr2301/namespaces/maintenance.py @@ -13,6 +13,8 @@ _TIME_RE = re.compile(r"^([0-9]{1,2}):([0-9]{1,2})$") +_CONFIG_RESTORE_CHUNK_BYTES = 1024 * 1024 +_CONFIG_RESTORE_MAX_BYTES = 200 * 1024 * 1024 class TimedRebootSettings(TypedDict, total=False): @@ -121,7 +123,7 @@ def restore_config_backup( self, backup: bytes | bytearray | memoryview, *, - chunk_size: int = 1024 * 1024, + chunk_size: int = _CONFIG_RESTORE_CHUNK_BYTES, action_timeout: float = 20.0, recovery_attempts: int = 120, recovery_delay: float = 1.0, @@ -145,7 +147,7 @@ def restore_config_backup( payload = bytes(backup) if not payload: raise ValueError("backup must not be empty") - if len(payload) > 200 * 1024 * 1024: + if len(payload) > _CONFIG_RESTORE_MAX_BYTES: raise ValueError("backup exceeds the stock frontend 200 MiB limit") if isinstance(chunk_size, bool) or not isinstance(chunk_size, int): raise TypeError("chunk_size must be an int") From 09b95caec77fe2b4c9594828d8b979798c97df7a Mon Sep 17 00:00:00 2001 From: Marc Leinen <34336531+MarcLeinenDE@users.noreply.github.com> Date: Mon, 21 Sep 2026 08:26:47 +0200 Subject: [PATCH 6/9] test: cover configuration backup and restore semantics --- tests/test_maintenance_namespace.py | 154 ++++++++++++++++++++++++++++ 1 file changed, 154 insertions(+) diff --git a/tests/test_maintenance_namespace.py b/tests/test_maintenance_namespace.py index 1ee24fb..64c78f8 100644 --- a/tests/test_maintenance_namespace.py +++ b/tests/test_maintenance_namespace.py @@ -261,3 +261,157 @@ def test_disruptive_maintenance_rejects_invalid_recovery_options(method_name, kw getattr(client.maintenance, method_name)(**kwargs) assert session.calls == [] + + +def test_download_config_backup_uses_stock_file_cgi_contract(): + client, session = authenticated_client( + FakeResponse(content=b"opaque-secret-backup") + ) + + result = client.maintenance.download_config_backup(timeout=12) + + assert result == b"opaque-secret-backup" + method, url, kwargs = session.calls[0] + assert method == "GET" + assert url == "http://zyxel.home/file.cgi" + assert kwargs["params"] == { + "Action": "Download", + "file": "backup_config", + "dl": "1", + } + assert kwargs["timeout"] == 12 + + +def test_download_config_backup_rejects_empty_file(): + client, _ = authenticated_client(FakeResponse(content=b"")) + + with pytest.raises(Exception, match="returned no data"): + client.maintenance.download_config_backup() + + +def test_restore_config_backup_uploads_sequential_raw_chunks_and_verifies_reboot(): + client, session = authenticated_client( + {"boot_time": 5000, "result": 0}, + FakeResponse(content=b"chunk-1-ok"), + FakeResponse(status_code=503), + {"boot_time": 7, "result": 0}, + ) + + result = client.maintenance.restore_config_backup( + b"ABCDEFGH", + chunk_size=4, + action_timeout=6, + recovery_attempts=1, + recovery_delay=0, + recovery_timeout=2, + initial_delay=0, + ) + + assert result["boot_time_before"] == 5000 + assert result["boot_time_after"] == 7 + assert result["uploaded_bytes"] == 8 + assert result["chunk_count"] == 2 + assert result["outage_observed"] is True + assert result["action_error"] == "TransportError" + + assert [call[0] for call in session.calls] == ["GET", "POST", "POST", "GET"] + + first_upload = session.calls[1] + second_upload = session.calls[2] + for call in (first_upload, second_upload): + _, url, kwargs = call + assert url == "http://zyxel.home/file.cgi" + assert kwargs["params"] == { + "Action": "Upload", + "file": "restore_config", + } + assert kwargs["headers"] == { + "Content-Type": "application/octet-stream" + } + assert "files" not in kwargs + assert "json" not in kwargs + assert "Content-Range" not in kwargs["headers"] + + assert first_upload[2]["data"] == b"ABCD" + assert second_upload[2]["data"] == b"EFGH" + + +def test_restore_config_backup_accepts_final_http_response_but_still_requires_reboot(): + client, _ = authenticated_client( + {"boot_time": 5000, "result": 0}, + FakeResponse(content=b"ok"), + {"boot_time": 6, "result": 0}, + ) + + result = client.maintenance.restore_config_backup( + b"ABCD", + chunk_size=4, + recovery_attempts=1, + recovery_delay=0, + initial_delay=0, + ) + + assert result["boot_time_after"] == 6 + assert result["outage_observed"] is False + + +def test_restore_config_backup_rejects_frontend_other_error_without_echoing_backup(): + client, _ = authenticated_client( + {"boot_time": 5000, "result": 0}, + FakeResponse(content=b'{"system_err":" other error"}'), + ) + + secret = b"DO-NOT-ECHO-CONFIG-BYTES" + with pytest.raises(APIError) as exc_info: + client.maintenance.restore_config_backup( + secret, + chunk_size=len(secret), + recovery_attempts=1, + recovery_delay=0, + initial_delay=0, + ) + + assert exc_info.value.method_id == "file.cgi/restore_config" + assert secret.decode() not in repr(exc_info.value.response) + + +def test_restore_config_backup_requires_boot_time_reset(): + client, _ = authenticated_client( + {"boot_time": 5000, "result": 0}, + FakeResponse(content=b"ok"), + {"boot_time": 5001, "result": 0}, + ) + + with pytest.raises(APIError) as exc_info: + client.maintenance.restore_config_backup( + b"ABCD", + chunk_size=4, + recovery_attempts=1, + recovery_delay=0, + initial_delay=0, + ) + + assert exc_info.value.method_id == "file.cgi/restore_config" + assert exc_info.value.response["boot_time_before"] == 5000 + assert exc_info.value.response["boot_time_after"] == 5001 + + +def test_restore_config_backup_validates_input_before_network_access(monkeypatch): + import nr2301.namespaces.maintenance as maintenance_module + + client, session = authenticated_client() + + with pytest.raises(ValueError, match="must not be empty"): + client.maintenance.restore_config_backup(b"") + + with pytest.raises(TypeError, match="bytes-like"): + client.maintenance.restore_config_backup("nope") # type: ignore[arg-type] + + with pytest.raises(ValueError, match="chunk_size"): + client.maintenance.restore_config_backup(b"x", chunk_size=0) + + monkeypatch.setattr(maintenance_module, "_CONFIG_RESTORE_MAX_BYTES", 3) + with pytest.raises(ValueError, match="200 MiB"): + client.maintenance.restore_config_backup(b"four") + + assert session.calls == [] From c88c38ce8891d97e605881965a401b9a04327106 Mon Sep 17 00:00:00 2001 From: Marc Leinen <34336531+MarcLeinenDE@users.noreply.github.com> Date: Mon, 21 Sep 2026 08:28:30 +0200 Subject: [PATCH 7/9] test: add physical configuration backup restore lifecycle --- .../integration/test_config_backup_restore.py | 112 ++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 tests/integration/test_config_backup_restore.py diff --git a/tests/integration/test_config_backup_restore.py b/tests/integration/test_config_backup_restore.py new file mode 100644 index 0000000..7b710aa --- /dev/null +++ b/tests/integration/test_config_backup_restore.py @@ -0,0 +1,112 @@ +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import annotations + +import hashlib +import os + +import pytest + +from nr2301 import NR2301Client + + +if os.environ.get("NR2301_DESTRUCTIVE_INTEGRATION") != "1": + pytest.skip( + "physical configuration restore tests require " + "NR2301_DESTRUCTIVE_INTEGRATION=1", + allow_module_level=True, + ) + + +pytestmark = pytest.mark.integration + + +@pytest.fixture(scope="module") +def router(): + password = os.environ.get("NR2301_PASSWORD") + if not password: + pytest.skip("NR2301_PASSWORD is required for physical-router integration tests") + + with NR2301Client( + os.environ.get("NR2301_URL", "http://zyxel.home"), + username=os.environ.get("NR2301_USERNAME", "admin"), + password=password, + timeout=15.0, + ) as client: + client.login() + yield client + + +def _stable_config_snapshot(router): + """Read only stable configuration state; exclude dynamic runtime/radio data.""" + + return { + "ui_language": router.device.ui_language(timeout=5.0), + "work_mode": router.device.work_mode(timeout=5.0), + "sleep_wait_time": router.device.sleep_wait_time(timeout=5.0), + "timed_reboot": router.maintenance.timed_reboot(timeout=5.0), + "dns": router.lan.dns(timeout=5.0), + "upnp": router.firewall.upnp_state(timeout=5.0), + "vpn_passthrough": router.firewall.vpn_passthrough(timeout=5.0), + "ping_from_wan": router.firewall.ping_from_wan(timeout=5.0), + "admin_from_wan": router.firewall.admin_from_wan(timeout=5.0), + } + + +def _sha256(data: bytes) -> str: + return hashlib.sha256(data).hexdigest() + + +def test_download_restore_same_backup_and_verify_configuration(router): + before_state = _stable_config_snapshot(router) + + backup = router.maintenance.download_config_backup(timeout=30.0) + before_hash = _sha256(backup) + + print( + "CONFIG_BACKUP" + f" size={len(backup)}" + f" sha256={before_hash}", + flush=True, + ) + + result = router.maintenance.restore_config_backup( + backup, + action_timeout=30.0, + recovery_attempts=150, + recovery_delay=1.0, + recovery_timeout=4.0, + initial_delay=2.0, + ) + + print( + "CONFIG_RESTORE" + f" boot_before={result.get('boot_time_before')}" + f" boot_after={result.get('boot_time_after')}" + f" outage_observed={result.get('outage_observed')}" + f" action_error={result.get('action_error')!r}" + f" uploaded_bytes={result.get('uploaded_bytes')}" + f" chunks={result.get('chunk_count')}", + flush=True, + ) + + assert result["boot_time_after"] < result["boot_time_before"] + assert result["uploaded_bytes"] == len(backup) + assert result["chunk_count"] >= 1 + + after_state = _stable_config_snapshot(router) + assert after_state == before_state + + # Reconfirm that the stock backup download endpoint remains usable after + # restore/reboot. The backup file itself is never printed or persisted. + backup_after = router.maintenance.download_config_backup(timeout=30.0) + after_hash = _sha256(backup_after) + + print( + "CONFIG_BACKUP_AFTER_RESTORE" + f" size={len(backup_after)}" + f" sha256={after_hash}", + flush=True, + ) + + assert backup_after From 49b9759dce3b876a313c2cab3288bbe974e5ed21 Mon Sep 17 00:00:00 2001 From: Marc Leinen <34336531+MarcLeinenDE@users.noreply.github.com> Date: Mon, 21 Sep 2026 08:49:07 +0200 Subject: [PATCH 8/9] test: wait for all configuration APIs after restore --- .../integration/test_config_backup_restore.py | 52 +++++++++++++++++-- 1 file changed, 49 insertions(+), 3 deletions(-) diff --git a/tests/integration/test_config_backup_restore.py b/tests/integration/test_config_backup_restore.py index 7b710aa..b743ce5 100644 --- a/tests/integration/test_config_backup_restore.py +++ b/tests/integration/test_config_backup_restore.py @@ -4,10 +4,11 @@ import hashlib import os +import time import pytest -from nr2301 import NR2301Client +from nr2301 import NR2301Client, NR2301Error if os.environ.get("NR2301_DESTRUCTIVE_INTEGRATION") != "1": @@ -53,12 +54,53 @@ def _stable_config_snapshot(router): } +def _stable_config_snapshot_with_recovery( + router, + *, + attempts=60, + delay=1.0, +): + """Wait until all compared configuration namespaces are API-ready.""" + + last_error = None + for attempt in range(1, attempts + 1): + try: + snapshot = _stable_config_snapshot(router) + if attempt > 1: + print( + "CONFIG_API_READY" + f" attempt={attempt}" + f" previous_error={type(last_error).__name__ if last_error else None}", + flush=True, + ) + return snapshot + except NR2301Error as exc: + last_error = exc + print( + "CONFIG_API_NOT_READY" + f" attempt={attempt}" + f" error={type(exc).__name__}", + flush=True, + ) + try: + router.login() + except NR2301Error: + pass + if attempt < attempts: + time.sleep(delay) + + raise AssertionError( + "configuration APIs did not become jointly ready after restore: " + f"{type(last_error).__name__ if last_error else 'unknown'}" + ) + + def _sha256(data: bytes) -> str: return hashlib.sha256(data).hexdigest() def test_download_restore_same_backup_and_verify_configuration(router): - before_state = _stable_config_snapshot(router) + before_state = _stable_config_snapshot_with_recovery(router) backup = router.maintenance.download_config_backup(timeout=30.0) before_hash = _sha256(backup) @@ -94,7 +136,11 @@ def test_download_restore_same_backup_and_verify_configuration(router): assert result["uploaded_bytes"] == len(backup) assert result["chunk_count"] >= 1 - after_state = _stable_config_snapshot(router) + after_state = _stable_config_snapshot_with_recovery( + router, + attempts=90, + delay=1.0, + ) assert after_state == before_state # Reconfirm that the stock backup download endpoint remains usable after From 95c9c212a4a139ea28bfef3d54a75e9edb3cbceb Mon Sep 17 00:00:00 2001 From: Marc Leinen <34336531+MarcLeinenDE@users.noreply.github.com> Date: Mon, 21 Sep 2026 08:50:17 +0200 Subject: [PATCH 9/9] test: identify delayed post-restore configuration reads --- .../integration/test_config_backup_restore.py | 30 ++++++++++++------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/tests/integration/test_config_backup_restore.py b/tests/integration/test_config_backup_restore.py index b743ce5..fd989e1 100644 --- a/tests/integration/test_config_backup_restore.py +++ b/tests/integration/test_config_backup_restore.py @@ -41,18 +41,27 @@ def router(): def _stable_config_snapshot(router): """Read only stable configuration state; exclude dynamic runtime/radio data.""" - return { - "ui_language": router.device.ui_language(timeout=5.0), - "work_mode": router.device.work_mode(timeout=5.0), - "sleep_wait_time": router.device.sleep_wait_time(timeout=5.0), - "timed_reboot": router.maintenance.timed_reboot(timeout=5.0), - "dns": router.lan.dns(timeout=5.0), - "upnp": router.firewall.upnp_state(timeout=5.0), - "vpn_passthrough": router.firewall.vpn_passthrough(timeout=5.0), - "ping_from_wan": router.firewall.ping_from_wan(timeout=5.0), - "admin_from_wan": router.firewall.admin_from_wan(timeout=5.0), + readers = { + "ui_language": lambda: router.device.ui_language(timeout=5.0), + "work_mode": lambda: router.device.work_mode(timeout=5.0), + "sleep_wait_time": lambda: router.device.sleep_wait_time(timeout=5.0), + "timed_reboot": lambda: router.maintenance.timed_reboot(timeout=5.0), + "dns": lambda: router.lan.dns(timeout=5.0), + "upnp": lambda: router.firewall.upnp_state(timeout=5.0), + "vpn_passthrough": lambda: router.firewall.vpn_passthrough(timeout=5.0), + "ping_from_wan": lambda: router.firewall.ping_from_wan(timeout=5.0), + "admin_from_wan": lambda: router.firewall.admin_from_wan(timeout=5.0), } + snapshot = {} + for name, reader in readers.items(): + try: + snapshot[name] = reader() + except NR2301Error as exc: + setattr(exc, "_nr2301_snapshot_field", name) + raise + return snapshot + def _stable_config_snapshot_with_recovery( router, @@ -79,6 +88,7 @@ def _stable_config_snapshot_with_recovery( print( "CONFIG_API_NOT_READY" f" attempt={attempt}" + f" field={getattr(exc, '_nr2301_snapshot_field', 'unknown')}" f" error={type(exc).__name__}", flush=True, )