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.
1194 lines
52 KiB
Python
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"
|