Files
ninlight-w600/tools/station/tests/test_server.py
T
Michael Sauer b41ed51018 feat(station): Migrationsroute mit Horch- und Freigabephase
server.py bedient jetzt zwei Modi ueber denselben Dienst: --mode/--mode-file/--control
legen fest, ob ninlight_<target>_ota.img (Rettung) oder qcx_firmware_<pid>_..._ota_light.img
(Migration alter Klyqa-Lampen, via products.parse_request) ausgeliefert wird. Der Modus
kommt wahlweise aus einer Datei statt einem Startparameter, damit Task 4 ihn spaeter ohne
Dienst-Neustart umschalten kann. Die Steuerdatei control.json (Phase auto/listen/armed)
wird bei jeder Anfrage frisch gelesen und atomar mit 0644 geschrieben - eine fehlende oder
kaputte Steuerdatei blockiert eine Migration nie, sondern faellt auf "auto" zurueck. Nach
einer erfolgreichen Auslieferung in Phase "armed" faellt die Freigabe erst nach dem
vollstaendigen Senden auf "listen" zurueck, nie davor, damit ein abgebrochener Transfer die
Freigabe fuer den naechsten Bootversuch stehen laesst. status.json bekommt die
Migrationsfelder aus Spec 5.2 (mode, phase, armed_target, last_seen).

deploy.sh liefert products.py mit aus, sonst startet der Dienst nach dem naechsten Deploy
mit ModuleNotFoundError; test_deploy.sh zieht die Sandbox-Dummy-Datei entsprechend nach.

Neue Tests in test_server.py verwenden eigene server/migration_server-Fixtures samt
Testklient (get/status/set_control/read_control/delete_control/write_raw_control), ohne die
bestehende station-Fixture und ihre Tests anzufassen.
2026-09-13 18:06:25 +02:00

682 lines
27 KiB
Python

import hashlib
import http.client
import json
import os
import select
import socket
import stat
import subprocess
import sys
import threading
import time
from pathlib import Path
import pytest
SERVER = Path(__file__).resolve().parents[1] / "server.py"
TRAILER_SIZE = 512
RECENT_MAX = 8
def make_image(path: Path, device: str, version: str, payload: bytes) -> bytes:
"""Builds a file with the same trailer layout the firmware verifies."""
trailer = bytearray(b"\xff" * TRAILER_SIZE)
trailer[0:4] = b"NINL"
trailer[4] = 1
trailer[5:21] = device.encode().ljust(16, b"\x00")
trailer[21:37] = version.encode().ljust(16, b"\x00")
trailer[37:69] = hashlib.sha256(payload).digest()
data = payload + bytes(trailer)
path.write_bytes(data)
return data
def free_port() -> int:
s = socket.socket()
s.bind(("127.0.0.1", 0))
port = s.getsockname()[1]
s.close()
return port
class Station:
def __init__(self, tmp_path, *extra):
self.image_dir = tmp_path / "images"
self.image_dir.mkdir()
self.state = tmp_path / "status.json"
self.port = free_port()
self.proc = subprocess.Popen(
[sys.executable, str(SERVER), "--image-dir", str(self.image_dir),
"--bind", "127.0.0.1", "--port", str(self.port),
"--state", str(self.state), *extra],
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
)
deadline = time.time() + 10
while time.time() < deadline:
try:
socket.create_connection(("127.0.0.1", self.port), 0.2).close()
return
except OSError:
if self.proc.poll() is not None:
raise RuntimeError("server died: " + (self.proc.stdout.read() or ""))
time.sleep(0.05)
raise RuntimeError("server did not come up")
def conn(self):
return http.client.HTTPConnection("127.0.0.1", self.port, timeout=5)
def read_state(self, predicate=None):
"""Reads status.json, optionally waiting for it to satisfy `predicate`.
The server only writes the entry for a request after that request's response has
actually finished (or failed) on the wire - by design, so it never claims success
before the last byte is confirmed sent (see server.py). A client that has just
finished reading its own response can therefore still see a status.json that is a
few milliseconds stale. Rather than have the server promise something before it is
true, we wait here for the state to catch up.
"""
last = None
for _ in range(100):
if self.state.exists():
try:
last = json.loads(self.state.read_text())
if predicate is None or predicate(last):
return last
except json.JSONDecodeError:
pass
time.sleep(0.05)
if last is None:
raise AssertionError("no state file written")
raise AssertionError(f"state never satisfied predicate, last seen: {last!r}")
def stop(self):
self.proc.terminate()
self.proc.wait(timeout=5)
def wait_for_log_line(self, contains, timeout=3.0):
"""Reads the server's merged stdout/stderr (see __init__) until a line containing
`contains` shows up, skipping unrelated lines (e.g. the startup banner) - used to
check that a request actually produced a log line (F3: every request must,
including rejected ones and errors the base handler catches before routing).
Reads the raw fd directly with os.read() instead of self.proc.stdout.readline():
readline() goes through TextIOWrapper's internal BufferedReader, whose underlying
read() can slurp everything currently sitting in the pipe (banner *and* the log
line we're waiting for) into its own buffer on the very first call, while only
handing back the first line. select() then reports the fd as not-readable on the
next iteration - the data isn't gone, it is just buffered somewhere select() can't
see - and a fixed-size test timeout would flake on exactly the line it's checking
for.
"""
fd = self.proc.stdout.fileno()
buf = getattr(self, "_log_buf", "")
deadline = time.time() + timeout
while True:
while "\n" in buf:
line, buf = buf.split("\n", 1)
self._log_buf = buf
if contains in line:
return line + "\n"
remaining = deadline - time.time()
if remaining <= 0:
self._log_buf = buf
return None
r, _, _ = select.select([fd], [], [], remaining)
if not r:
self._log_buf = buf
return None
chunk = os.read(fd, 65536)
if not chunk:
self._log_buf = buf
return None
buf += chunk.decode("utf-8", "replace")
@pytest.fixture
def station(tmp_path):
s = Station(tmp_path)
yield s
s.stop()
def test_serves_a_known_image(station):
data = make_image(station.image_dir / "ninlight_evk_ota.img", "evk", "1.2.3", b"payload" * 500)
c = station.conn()
c.request("GET", "/ninlight_evk_ota.img")
r = c.getresponse()
body = r.read()
assert r.status == 200
assert int(r.getheader("Content-Length")) == len(data)
assert body == data
def test_missing_image_is_404(station):
c = station.conn()
c.request("GET", "/ninlight_g95_cw_ota.img")
assert c.getresponse().status == 404
def test_unknown_target_is_404(station):
make_image(station.image_dir / "ninlight_evk_ota.img", "evk", "1.0.0", b"x" * 100)
for path in ("/ninlight_nosuch_ota.img", "/ninlight_evk_ota.img.bak", "/", "/images/"):
c = station.conn()
c.request("GET", path)
assert c.getresponse().status == 404, path
def test_path_traversal_is_refused(station):
secret = station.image_dir.parent / "secret.txt"
secret.write_text("nicht ausliefern")
c = station.conn()
c.request("GET", "/../secret.txt")
assert c.getresponse().status == 404
def test_other_methods_are_405(station):
c = station.conn()
c.request("POST", "/ninlight_evk_ota.img", body=b"")
assert c.getresponse().status == 405
def test_rejected_method_produces_a_log_line(station):
"""F3: do_POST (and PUT/DELETE/HEAD) used to call reject() without logging anything -
the station is the only diagnostic tool during a rescue, and the spec requires a line
per request, rejections included."""
c = station.conn()
c.request("POST", "/ninlight_evk_ota.img", body=b"")
assert c.getresponse().status == 405
line = station.wait_for_log_line(" 405 ")
assert line is not None, "kein Log-Eintrag fuer die abgelehnte POST-Anfrage"
assert "/ninlight_evk_ota.img" in line
def test_log_error_is_not_silenced_by_log_message(capsys):
"""F3: BaseHTTPRequestHandler.log_error() calls log_message() by default (used e.g. for
a malformed request line or a client timeout, cases that never reach do_GET/do_POST).
server.py silences log_message() in favour of its own per-request log() calls - which
used to silence log_error() right along with it, since it wasn't overridden separately.
This exercises Handler.log_error() directly rather than over a raw socket: this
sandbox's network layer intercepts/rewrites malformed non-HTTP payloads before they
reach the server (observed via a spoofed 'Server: aiohttp' response), which makes a
genuine malformed-request-line test unreliable here."""
import importlib.util
spec = importlib.util.spec_from_file_location("ninlight_station_server", SERVER)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
class _Stub:
client_address = ("203.0.113.5", 54321)
module.Handler.log_error(_Stub(), "code %d, message %s", 400, "Bad request version")
out = capsys.readouterr().out
assert "ERROR" in out
assert "203.0.113.5" in out
assert "code 400, message Bad request version" in out
def test_range_request_returns_206_and_the_right_slice(station):
data = make_image(station.image_dir / "ninlight_e27_rgb_ota.img", "e27_rgb", "2.0.0", b"abc" * 2000)
c = station.conn()
c.request("GET", "/ninlight_e27_rgb_ota.img", headers={"Range": "bytes=1000-"})
r = c.getresponse()
body = r.read()
assert r.status == 206
assert r.getheader("Content-Range") == f"bytes 1000-{len(data)-1}/{len(data)}"
assert body == data[1000:]
def test_state_file_records_a_delivery(station):
make_image(station.image_dir / "ninlight_evk_ota.img", "evk", "0.3.4", b"z" * 1000)
c = station.conn()
c.request("GET", "/ninlight_evk_ota.img")
c.getresponse().read()
state = station.read_state(lambda s: s["served"] >= 1)
assert state["served"] == 1
entry = state["recent"][-1]
assert entry["target"] == "evk"
assert entry["version"] == "0.3.4"
assert entry["status"] == "ok"
assert entry["ip"] == "127.0.0.1"
assert entry["bytes"] == 1000 + TRAILER_SIZE
def test_state_file_records_a_missing_image(station):
c = station.conn()
c.request("GET", "/ninlight_g95_cw_ota.img")
c.getresponse().read()
state = station.read_state(lambda s: len(s["recent"]) >= 1)
assert state["served"] == 0
assert state["recent"][-1]["status"] == "notfound"
assert state["recent"][-1]["target"] == "g95_cw"
def test_state_file_is_world_readable_even_under_a_restrictive_umask(tmp_path):
"""status.json is written by a root service but read by a normal-user CLI.
tempfile.mkstemp() creates its file 0600 and os.replace() preserves whatever mode
the temp file had, so the server must explicitly widen the mode before the rename -
and must do so with chmod() rather than relying on the mode passed to mkstemp/open,
because that one is still narrowed by the process umask. A restrictive umask (as
root services on the Pi may well run under) must not bring the 0600 back.
"""
old_umask = os.umask(0o077)
try:
s = Station(tmp_path)
finally:
os.umask(old_umask)
try:
make_image(s.image_dir / "ninlight_evk_ota.img", "evk", "1.0.0", b"w" * 100)
def world_readable() -> bool:
mode = stat.S_IMODE(s.state.stat().st_mode)
return bool(mode & stat.S_IROTH)
# Written once already at startup (Station.__init__ -> write_state()).
s.read_state(lambda _: world_readable())
assert world_readable(), "status.json is not world-readable after the initial write"
# A second write (a real delivery) must not lose the mode either - e.g. if the
# widening were only applied to the file's very first creation instead of to
# every temp file, mode 0600 would silently come back on every later write.
c = s.conn()
c.request("GET", "/ninlight_evk_ota.img")
c.getresponse().read()
s.read_state(lambda st: st["served"] >= 1)
assert world_readable(), "status.json lost its mode on a later overwrite"
finally:
s.stop()
def test_recent_is_capped_at_eight(station):
make_image(station.image_dir / "ninlight_evk_ota.img", "evk", "1.0.0", b"q" * 10)
for _ in range(12):
c = station.conn()
c.request("GET", "/ninlight_evk_ota.img")
c.getresponse().read()
state = station.read_state(lambda s: s["served"] >= 12)
assert state["served"] == 12
assert len(state["recent"]) == RECENT_MAX
def test_aborted_transfer_is_recorded(tmp_path):
s = Station(tmp_path, "--stall-after", "256")
try:
make_image(s.image_dir / "ninlight_evk_ota.img", "evk", "1.0.0", b"y" * 100000)
sock = socket.create_connection(("127.0.0.1", s.port), timeout=5)
sock.sendall(b"GET /ninlight_evk_ota.img HTTP/1.1\r\nHost: x\r\n\r\n")
sock.recv(4096)
time.sleep(0.3)
sock.close()
deadline = time.time() + 10
while time.time() < deadline:
try:
entry = json.loads(s.state.read_text())["recent"][-1]
if entry["status"] == "aborted":
assert entry["bytes"] < 100000
return
except (OSError, json.JSONDecodeError, IndexError, KeyError):
pass
time.sleep(0.1)
raise AssertionError("abort was not recorded")
finally:
s.stop()
def test_stall_after_stops_sending_but_keeps_the_connection(tmp_path):
s = Station(tmp_path, "--stall-after", "512")
try:
make_image(s.image_dir / "ninlight_evk_ota.img", "evk", "1.0.0", b"y" * 200000)
sock = socket.create_connection(("127.0.0.1", s.port), timeout=5)
sock.sendall(b"GET /ninlight_evk_ota.img HTTP/1.1\r\nHost: x\r\n\r\n")
got = b""
sock.settimeout(2)
try:
while len(got) < 200000:
chunk = sock.recv(65536)
if not chunk:
break
got += chunk
except socket.timeout:
pass
# far less than the full image
assert len(got) < 20000
# and the connection is genuinely still open: a server that (wrongly) closed it
# after the stall boundary would make this recv() return b"" (EOF) instead of
# timing out. Just counting received bytes above would not catch that - a server
# closing the connection right after --stall-after also sends far less than
# 20000 bytes.
sock.settimeout(1)
with pytest.raises(socket.timeout):
sock.recv(1)
sock.close()
finally:
s.stop()
def test_stall_after_budget_survives_a_resumed_request(tmp_path):
"""--stall-after must stop a client for good, not just for one request.
On real hardware, a lamp whose download stalls hits its own receive timeout, gives up
on the connection, and opens a new one with a Range header picking up right where it
left off - exactly what test_stall_after_stops_sending_but_keeps_the_connection above
exercises for a single request. A per-request byte counter would hand that resumed
request a brand-new --stall-after allowance, so the "stall" would only ever delay
delivery instead of actually stopping it - which is what was observed on the EVK: the
server stalled at 65536 bytes each time, but the lamp reconnected five times and walked
away with the complete image. This test reproduces that reconnect and checks that the
second, resumed request gets no further bytes at all.
"""
s = Station(tmp_path, "--stall-after", "1000")
try:
make_image(s.image_dir / "ninlight_evk_ota.img", "evk", "1.0.0", b"y" * 200000)
# First request: burns through the whole --stall-after budget, then the server
# holds the connection open and sends nothing further.
sock = socket.create_connection(("127.0.0.1", s.port), timeout=5)
sock.sendall(b"GET /ninlight_evk_ota.img HTTP/1.1\r\nHost: x\r\n\r\n")
got = b""
sock.settimeout(2)
try:
while len(got) < 200000:
chunk = sock.recv(65536)
if not chunk:
break
got += chunk
except socket.timeout:
pass
assert len(got) < 20000 # far less than the full image, same bound as above
sock.close() # the lamp's watchdog gives up and reconnects, like on real hardware
# Second request, resuming with Range exactly like a real lamp would. This
# client's cumulative budget is already spent, so it must get zero more bytes of
# body - not a fresh --stall-after allowance just because it's a new connection.
sock2 = socket.create_connection(("127.0.0.1", s.port), timeout=5)
sock2.sendall(b"GET /ninlight_evk_ota.img HTTP/1.1\r\nHost: x\r\nRange: bytes=1000-\r\n\r\n")
sock2.settimeout(2)
header = b""
while b"\r\n\r\n" not in header:
header += sock2.recv(4096)
assert b" 206 " in header.split(b"\r\n", 1)[0]
body_after_header = header.split(b"\r\n\r\n", 1)[1]
assert body_after_header == b"", "resumed request must not receive any body bytes"
try:
more = sock2.recv(1)
except socket.timeout:
more = None
assert more in (None, b""), "server sent data after the client's stall budget was spent"
sock2.close()
finally:
s.stop()
def test_concurrent_downloads_are_all_recorded(station):
"""Several lamps hitting the station at once must not lose or corrupt status.json.
Station.served is a shared counter and Station.recent a shared list, mutated from one
handler thread per connection under ThreadingHTTPServer. Without a lock around the
read-modify-write of `served` and the recent-list mutation + serialize-to-disk step,
concurrent requests can lose increments (two threads read the same old value) or have
one thread's status.json write clobber another's.
"""
make_image(station.image_dir / "ninlight_evk_ota.img", "evk", "1.0.0", b"c" * 5000)
expected_len = 5000 + TRAILER_SIZE
n_workers = 20
errors = []
results_lock = threading.Lock()
def worker():
try:
c = station.conn()
c.request("GET", "/ninlight_evk_ota.img")
r = c.getresponse()
body = r.read()
if r.status != 200 or len(body) != expected_len:
with results_lock:
errors.append((r.status, len(body)))
except Exception as e: # pragma: no cover - surfaced via the errors list below
with results_lock:
errors.append(e)
threads = [threading.Thread(target=worker) for _ in range(n_workers)]
for t in threads:
t.start()
for t in threads:
t.join(timeout=10)
assert not errors, f"some downloads failed or returned the wrong body: {errors}"
state = station.read_state(lambda s: s["served"] >= n_workers)
assert state["served"] == n_workers
assert len(state["recent"]) == min(n_workers, RECENT_MAX)
for entry in state["recent"]:
assert entry["status"] == "ok"
assert entry["target"] == "evk"
assert entry["bytes"] == expected_len
# --- Migrationsroute (Task 3) -------------------------------------------------------
#
# Eigener Testklient statt der Station-Klasse oben: die migration_server/server-Fixtures
# brauchen get()/status()/set_control()/read_control()/delete_control()/write_raw_control(),
# waehrend die Station-Klasse und die "station"-Fixture oben unveraendert fuer die
# bestehenden Rettungsmodus-Tests weiterlaufen sollen.
class _Response:
def __init__(self, status, headers, body):
self.status = status
self.headers = headers
self.body = body
class MigrationTestServer:
"""Startet server.py mit --mode/--control und bietet die vom Brief geforderten
Hilfsmethoden. server.py schreibt status.json/control.json fuer eine Anfrage im
selben Handler-Thread, unmittelbar nachdem der Antwortkoerper vollstaendig gesendet
wurde - aber http.client's read() kann zurueckkehren, sobald Content-Length Bytes
angekommen sind, was diesem serverseitigen Schreiben knapp zuvorkommen kann. get()
legt deshalb eine kurze Pause ein, bevor es zurueckkehrt (derselbe Grund, aus dem
Station.read_state() oben mit einem Prädikat auf status.json wartet - hier gibt es
kein generisches Prädikat, das jeder Aufrufer kennt, darum die feste kleine Pause statt
einer Wiederholschleife)."""
def __init__(self, tmp_path, mode):
self.image_dir = tmp_path / "images"
self.image_dir.mkdir()
self.state_path = tmp_path / "status.json"
self.control_path = tmp_path / "control.json"
self.port = free_port()
self.proc = subprocess.Popen(
[sys.executable, str(SERVER), "--image-dir", str(self.image_dir),
"--bind", "127.0.0.1", "--port", str(self.port),
"--state", str(self.state_path), "--mode", mode,
"--control", str(self.control_path)],
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
)
deadline = time.time() + 10
while time.time() < deadline:
try:
socket.create_connection(("127.0.0.1", self.port), 0.2).close()
return
except OSError:
if self.proc.poll() is not None:
raise RuntimeError("server died: " + (self.proc.stdout.read() or ""))
time.sleep(0.05)
raise RuntimeError("server did not come up")
def get(self, path, headers=None):
conn = http.client.HTTPConnection("127.0.0.1", self.port, timeout=5)
try:
conn.request("GET", path, headers=headers or {})
r = conn.getresponse()
body = r.read()
resp = _Response(r.status, dict(r.getheaders()), body)
finally:
conn.close()
# See the class docstring: a short pause so the server's own status.json/
# control.json write for this request has landed before we return.
time.sleep(0.05)
return resp
def _read_json(self, path, timeout=2.0):
deadline = time.time() + timeout
last_err = None
while time.time() < deadline:
try:
return json.loads(path.read_text())
except (OSError, json.JSONDecodeError) as e:
last_err = e
time.sleep(0.02)
raise AssertionError(f"could not read {path}: {last_err}")
def status(self):
return self._read_json(self.state_path)
def read_control(self):
return self._read_json(self.control_path)
def set_control(self, phase, armed_target=None):
tmp = self.control_path.with_name(self.control_path.name + ".tmp")
tmp.write_text(json.dumps({"phase": phase, "armed_target": armed_target}))
os.chmod(tmp, 0o644)
os.replace(tmp, self.control_path)
def delete_control(self):
try:
self.control_path.unlink()
except FileNotFoundError:
pass
def write_raw_control(self, text):
self.control_path.write_text(text)
def stop(self):
self.proc.terminate()
self.proc.wait(timeout=5)
@pytest.fixture
def server(tmp_path):
srv = MigrationTestServer(tmp_path, "recovery")
yield srv, srv.image_dir
srv.stop()
@pytest.fixture
def migration_server(tmp_path):
srv = MigrationTestServer(tmp_path, "migration")
yield srv, srv.image_dir
srv.stop()
def test_migration_auto_serves_known_product(migration_server):
srv, imgdir = migration_server
(imgdir / "migrate").mkdir(exist_ok=True)
(imgdir / "migrate" / "g95_cw_dev.img").write_bytes(b"IMG" * 1000)
r = srv.get("/qcx_firmware_-klyqa.lighting.cw-ww.g95-dev_V1.1.1.1800-dev_ota_light.img")
assert r.status == 200
assert r.body == b"IMG" * 1000
assert srv.status()["last_seen"]["target"] == "g95_cw"
assert srv.status()["last_seen"]["variant"] == "dev"
def test_migration_listen_records_but_serves_nothing(migration_server):
srv, imgdir = migration_server
(imgdir / "migrate").mkdir(exist_ok=True)
(imgdir / "migrate" / "g95_cw_dev.img").write_bytes(b"IMG")
srv.set_control(phase="listen")
r = srv.get("/qcx_firmware_-klyqa.lighting.cw-ww.g95-dev_V1_ota_light.img")
assert r.status == 404
st = srv.status()
assert st["last_seen"]["target"] == "g95_cw" # erkannt
assert st["served"] == 0 # aber nichts ausgeliefert
assert st["recent"][0]["status"] == "listen"
def test_arm_serves_exactly_once_then_falls_back_to_listen(migration_server):
srv, imgdir = migration_server
(imgdir / "migrate").mkdir(exist_ok=True)
(imgdir / "migrate" / "g95_cw_dev.img").write_bytes(b"IMG")
srv.set_control(phase="armed", armed_target="g95_cw")
p = "/qcx_firmware_-klyqa.lighting.cw-ww.g95-dev_V1_ota_light.img"
assert srv.get(p).status == 200
assert srv.read_control()["phase"] == "listen"
assert srv.get(p).status == 404
def test_armed_for_other_target_does_not_serve(migration_server):
srv, imgdir = migration_server
(imgdir / "migrate").mkdir(exist_ok=True)
(imgdir / "migrate" / "g95_cw_dev.img").write_bytes(b"IMG")
srv.set_control(phase="armed", armed_target="gu10_rgb")
r = srv.get("/qcx_firmware_-klyqa.lighting.cw-ww.g95-dev_V1_ota_light.img")
assert r.status == 404
assert srv.read_control()["phase"] == "armed" # Freigabe bleibt stehen
def test_unknown_product_id_is_404_and_logged(migration_server):
srv, _ = migration_server
r = srv.get("/qcx_firmware_-acme.bulb_V1_ota_light.img")
assert r.status == 404
assert srv.status()["recent"][0]["status"] == "unknown"
def test_missing_migrate_image_is_404(migration_server):
srv, _ = migration_server
r = srv.get("/qcx_firmware_-klyqa.lighting.cw-ww.e14_V1_ota_light.img")
assert r.status == 404
assert srv.status()["recent"][0]["status"] == "notfound"
def test_recovery_path_is_404_in_migration_mode(migration_server):
srv, imgdir = migration_server
(imgdir / "ninlight_evk_ota.img").write_bytes(b"x" * 600)
assert srv.get("/ninlight_evk_ota.img").status == 404
def test_migration_path_is_404_in_recovery_mode(server):
srv, _ = server
assert srv.get(
"/qcx_firmware_-klyqa.lighting.cw-ww.g95-dev_V1_ota_light.img").status == 404
def test_missing_control_file_means_auto(migration_server):
srv, imgdir = migration_server
(imgdir / "migrate").mkdir(exist_ok=True)
(imgdir / "migrate" / "g95_cw_dev.img").write_bytes(b"IMG")
srv.delete_control()
assert srv.get(
"/qcx_firmware_-klyqa.lighting.cw-ww.g95-dev_V1_ota_light.img").status == 200
def test_corrupt_control_file_means_auto(migration_server):
srv, imgdir = migration_server
(imgdir / "migrate").mkdir(exist_ok=True)
(imgdir / "migrate" / "g95_cw_dev.img").write_bytes(b"IMG")
srv.write_raw_control("{das ist kein json")
assert srv.get(
"/qcx_firmware_-klyqa.lighting.cw-ww.g95-dev_V1_ota_light.img").status == 200
def test_range_resume_on_migration_image(migration_server):
srv, imgdir = migration_server
(imgdir / "migrate").mkdir(exist_ok=True)
(imgdir / "migrate" / "g95_cw_dev.img").write_bytes(bytes(range(256)) * 40)
p = "/qcx_firmware_-klyqa.lighting.cw-ww.g95-dev_V1_ota_light.img"
r = srv.get(p, headers={"Range": "bytes=1000-"})
assert r.status == 206
assert r.headers["Content-Range"] == "bytes 1000-10239/10240"
assert r.body == (bytes(range(256)) * 40)[1000:]
def test_version_comes_from_manifest(migration_server):
srv, imgdir = migration_server
(imgdir / "migrate").mkdir(exist_ok=True)
(imgdir / "migrate" / "g95_cw_dev.img").write_bytes(b"IMG")
(imgdir / "migrate" / "manifest.json").write_text(json.dumps({
"ninlight_version": "0.3.9",
"images": [{"target": "g95_cw", "variant": "dev", "file": "g95_cw_dev.img"}]}))
srv.get("/qcx_firmware_-klyqa.lighting.cw-ww.g95-dev_V1_ota_light.img")
assert srv.status()["recent"][0]["version"] == "0.3.9"