diff --git a/src/ConfigFile.cpp b/src/ConfigFile.cpp index 2d606e4..e7854ca 100644 --- a/src/ConfigFile.cpp +++ b/src/ConfigFile.cpp @@ -608,7 +608,9 @@ bool ShadeConfigFile::restoreFile(SomfyShadeController *s, const char *filename, else { this->file.seek(this->file.position() + this->header.netRecordSize, SeekSet); } - if(opts.shades) s->commit(); + // The repeaters are saved in shades.cfg too. Left to somfy.loop(), they + // race the reboot. + if(opts.shades || opts.repeaters) s->commit(); if(opts.transceiver) { // Read into a copy so the radio task never transmits on a half restored config. diff --git a/test/e2e/test_restore.py b/test/e2e/test_restore.py index bfe0445..e4b1e25 100644 --- a/test/e2e/test_restore.py +++ b/test/e2e/test_restore.py @@ -8,17 +8,13 @@ import pytest from proto_client import run_sync from test_commands import is_ok, op, synced_model -from test_routes import http, upload +from test_routes import auth, http, restore_without_a_file, upload from test_settings import desc, settings, uptime, wait_for_restart # Saved for the round trip and put back by the restore. .invalid never resolves. CHANGED_NTP_SERVER = "restore.e2e.invalid" -def auth(token): - return {"apikey": token} if token else {} - - def test_restore_reads_the_options_sent_as_a_form_field(host, token): """The options come as the form field "data", next to the file. @@ -32,6 +28,15 @@ def test_restore_reads_the_options_sent_as_a_form_field(host, token): assert json.loads(body)["desc"] == "JSON parse error" +def test_a_refused_restore_leaves_no_upload_behind(host, token): + """A later /restore without a file must not find the refused one's upload.""" + status, _, _ = upload(host, "/restore", auth(token), fields={"data": "{"}) + assert status == 500 + status, reason = restore_without_a_file(host, token) + assert status == 400 + assert "didn't complete" in reason, reason + + def test_a_restore_brings_back_the_parts_that_were_ticked(request, host, token, connect, schema): if not request.config.getoption("--reboot"): pytest.skip("restores a backup and reboots the device: pass --reboot") @@ -48,7 +53,7 @@ def test_a_restore_brings_back_the_parts_that_were_ticked(request, host, token, assert is_ok(schema, reply), desc(schema, reply) try: options = json.dumps({"shades": False, "settings": True}) - status, _, body = upload(host, "/restore", auth(token), content=backup, fields={"data": options}) + status, _, body = upload(host, "/restore", auth(token), file=backup, fields={"data": options}) assert status == 200, body wait_for_restart(host, token, schema, uptime_before) finally: diff --git a/test/e2e/test_routes.py b/test/e2e/test_routes.py index c3e6e36..8bf4859 100644 --- a/test/e2e/test_routes.py +++ b/test/e2e/test_routes.py @@ -38,18 +38,23 @@ def http(host, path, method="GET", body=None, headers=None): return e.code, e.headers, e.read() -def upload(host, path, headers=None, content=b"\0" * 256, fields=None): +def auth(token): + """The header that carries the token, if there is one.""" + return {"apikey": token} if token else {} + + +def upload(host, path, headers=None, file=b"\0" * 256, fields=None): """Posts a form as the web UI does: the file, then the fields. The file is by default a small one that isn't a backup or an image, and - content=None leaves it out. + file=None leaves it out. """ boundary = "e2e-upload" parts = [] - if content is not None: + if file is not None: parts.append( f"--{boundary}\r\nContent-Disposition: form-data; name=\"file\"; filename=\"e2e.bin\"\r\n" - "Content-Type: application/octet-stream\r\n\r\n".encode() + content) + "Content-Type: application/octet-stream\r\n\r\n".encode() + file) for name, value in (fields or {}).items(): parts.append(f"--{boundary}\r\nContent-Disposition: form-data; name=\"{name}\"\r\n\r\n{value}".encode()) body = b"\r\n".join(parts) + f"\r\n--{boundary}--\r\n".encode() @@ -95,7 +100,7 @@ def test_secured_refuses_a_web_asset_name_below_a_route(host, secured, method, p def test_secured_accepts_the_token_on_a_route(host, secured, token): if not secured: pytest.skip("the device's security is None") - status, _, _ = http(host, "/backup", headers={"apikey": token}) + status, _, _ = http(host, "/backup", headers=auth(token)) assert status == 200 @@ -109,7 +114,7 @@ def test_secured_refuses_an_upload_without_a_token(host, secured, path): def restore_without_a_file(host, token): - status, _, body = upload(host, "/restore", {"apikey": token}, content=None, fields={"e2e": "1"}) + status, _, body = upload(host, "/restore", auth(token), file=None, fields={"e2e": "1"}) return status, json.loads(body or b"{}").get("desc", "")