Files
Michael Sauer ac8dfb1077 feat(station): Rettung folgt dem Bildsatz-Umschalter, Server meldet Lebenszeichen
server.py: image_path() liefert jetzt aus rescue_dir() (nach dem Umschalter
ohne-wlan/mit-wlan), genau wie migrate_dir() es fuer die Migration schon tut.
Bewusst kein Fallback auf den alten flachen Bestand in images/ - ein Pi mit
altem Bestand muss einmal per deploy.sh neu bestueckt werden, sonst wuerde
"ohne-wlan" den alten Zustand (immer mit Zugangsdaten) stillschweigend
fortsetzen.

Station.last_activity/"last_request_ts" in status.json: Zeitpunkt jeder
Anfrage (auch erfolgloser), damit die Anzeige ein Lebenszeichen zeigen kann,
statt nur am Servierzaehler abzulesen, ob ueberhaupt etwas ankommt.
2026-09-23 21:22:22 +02:00

1194 lines
52 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()
# image_path() folgt jetzt dem Bildsatz-Umschalter, genauso wie migrate_dir() es
# tut - ohne --imageset-file gilt die Vorgabe "ohne-wlan" (server.py,
# DEFAULT_IMAGE_SET), also landen Rettungsabbilder in image_dir/rescue-nocreds.
# Die meisten Tests hier pruefen nur die Ausliefermechanik, nicht den
# Bildsatz-Umschalter selbst - die Bildsatz-spezifischen Tests weiter unten legen
# ihre Abbilder gezielt in andere Verzeichnisse.
self.rescue_dir = self.image_dir / "rescue-nocreds"
self.rescue_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.rescue_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.rescue_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.rescue_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.rescue_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_carries_a_last_request_timestamp_for_the_display(station):
"""display.py zeigt einen kleinen Lebenszeichen-Punkt, sobald eine Anfrage jeder Art
hereinkommt (auch ein 404) - dafuer muss status.json den Zeitpunkt tragen, nicht nur
den Erfolgszaehler. Vor der ersten Anfrage ist das Feld da, aber None."""
initial = station.read_state()
assert "last_request_ts" in initial
assert initial["last_request_ts"] is None
c = station.conn()
c.request("GET", "/ninlight_g95_cw_ota.img") # existiert nicht - zaehlt trotzdem
c.getresponse().read()
state = station.read_state(lambda s: s["last_request_ts"] is not None)
assert isinstance(state["last_request_ts"], str) and state["last_request_ts"]
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.rescue_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.rescue_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.rescue_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.rescue_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.rescue_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.rescue_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.
status()/read_control() folgen demselben Muster wie Station.read_state() oben
(Zeilen 68-90): sie pollen bis zu einigen Sekunden auf ein optionales Praedikat, statt
sich auf eine feste Pause nach get() zu verlassen. Grund ist derselbe wie dort -
server.py schreibt status.json/control.json fuer eine Anfrage erst, nachdem der
Antwortkoerper vollstaendig gesendet ist, aber http.client's read() kann zurueckkehren,
sobald Content-Length Bytes angekommen sind, was diesem Schreiben knapp zuvorkommen
kann. Jeder Aufrufer kennt sein eigenes Praedikat (das ohnehin schon als Assertion
dasteht), daher kein pauschales Warten hier."""
def __init__(self, tmp_path, mode, *extra_args):
self.image_dir = tmp_path / "images"
self.image_dir.mkdir()
self.state_path = tmp_path / "status.json"
# Eigenes Unterverzeichnis fuer die Steuerdatei, getrennt von status.json: ein Test
# fuer I3 (Fixrunde 1) macht genau dieses Verzeichnis schreibgeschuetzt, um einen
# fehlschlagenden write_control() zu erzwingen, und darf dabei nicht auch das
# Schreiben von status.json lahmlegen, ueber das derselbe Test den Fehler beobachtet.
control_dir = tmp_path / "control"
control_dir.mkdir()
self.control_path = control_dir / "control.json"
# Der Umschalter des Bildsatzes. Die Datei existiert zunaechst NICHT - das ist
# der Auslieferungszustand und muss "ohne-wlan" (migrate-nocreds/) bedeuten.
self.imageset_path = tmp_path / "imageset"
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),
"--imageset-file", str(self.imageset_path), *extra_args],
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()
return _Response(r.status, dict(r.getheaders()), body)
finally:
conn.close()
def _poll_json(self, path, predicate=None, timeout=5.0):
"""Wie Station.read_state() oben: liest `path` wiederholt (bis zu `timeout`
Sekunden), bis er sich parsen laesst und - falls angegeben - `predicate` erfuellt.
Ohne predicate reicht ein einziges erfolgreiches Parsen, genau wie dort."""
last = None
last_err = None
deadline = time.time() + timeout
while time.time() < deadline:
try:
last = json.loads(path.read_text())
if predicate is None or predicate(last):
return last
except (OSError, json.JSONDecodeError) as e:
last_err = e
time.sleep(0.02)
if last is None:
raise AssertionError(f"could not read {path}: {last_err}")
raise AssertionError(f"{path} never satisfied predicate, last seen: {last!r}")
def status(self, predicate=None):
return self._poll_json(self.state_path, predicate)
def read_control(self, predicate=None):
return self._poll_json(self.control_path, predicate)
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 set_image_set(self, value):
"""Stellt den Umschalter so um, wie es das CLI tut: eine Datei mit dem Namen des
Bildsatzes. `None` loescht sie wieder (= Auslieferungszustand)."""
if value is None:
try:
self.imageset_path.unlink()
except FileNotFoundError:
pass
return
self.imageset_path.write_text(value)
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-nocreds").mkdir(exist_ok=True)
(imgdir / "migrate-nocreds" / "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
st = srv.status(lambda s: s.get("last_seen") is not None)
assert st["last_seen"]["target"] == "g95_cw"
assert st["last_seen"]["variant"] == "dev"
def test_migration_dev_only_in_version_serves_the_dev_image(migration_server):
"""Der Hardwarebefund vom 2026-09-15: Produkt-ID OHNE "-dev", Version MIT. Frueher
ging hier das produktionssignierte Image raus (200, und die Lampe lehnte es ab) -
jetzt muss das dev-signierte gehen, und status.json muss die Quelle mitfuehren."""
srv, imgdir = migration_server
(imgdir / "migrate-nocreds").mkdir(exist_ok=True)
(imgdir / "migrate-nocreds" / "g95_cw_dev.img").write_bytes(b"DEV" * 1000)
(imgdir / "migrate-nocreds" / "g95_cw_prod.img").write_bytes(b"PROD" * 1000)
r = srv.get("/qcx_firmware_-klyqa.lighting.cw-ww.g95_V1.1.1.2357-dev_ota_light.img")
assert r.status == 200
assert r.body == b"DEV" * 1000
st = srv.status(lambda s: s.get("last_seen") is not None)
assert st["last_seen"]["target"] == "g95_cw"
assert st["last_seen"]["variant"] == "dev"
assert st["last_seen"]["variant_source"] == "version"
assert st["recent"][-1]["variant"] == "dev"
assert st["recent"][-1]["variant_source"] == "version"
def test_migration_production_lamp_still_gets_the_prod_image(migration_server):
"""Die erfolgreich migrierte GU10: nirgends ein "-dev", also prod - und die Quelle
"none", damit am Panel/in status.json steht, worauf die Einstufung beruht."""
srv, imgdir = migration_server
(imgdir / "migrate-nocreds").mkdir(exist_ok=True)
(imgdir / "migrate-nocreds" / "gu10_rgb_dev.img").write_bytes(b"DEV" * 1000)
(imgdir / "migrate-nocreds" / "gu10_rgb_prod.img").write_bytes(b"PROD" * 1000)
r = srv.get("/qcx_firmware_-klyqa.lighting.rgb-cw-ww.gu10_V1.1.0.1138_ota_light.img")
assert r.status == 200
assert r.body == b"PROD" * 1000
st = srv.status(lambda s: s.get("last_seen") is not None)
assert st["last_seen"]["variant"] == "prod"
assert st["last_seen"]["variant_source"] == "none"
def test_migration_listen_records_but_serves_nothing(migration_server):
srv, imgdir = migration_server
(imgdir / "migrate-nocreds").mkdir(exist_ok=True)
(imgdir / "migrate-nocreds" / "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(lambda s: len(s["recent"]) >= 1)
assert st["last_seen"]["target"] == "g95_cw" # erkannt
assert st["served"] == 0 # aber nichts ausgeliefert
assert st["recent"][-1]["status"] == "listen"
def test_arm_serves_exactly_once_then_falls_back_to_listen(migration_server):
srv, imgdir = migration_server
(imgdir / "migrate-nocreds").mkdir(exist_ok=True)
(imgdir / "migrate-nocreds" / "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(lambda c: c.get("phase") == "listen")["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-nocreds").mkdir(exist_ok=True)
(imgdir / "migrate-nocreds" / "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
# --- Eine Freigabe wird NUR durch eine erfolgreiche Auslieferung verbraucht ----------
#
# Die Zusage (Spec 3.3): claim_delivery() nimmt die Freigabe schon unter der Sperre
# weg, BEVOR ueberhaupt gesendet wird (das ist der Punkt von C1 aus Fixrunde 1). Geht
# danach nichts raus, muss restore_armed() sie zurueckgeben - sonst steht der Bediener
# vor einer Lampe, die beim naechsten Boot nichts mehr bekommt, und muss raten, warum.
#
# Es gibt drei solche Stellen in _do_get_migration(): fehlender Bildsatz, fehlendes
# Einzelimage und abgebrochener Transfer. Nur die erste (die juengste) war getestet -
# das Abschlussreview hat die beiden anderen mit je einem "if False:" ausgehebelt, ohne
# dass ein einziger der 225 Tests rot wurde. Die beiden folgenden Tests schliessen das.
def test_a_missing_image_does_not_burn_the_release(migration_server):
"""Der Bildsatz ist da, nur DIESES Modell fehlt darin (Status "notfound"). Die
Freigabe darf daran nicht verfallen: sie war fuer genau dieses Ziel gedacht, und ein
fehlendes Image ist ein Deploy-Versaeumnis, kein Auslieferungsvorgang."""
srv, imgdir = migration_server
(imgdir / "migrate-nocreds").mkdir(exist_ok=True)
# Der Satz existiert (ein anderes Modell liegt darin), e14_cw fehlt darin.
(imgdir / "migrate-nocreds" / "g95_cw_dev.img").write_bytes(b"IMG")
srv.set_control(phase="armed", armed_target="e14_cw")
r = srv.get("/qcx_firmware_-klyqa.lighting.cw-ww.e14_V1_ota_light.img")
assert r.status == 404
st = srv.status(lambda s: len(s["recent"]) >= 1)
assert st["recent"][-1]["status"] == "notfound"
assert st["served"] == 0
control = srv.read_control()
assert control["phase"] == "armed", \
"die Freigabe wurde verbraucht, obwohl es gar kein Image auszuliefern gab"
assert control["armed_target"] == "e14_cw", "das freigegebene Ziel ging verloren"
def test_an_aborted_transfer_does_not_burn_the_release(tmp_path):
"""Bricht die Uebertragung ab (Lampe verliert Strom oder Funk mitten im Download),
muss die Freigabe stehen bleiben, damit die Lampe es beim naechsten Boot erneut
versuchen kann - sonst muss jemand vor Ort erneut freigeben, und genau diese
Nachsicht ist der Grund, warum restore_armed() ueberhaupt existiert.
Nachgestellt wie test_aborted_transfer_is_recorded oben: --stall-after laesst den
Server nach n Byte verstummen, der Test schliesst die Verbindung danach, und
_serve_file() meldet "aborted"."""
srv = MigrationTestServer(tmp_path, "migration", "--stall-after", "256")
try:
imgdir = srv.image_dir
(imgdir / "migrate-nocreds").mkdir(exist_ok=True)
(imgdir / "migrate-nocreds" / "g95_cw_dev.img").write_bytes(b"y" * 100000)
srv.set_control(phase="armed", armed_target="g95_cw")
sock = socket.create_connection(("127.0.0.1", srv.port), timeout=5)
try:
sock.sendall(("GET " + G95_DEV + " HTTP/1.1\r\nHost: x\r\n\r\n").encode())
sock.recv(4096)
time.sleep(0.3)
finally:
sock.close()
st = srv.status(lambda s: s["recent"] and s["recent"][-1]["status"] == "aborted")
assert st["recent"][-1]["bytes"] < 100000
assert st["served"] == 0
control = srv.read_control(lambda c: True)
assert control["phase"] == "armed", \
"die Freigabe wurde von einem ABGEBROCHENEN Transfer verbraucht"
assert control["armed_target"] == "g95_cw", "das freigegebene Ziel ging verloren"
finally:
srv.stop()
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
st = srv.status(lambda s: len(s["recent"]) >= 1)
assert st["recent"][-1]["status"] == "unknown"
def test_missing_migrate_image_is_404(migration_server):
srv, imgdir = migration_server
# Der Bildsatz ist da (er enthaelt ein anderes Modell), nur dieses eine Modell fehlt
# darin - ein anderer Zustand als ein fehlender Bildsatz (siehe
# test_a_selected_but_missing_image_set_delivers_nothing).
(imgdir / "migrate-nocreds").mkdir(exist_ok=True)
(imgdir / "migrate-nocreds" / "g95_cw_dev.img").write_bytes(b"IMG")
r = srv.get("/qcx_firmware_-klyqa.lighting.cw-ww.e14_V1_ota_light.img")
assert r.status == 404
st = srv.status(lambda s: len(s["recent"]) >= 1)
assert st["recent"][-1]["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-nocreds").mkdir(exist_ok=True)
(imgdir / "migrate-nocreds" / "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-nocreds").mkdir(exist_ok=True)
(imgdir / "migrate-nocreds" / "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-nocreds").mkdir(exist_ok=True)
(imgdir / "migrate-nocreds" / "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):
"""Faellt auf das manifestweite Feld zurueck, wenn der Eintrag selbst keine eigene
Version traegt - Bestandskompatibilitaet mit einem Manifest aus der Zeit vor
Fixrunde 2 (N2)."""
srv, imgdir = migration_server
(imgdir / "migrate-nocreds").mkdir(exist_ok=True)
(imgdir / "migrate-nocreds" / "g95_cw_dev.img").write_bytes(b"IMG")
(imgdir / "migrate-nocreds" / "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")
st = srv.status(lambda s: len(s["recent"]) >= 1)
assert st["recent"][-1]["version"] == "0.3.9"
def test_version_prefers_the_delivered_entry_over_the_manifest_wide_field(migration_server):
"""Fixrunde 2, N2: nach einem Merge in deploy.sh (Voll-Deploy mit nur einem der
beiden Signierschluessel) koennen Eintraege im selben Manifest unterschiedliche
Firmware-Versionen tragen - z.B. ein neu signiertes g95_cw_dev.img auf 1.2.0, aber
ein unveraendert liegen gebliebenes aelteres g95_cw_prod.img auf 1.0.0. Der Server
darf dann nicht das manifestweite Feld fuer beide protokollieren, sondern muss die
Version DES AUSGELIEFERTEN Eintrags nehmen."""
srv, imgdir = migration_server
(imgdir / "migrate-nocreds").mkdir(exist_ok=True)
(imgdir / "migrate-nocreds" / "g95_cw_dev.img").write_bytes(b"IMG-DEV")
(imgdir / "migrate-nocreds" / "g95_cw_prod.img").write_bytes(b"IMG-PROD")
(imgdir / "migrate-nocreds" / "manifest.json").write_text(json.dumps({
# Manifestweites Feld absichtlich falsch/veraltet, damit ein Zurueckfallen
# darauf im Test auffallen wuerde.
"ninlight_version": "1.2.0",
"images": [
{"target": "g95_cw", "variant": "dev", "file": "g95_cw_dev.img",
"ninlight_version": "1.2.0"},
{"target": "g95_cw", "variant": "prod", "file": "g95_cw_prod.img",
"ninlight_version": "1.0.0"},
]}))
srv.get("/qcx_firmware_-klyqa.lighting.cw-ww.g95_V1_ota_light.img")
st = srv.status(lambda s: len(s["recent"]) >= 1)
assert st["recent"][-1]["version"] == "1.0.0"
def test_control_file_is_world_readable_even_under_a_restrictive_umask(tmp_path):
"""Dieselbe 0600-Falle wie bei status.json (siehe
test_state_file_is_world_readable_even_under_a_restrictive_umask oben), aber fuer
control.json: das CLI aus Task 4 muss die Datei lesen UND schreiben koennen."""
old_umask = os.umask(0o077)
try:
srv = MigrationTestServer(tmp_path, "migration")
finally:
os.umask(old_umask)
try:
(srv.image_dir / "migrate-nocreds").mkdir(exist_ok=True)
(srv.image_dir / "migrate-nocreds" / "g95_cw_dev.img").write_bytes(b"IMG")
srv.set_control(phase="armed", armed_target="g95_cw")
def world_readable():
mode = stat.S_IMODE(srv.control_path.stat().st_mode)
return bool(mode & stat.S_IROTH)
# set_control() oben schreibt direkt aus dem Test, nicht ueber den Server - das
# will hier nicht geprueft werden. Erst die Anfrage unten bringt den Server selbst
# dazu, control.json zu schreiben (armed -> listen), also denselben Codepfad
# (Station.write_control()) wie write_state() fuer status.json.
assert srv.get(
"/qcx_firmware_-klyqa.lighting.cw-ww.g95-dev_V1_ota_light.img").status == 200
srv.read_control(lambda _: world_readable())
assert world_readable(), "control.json ist nach dem Schreiben durch den Server " \
"nicht welt-lesbar"
finally:
srv.stop()
def test_armed_serves_exactly_once_under_concurrency(migration_server):
"""C1 (Fixrunde 1): read_control()/Phasenpruefung/Auslieferung/write_control("listen")
liefen ohne gemeinsame Sperre ab - zwei gleichzeitige Anfragen (zwei Lampen, oder Erst-
und Range-Resume-Verbindung derselben Lampe) konnten beide phase == "armed" sehen und
beide die volle Auslieferung bekommen. station.claim_delivery() muss das Lesen und das
Verbrauchen der Freigabe atomar unter einer Sperre machen, damit von n gleichzeitigen
Anfragen gegen dieselbe Freigabe hoechstens eine durchkommt."""
srv, imgdir = migration_server
(imgdir / "migrate-nocreds").mkdir(exist_ok=True)
(imgdir / "migrate-nocreds" / "g95_cw_dev.img").write_bytes(b"IMG" * 20000)
srv.set_control(phase="armed", armed_target="g95_cw")
p = "/qcx_firmware_-klyqa.lighting.cw-ww.g95-dev_V1_ota_light.img"
n_workers = 20
results = []
results_lock = threading.Lock()
def worker():
r = srv.get(p)
with results_lock:
results.append(r.status)
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 len(results) == n_workers, "nicht alle Worker sind fertig geworden"
assert results.count(200) == 1, f"erwartet genau eine 200, bekommen: {results}"
assert results.count(404) == n_workers - 1
assert srv.read_control(lambda c: c.get("phase") == "listen")["phase"] == "listen"
def test_control_write_failure_blocks_further_armed_deliveries(migration_server):
"""I3 (Fixrunde 1): schlaegt das Schreiben von control.json fehl (z.B. volles oder
schreibgeschuetztes /run), darf die Freigabe nicht dauerhaft auf "armed" stehen bleiben
- sonst wuerde jede weitere Lampe des Ziels ebenfalls bedient, genau die Luecke aus C1,
nur diesmal durch einen Schreibfehler statt durch Nebenlaeufigkeit ausgeloest. Die eine
Anfrage, die den Fehler ausloest, bekommt noch ihre Auslieferung (wie eine normale
Freigabe); jede weitere schlaegt fehl, bis das Schreiben wieder klappt. Der Fehler muss
ausserdem in status.json sichtbar sein, sonst wundert sich niemand, warum "arm" nicht
mehr wirkt."""
srv, imgdir = migration_server
(imgdir / "migrate-nocreds").mkdir(exist_ok=True)
(imgdir / "migrate-nocreds" / "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"
control_dir = srv.control_path.parent
old_mode = control_dir.stat().st_mode
os.chmod(control_dir, 0o500) # r-x fuer den Besitzer, kein Schreiben mehr moeglich
try:
r1 = srv.get(p)
assert r1.status == 200, "die eine vorgesehene Auslieferung muss noch rausgehen"
st = srv.status(lambda s: s.get("control_error"))
assert st["control_error"], "Schreibfehler muss in status.json sichtbar sein"
r2 = srv.get(p)
assert r2.status == 404, \
"ein Schreibfehler darf die Freigabe nicht dauerhaft offen lassen"
finally:
os.chmod(control_dir, old_mode)
def _load_server_module():
"""Laedt server.py wie test_log_error_is_not_silenced_by_log_message oben - als
eigenstaendiges Modulobjekt, ohne es in sys.modules einzutragen, damit mehrfache
Aufrufe (einer je resolve_mode-Test) sich nicht in die Quere kommen."""
import importlib.util
spec = importlib.util.spec_from_file_location("ninlight_station_server_rm", SERVER)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def test_resolve_mode_explicit_mode_wins_over_file(tmp_path):
"""I5 (Fixrunde 1): --mode schlaegt --mode-file."""
module = _load_server_module()
mode_file = tmp_path / "mode"
mode_file.write_text("migration")
assert module.resolve_mode("recovery", mode_file) == "recovery"
def test_resolve_mode_reads_the_mode_file(tmp_path):
module = _load_server_module()
mode_file = tmp_path / "mode"
mode_file.write_text("migration")
assert module.resolve_mode(None, mode_file) == "migration"
def test_resolve_mode_missing_file_means_recovery(tmp_path):
module = _load_server_module()
mode_file = tmp_path / "mode" # bewusst nicht angelegt
assert module.resolve_mode(None, mode_file) == "recovery"
def test_resolve_mode_garbage_file_means_recovery(tmp_path):
module = _load_server_module()
mode_file = tmp_path / "mode"
mode_file.write_text("garbage\n")
assert module.resolve_mode(None, mode_file) == "recovery"
def test_resolve_mode_default_without_mode_file_is_recovery():
module = _load_server_module()
assert module.resolve_mode(None, None) == "recovery"
# --- Umschalter: mit oder ohne einkompilierte WLAN-Zugangsdaten ---------------------
#
# Zwei Bildsaetze liegen auf dem Pi (images/migrate-nocreds und images/migrate-wlan);
# welcher ausgeliefert wird, entscheidet allein der Umschalter. Geprueft wird deshalb
# immer an der TATSAECHLICH AUSGELIEFERTEN DATEI, nicht an einem Pfad oder einem
# Statusfeld - der Fehlgriff, gegen den der Umschalter da ist, ist ein falscher Inhalt
# in einer Lampe.
def _both_sets(imgdir):
(imgdir / "migrate-nocreds").mkdir(exist_ok=True)
(imgdir / "migrate-nocreds" / "g95_cw_dev.img").write_bytes(b"OHNE" * 100)
(imgdir / "migrate-wlan").mkdir(exist_ok=True)
(imgdir / "migrate-wlan" / "g95_cw_dev.img").write_bytes(b"MITW" * 100)
G95_DEV = "/qcx_firmware_-klyqa.lighting.cw-ww.g95-dev_V1_ota_light.img"
def test_the_default_image_set_is_the_one_without_wifi_credentials(migration_server):
"""Ohne Umschalterdatei (Auslieferungszustand) geht der Abzug OHNE Zugangsdaten
raus. Die sichere Vorgabe ist die, bei der ein Fehlgriff niemandem das eigene
WLAN-Passwort in ein fremdes Geraet schreibt."""
srv, imgdir = migration_server
_both_sets(imgdir)
r = srv.get(G95_DEV)
assert r.status == 200
assert r.body == b"OHNE" * 100
def test_the_switch_decides_which_file_is_actually_delivered(migration_server):
srv, imgdir = migration_server
_both_sets(imgdir)
srv.set_image_set("mit-wlan")
assert srv.get(G95_DEV).body == b"MITW" * 100
# ... und wieder zurueck, ohne Dienstneustart: der Umschalter wirkt sofort.
srv.set_image_set("ohne-wlan")
assert srv.get(G95_DEV).body == b"OHNE" * 100
def test_a_broken_switch_file_falls_back_to_the_safe_set(migration_server):
"""Unbekannter Inhalt ist kein Grund, den teureren Satz zu nehmen."""
srv, imgdir = migration_server
_both_sets(imgdir)
srv.set_image_set("voellig-anderes")
assert srv.get(G95_DEV).body == b"OHNE" * 100
def test_a_selected_but_missing_image_set_delivers_nothing(migration_server):
"""Der gewaehlte Satz liegt nicht auf dem Pi: es darf NICHTS rausgehen - insbesondere
nicht die Datei des anderen Satzes, die daneben liegt und genauso heisst."""
srv, imgdir = migration_server
(imgdir / "migrate-nocreds").mkdir(exist_ok=True)
(imgdir / "migrate-nocreds" / "g95_cw_dev.img").write_bytes(b"OHNE" * 100)
srv.set_image_set("mit-wlan") # migrate-wlan/ existiert nicht
r = srv.get(G95_DEV)
assert r.status == 404
assert b"OHNE" not in r.body
st = srv.status(lambda s: len(s["recent"]) >= 1)
assert st["served"] == 0
# Eigener Status, damit am Panel/in status.json "Satz fehlt" von "dieses Modell
# fehlt" unterscheidbar bleibt.
assert st["recent"][-1]["status"] == "noimageset"
def test_a_missing_image_set_does_not_burn_the_release(migration_server):
"""Wie beim fehlenden Einzelimage: eine Freigabe darf nicht daran verfallen, dass es
gar nichts auszuliefern gab."""
srv, imgdir = migration_server
(imgdir / "migrate-nocreds").mkdir(exist_ok=True)
srv.set_image_set("mit-wlan")
srv.set_control(phase="armed", armed_target="g95_cw")
assert srv.get(G95_DEV).status == 404
assert srv.read_control()["phase"] == "armed"
def test_the_version_comes_from_the_manifest_of_the_selected_set(migration_server):
"""Nach dem Umschalten darf nicht die Version des vorigen Satzes stehenbleiben - die
beiden manifest.json entstehen im selben Deploy und koennen dieselbe mtime tragen."""
srv, imgdir = migration_server
_both_sets(imgdir)
for name, version in (("migrate-nocreds", "0.4.0-onb"), ("migrate-wlan", "0.4.0-wlan")):
(imgdir / name / "manifest.json").write_text(json.dumps({
"images": [{"target": "g95_cw", "variant": "dev", "file": "g95_cw_dev.img",
"ninlight_version": version}]}))
# Beiden Manifesten dieselbe mtime geben - genau der Fall, den ein reiner
# mtime-Vergleich nicht bemerkt haette.
stamp = 1600000000
for name in ("migrate-nocreds", "migrate-wlan"):
os.utime(imgdir / name / "manifest.json", (stamp, stamp))
assert srv.get(G95_DEV).status == 200
assert srv.status(lambda s: s["recent"])["recent"][-1]["version"] == "0.4.0-onb"
srv.set_image_set("mit-wlan")
assert srv.get(G95_DEV).status == 200
assert srv.status(
lambda s: len(s["recent"]) >= 2
)["recent"][-1]["version"] == "0.4.0-wlan"
# --- Derselbe Umschalter gilt jetzt auch fuer die RETTUNG ---------------------------
#
# Der Eigentuemer hat das ausdruecklich verlangt: bisher bekam eine Rettung IMMER ein
# Abbild MIT Zugangsdaten (die flachen Abzuege in images/ kamen aus build/). image_path()
# folgt jetzt demselben Umschalter wie migrate_dir() - dieselben Tests wie oben fuer die
# Migration, nur gegen den Rettungspfad "/ninlight_<target>_ota.img".
def _both_rescue_sets(imgdir):
(imgdir / "rescue-nocreds").mkdir(exist_ok=True)
make_image(imgdir / "rescue-nocreds" / "ninlight_g95_cw_ota.img", "g95_cw", "1.0.0",
b"OHNE" * 100)
(imgdir / "rescue-wlan").mkdir(exist_ok=True)
make_image(imgdir / "rescue-wlan" / "ninlight_g95_cw_ota.img", "g95_cw", "1.0.0",
b"MITW" * 100)
def test_rescue_default_image_set_is_the_one_without_wifi_credentials(server):
"""Wie bei der Migration: ohne Umschalterdatei gilt die sichere Vorgabe - ein
Fehlgriff darf niemandem das eigene WLAN-Passwort in ein fremdes Geraet schreiben.
Vor diesem Umschalter bekam eine Rettung IMMER das Abbild MIT Zugangsdaten; das war
genau die Nebenwirkung, die der Eigentuemer sichtbar gemacht haben wollte."""
srv, imgdir = server
_both_rescue_sets(imgdir)
r = srv.get("/ninlight_g95_cw_ota.img")
assert r.status == 200
assert r.body.startswith(b"OHNE")
def test_rescue_switch_decides_which_file_is_actually_delivered(server):
srv, imgdir = server
_both_rescue_sets(imgdir)
srv.set_image_set("mit-wlan")
assert srv.get("/ninlight_g95_cw_ota.img").body.startswith(b"MITW")
# ... und wieder zurueck, ohne Dienstneustart.
srv.set_image_set("ohne-wlan")
assert srv.get("/ninlight_g95_cw_ota.img").body.startswith(b"OHNE")
def test_rescue_missing_selected_set_delivers_nothing(server):
"""Der gewaehlte Rettungs-Bildsatz liegt nicht auf dem Pi: NICHTS geht raus -
insbesondere nicht das gleichnamige Abbild des anderen, danebenliegenden Satzes.
Auf den anderen Satz auszuweichen waere genau der Fehlgriff, gegen den der
Umschalter da ist (siehe rescue_dir() in server.py)."""
srv, imgdir = server
(imgdir / "rescue-nocreds").mkdir(exist_ok=True)
make_image(imgdir / "rescue-nocreds" / "ninlight_g95_cw_ota.img", "g95_cw", "1.0.0",
b"OHNE" * 100)
srv.set_image_set("mit-wlan") # rescue-wlan/ existiert nicht
r = srv.get("/ninlight_g95_cw_ota.img")
assert r.status == 404
assert b"OHNE" not in r.body
st = srv.status(lambda s: len(s["recent"]) >= 1)
assert st["served"] == 0
assert st["recent"][-1]["status"] == "notfound"
def test_rescue_old_flat_layout_is_no_longer_served(server):
"""Rueckwaertskompatibilitaet bewusst NICHT gegeben (server.py, rescue_dir()): ein Pi
mit dem alten, flachen Bestand (vor diesem Umschalter, immer MIT Zugangsdaten aus
build/) muss einmal per deploy.sh neu bestueckt werden. Wuerde image_path() den
flachen Altbestand noch lesen, wuerde die Vorgabe "ohne-wlan" NICHTS daran aendern -
genau die alte, jetzt unerwuenschte Grundhaltung stillschweigend fortgesetzt."""
srv, imgdir = server
make_image(imgdir / "ninlight_g95_cw_ota.img", "g95_cw", "1.0.0", b"ALTBESTAND" * 50)
r = srv.get("/ninlight_g95_cw_ota.img")
assert r.status == 404
def test_read_image_set_defaults_without_a_file(tmp_path):
module = _load_server_module()
assert module.read_image_set(None) == "ohne-wlan"
assert module.read_image_set(tmp_path / "fehlt") == "ohne-wlan"
p = tmp_path / "imageset"
p.write_text("mit-wlan\n")
assert module.read_image_set(p) == "mit-wlan"