Compare commits

..

8 Commits

Author SHA1 Message Date
gitea-actions
51951476a9 chore: reset NIGHTLY_CHANGELOG.md after nightly-0.9.30-nightly12 release 2026-08-05 07:46:56 +00:00
5dd668e8ca docs: restore NIGHTLY_CHANGELOG entries after rebase (code-review fixes batch)
All checks were successful
Nightly Build / build (push) Successful in 8m51s
2026-08-05 09:37:43 +02:00
0c520ffa00 feat(update): treat the testing channel as docker-only in the update check
A testing-<sha> build has no Gitea releases (the testing workflow builds only
a Docker image), so the in-app update check must not fall through to the
stable path - which would wrongly offer a stable "update". handle_api_update_check
now short-circuits for a "testing" version with docker_only:true and nothing
to update (no Gitea round-trip at all), and handle_api_update_apply refuses
self-update on the testing channel the same way it already does for nightly.

Two new tests cover both. (Mirrors the same fix already on the testing
branch, applied here to the pre-refactor monolithic module.)
2026-08-05 09:35:26 +02:00
3daf945612 fix(filaments): guard against non-string profile names, log name collisions
Found during a targeted code review, not from a user report.

parse_profile()'s `name` field went straight through clean_name() without
routing through first_str() first, unlike filament_vendor/filament_type/
default_filament_colour right below it - all of which handle the
documented case where OrcaSlicer stores a field as ["value"] instead of a
plain string. If `data["name"]` was ever a list, clean_name()'s re.sub()
raised TypeError since it requires a string argument. Fixed by routing it
through first_str() like its neighbors.

Also added a debug log when sys_by_name (the system-profile lookup index)
overwrites an entry due to a name collision - clean_name() deliberately
collapses variant-suffixed profile names (e.g. "...@base" vs
"...@Anycubic Kobra X 0.4 nozzle") onto the same cleaned name, so a
collision is expected, but the resulting last-write-wins overwrite was
previously silent, making an unexpected inherits-parent resolution hard to
debug.

New tests in tests/test_orca_filaments_parser.py add the first dedicated
coverage for parse_profile()/parse_profile_bytes()/clean_name() - previous
tests only used pre-parsed profile dicts as fixtures and never exercised
the parsing logic itself.
2026-08-04 21:23:13 +02:00
60156fd589 fix(config): don't crash the whole bridge on a typo'd numeric config value
Found during a targeted code review, not from a user report.

MQTT_PORT, POLL_INTERVAL, and the other numeric module-level shortcuts in
config_loader.py ran int(get(...)) unguarded at import time. A hand-edited
config.ini with a typo (e.g. "mqtt_port = 98833x") raised an uncaught
ValueError before the bridge even started, with a raw traceback instead of
a usable diagnostic - list_printers() already guarded this exact class of
input the same way, but the module-level constants didn't. Added
_safe_int() (same try/except-with-fallback pattern) and applied it
everywhere int() was called unguarded on a config value.

Also wrapped migrate_env_to_config()'s filesystem writes (which also run
at import time during first-run .env migration) in try/except, so a
permission or disk-full error logs a clear message before re-raising
instead of surfacing as a bare traceback pointing into configparser.

New tests in tests/test_config_loader_robustness.py cover both, including
an end-to-end subprocess test that imports config_loader against a
malformed config.ini (a plain re-import wouldn't re-exercise the
import-time code path due to Python's module caching).
2026-08-04 21:22:54 +02:00
a106e4ab68 fix(bridge): camera process-race and unhandled JSON errors in two handlers
Found during a targeted code review, not from a user report:

- _run_jpeg_loop()/_run_h264_loop() operated directly on the shared
  self._proc_jpeg/self._proc_h264 instance attributes in their cleanup,
  unlike _run_mjpeg_loop() (already fixed for exactly this) which uses a
  local `proc` reference. If a loop's task is cancelled - e.g. by
  CameraCache.reset() after the printer rotates its stream URL on reboot -
  while a new task has already started and assigned its own process to the
  shared attribute, the cancelled task's cleanup killed the NEWER process
  instead of its own, leaking its own ffmpeg child as an orphan. Applied
  the same local-variable + identity-check pattern already used by
  _run_mjpeg_loop.

- handle_api_settings_post and handle_api_update_apply were the only two
  of ~84 handlers that called `await request.json()` without a try/except -
  every other handler follows the established pattern of returning a clean
  400 for a malformed body. A trivial malformed request to either endpoint
  produced an unhandled 500 with a full traceback instead.

New tests in tests/test_camera_process_race.py,
tests/test_settings.py, and tests/test_update_check.py cover both.
2026-08-04 21:22:54 +02:00
8384c69836 fix(mqtt): harden kobrax_client.py against malformed data and concurrent requests
Found during a targeted code review, not from a user report:

- _drain() could get permanently stuck: valid JSON that isn't an object
  (e.g. a bare number or list, which json.loads() happily accepts) crashed
  _dispatch()'s dict-oriented logic, and the exception escaped before
  self._buf was advanced past the bad packet - so the same malformed bytes
  sat at the front of the buffer and re-crashed every subsequent _drain()
  call, forcing a reconnect each time. Now the buffer always advances (via
  try/finally) and _dispatch() rejects non-dict payloads with a warning log
  instead of crashing.

- _pending_msgid/_pending_report were mutated without synchronization
  while the reader thread reads/resolves them in _dispatch() - a
  check-then-set race on the shared report_key slot meant two concurrent
  publish() calls for the same msg_type could have one silently miss its
  reply. Added a dedicated lock around all registration/cleanup.

- The report-topic resolution path never verified a reply's msgid matched
  the waiter it was about to resolve, so a late reply for an
  already-timed-out request could be delivered to an unrelated, newer
  caller waiting on the same report_key. Entries now carry their own msgid
  for this comparison; replies without a msgid still resolve normally
  (most printer push-reports don't carry one).

- upload_gcode() silently sent a request with an empty session token when
  the upload URL was missing "?s=", instead of raising a clear error at
  the actual point of failure. It also leaked the upload socket's file
  descriptor on any send/recv failure other than a timeout, since there
  was no try/finally around its lifetime.

New tests in tests/test_client_robustness.py cover all of the above.
2026-08-04 21:22:54 +02:00
gitea-actions
a360b463f9 chore: reset NIGHTLY_CHANGELOG.md after nightly-0.9.30-nightly10 release
All checks were successful
Nightly Build / build (push) Successful in 8m36s
2026-08-04 12:51:58 +00:00
11 changed files with 825 additions and 127 deletions

View File

@@ -1,5 +1,2 @@
## Changes in this build
- Fix: **combined ACE-RFID filament tags (e.g. "GEEETECH PLA Bas" from third-party RFID tools) still weren't auto-matching to imported OrcaSlicer profiles**, even after the first attempt at this in a previous nightly — the matching logic was only ever wired into the OrcaSlicer slicer-sync endpoint, never into the actual MQTT status path that feeds the dashboard and Happy-Hare gate data, so the dashboard kept showing the raw unmatched RFID string. Centralized the matching so all three places that resolve a slot's filament profile benefit identically. Also added variant-token disambiguation (e.g. "Bas" vs. "Matte") for when a vendor has multiple profiles of the same material, and fixed a related edge case where a manual per-slot override could be incorrectly treated as stale on an RFID-tagged spool (Issue #101, thanks @Blaim for the extensive debugging that pinpointed this).
- Feat: new setting under Settings → Print — "Delete file from printer after successful print" — automatically removes a GCode file from the printer's own storage once it finishes printing successfully, keeping only the copy in the bridge's own GCode store. Off by default, and only ever applies to files that were uploaded through the bridge itself (so there's always a backup); files started directly from the printer or Anycubic Slicer are never touched.
- Fix: **the dashboard could stay stuck showing a printer as online/"ready" indefinitely after it was physically switched off or unplugged**, discovered while testing the new smart-plug power-switch feature. Root cause was two-fold: the MQTT socket had no TCP keepalive, so a connection killed without a clean close (unplugged, not a graceful shutdown) could look alive to the OS for 15+ minutes; and even once the dead connection was detected, the status poll loop could get stuck waiting on a reconnect attempt that was already running elsewhere, so it never reached the code that flips the dashboard to "offline". Live-tested against a real printer, including that no sockets, threads, or file descriptors are left behind across repeated disconnect/reconnect cycles — a disconnected printer is now detected and reflected on the dashboard within about 15 seconds.

View File

@@ -7,8 +7,11 @@ import os
import sys
import pathlib
import configparser
import logging
from typing import Optional
log = logging.getLogger("kobrax.config")
_BASE = pathlib.Path(sys.executable).parent if getattr(sys, "frozen", False) else pathlib.Path(__file__).parent
CONFIG_SECTION_CONNECTION = "connection"
@@ -116,7 +119,6 @@ def migrate_env_to_config(env_path: pathlib.Path, config_path: pathlib.Path):
k, _, v = line.partition("=")
env_vals[k.strip()] = v.strip()
config_path.parent.mkdir(parents=True, exist_ok=True)
cfg = configparser.ConfigParser(interpolation=None)
cfg[CONFIG_SECTION_CONNECTION] = {
"printer_ip": env_vals.get("PRINTER_IP", ""),
@@ -136,10 +138,20 @@ def migrate_env_to_config(env_path: pathlib.Path, config_path: pathlib.Path):
cfg[CONFIG_SECTION_BRIDGE] = {
"poll_interval": "3",
}
with open(config_path, "w", encoding="utf-8") as f:
f.write("# KX-Bridge Konfigurationsdatei\n")
f.write("# Automatically migrated from .env\n\n")
cfg.write(f)
# This runs at module import time (see the "Laden" section below) - an
# uncaught mkdir/write failure (e.g. a read-only filesystem) would crash
# the whole bridge at startup with a raw traceback. Log a clear diagnostic
# before re-raising, so the actual cause (permissions, disk full) is
# visible instead of a bare stack trace pointing into configparser.
try:
config_path.parent.mkdir(parents=True, exist_ok=True)
with open(config_path, "w", encoding="utf-8") as f:
f.write("# KX-Bridge Konfigurationsdatei\n")
f.write("# Automatically migrated from .env\n\n")
cfg.write(f)
except OSError as e:
log.error("Failed to write migrated config.ini to %s: %s", config_path, e)
raise
def find_config_path() -> pathlib.Path:
@@ -445,9 +457,25 @@ def get(key: str, default: str = "") -> str:
return os.environ.get(key, default)
def _safe_int(value: str, default: int) -> int:
"""Falls back to `default` instead of raising on a non-numeric value.
All of these run at module import time - an uncaught ValueError here
(e.g. from a hand-edited config.ini with a typo like `mqtt_port = 98833x`)
would crash the entire bridge before it even starts, with a raw traceback
instead of a clear diagnostic. list_printers() already guards this same
class of input the same way; this applies it to the module-level
shortcuts too."""
try:
return int(value)
except (TypeError, ValueError):
log.warning("config: expected a number, got %r - using default %r", value, default)
return default
# Frequently used shortcuts
PRINTER_IP = get("PRINTER_IP", "")
MQTT_PORT = int(get("MQTT_PORT", "9883"))
MQTT_PORT = _safe_int(get("MQTT_PORT", "9883"), 9883)
USERNAME = get("MQTT_USERNAME", "")
PASSWORD = get("MQTT_PASSWORD", "")
MODE_ID = get("MODE_ID", "")
@@ -456,14 +484,14 @@ POWER_ON_URL = get("POWER_ON_URL", "")
POWER_OFF_URL = get("POWER_OFF_URL", "")
POWER_STATUS_URL = get("POWER_STATUS_URL", "")
DEFAULT_AMS_SLOT = get("DEFAULT_AMS_SLOT", "auto")
AUTO_LEVELING = int(get("AUTO_LEVELING", "1"))
VIBRATION_COMPENSATION = int(get("VIBRATION_COMPENSATION", "0"))
CAMERA_ON_PRINT = int(get("CAMERA_ON_PRINT", "0"))
WEB_UPLOAD_WARNING = int(get("WEB_UPLOAD_WARNING", "1"))
DELETE_PRINTER_FILE_AFTER_PRINT = int(get("DELETE_PRINTER_FILE_AFTER_PRINT", "0"))
PRINT_START_DIALOG = int(get("PRINT_START_DIALOG", get("FILE_READY_DIALOG", "1")))
AUTO_LEVELING = _safe_int(get("AUTO_LEVELING", "1"), 1)
VIBRATION_COMPENSATION = _safe_int(get("VIBRATION_COMPENSATION", "0"), 0)
CAMERA_ON_PRINT = _safe_int(get("CAMERA_ON_PRINT", "0"), 0)
WEB_UPLOAD_WARNING = _safe_int(get("WEB_UPLOAD_WARNING", "1"), 1)
DELETE_PRINTER_FILE_AFTER_PRINT = _safe_int(get("DELETE_PRINTER_FILE_AFTER_PRINT", "0"), 0)
PRINT_START_DIALOG = _safe_int(get("PRINT_START_DIALOG", get("FILE_READY_DIALOG", "1")), 1)
SPOOLMAN_SERVER = get("SPOOLMAN_SERVER", "")
SPOOLMAN_SYNC_RATE = int(get("SPOOLMAN_SYNC_RATE", "0"))
SPOOLMAN_SYNC_RATE = _safe_int(get("SPOOLMAN_SYNC_RATE", "0"), 0)
BRIDGE_HOST_IP = get("BRIDGE_HOST_IP", "")
POLL_INTERVAL = int(get("POLL_INTERVAL", "3"))
VERBOSE_HTTP_LOG = int(get("VERBOSE_HTTP_LOG", "0"))
POLL_INTERVAL = _safe_int(get("POLL_INTERVAL", "3"), 3)
VERBOSE_HTTP_LOG = _safe_int(get("VERBOSE_HTTP_LOG", "0"), 0)

View File

@@ -178,6 +178,14 @@ class KobraXClient:
self._pending_msgid: dict[str, dict] = {}
# Pending requests by msg_type/report topic suffix
self._pending_report: dict[str, dict] = {}
# Guards _pending_msgid/_pending_report against concurrent mutation:
# the reader thread resolves entries in _dispatch() while publish()
# (called from the poll loop and, via run_in_executor, HTTP handler
# threads) registers/cleans them up - without this, two concurrent
# publish() calls for the same msg_type can race on the
# check-then-set for a report_key slot, and _dispatch() could observe
# a dict mid-mutation.
self._pending_lock = threading.Lock()
# Optional callbacks: topic_suffix → callable(payload_dict)
self.callbacks: dict[str, callable] = {}
@@ -446,34 +454,46 @@ class KobraXClient:
def _drain(self):
buf = self._buf
idx = 0
while idx < len(buf):
ptype = buf[idx] & 0xF0
i = idx + 1
mul = 1
rem = 0
while i < len(buf):
b = buf[i]
rem += (b & 0x7F) * mul
mul *= 128
i += 1
if not (b & 0x80):
try:
while idx < len(buf):
ptype = buf[idx] & 0xF0
i = idx + 1
mul = 1
rem = 0
while i < len(buf):
b = buf[i]
rem += (b & 0x7F) * mul
mul *= 128
i += 1
if not (b & 0x80):
break
if i + rem > len(buf):
break
if i + rem > len(buf):
break
pkt = buf[i:i + rem]
idx = i + rem
pkt = buf[i:i + rem]
idx = i + rem
if ptype == 0x30:
topic, raw_payload = _parse_publish(pkt)
if topic is None:
continue
try:
payload = json.loads(raw_payload)
except Exception:
payload = {"_raw": raw_payload.decode("utf-8", errors="replace")}
self._dispatch(topic, payload)
self._buf = buf[idx:]
if ptype == 0x30:
topic, raw_payload = _parse_publish(pkt)
if topic is None:
continue
try:
payload = json.loads(raw_payload)
except Exception:
payload = {"_raw": raw_payload.decode("utf-8", errors="replace")}
try:
self._dispatch(topic, payload)
except Exception as e:
# A single malformed/unexpected message (e.g. valid JSON
# that isn't an object, like a bare number or list) must
# not be reprocessed forever: without this, an exception
# here would skip the buffer-advance below, leaving the
# same bad packet at the front of self._buf so every
# future _drain() call crashes on it again - each one
# forcing a reconnect via the reader loop's exception
# handler, an endless self-inflicted reconnect loop.
log.warning("dispatch error for %s: %s", topic, e)
finally:
self._buf = buf[idx:]
def _dedup_hash(self, suffix: str, payload: dict) -> str:
"""Hash payload ignoring volatile per-tick fields for dedup check."""
@@ -484,6 +504,9 @@ class KobraXClient:
return hashlib.md5(json.dumps(stable, sort_keys=True).encode(), usedforsecurity=False).hexdigest()
def _dispatch(self, topic: str, payload: dict):
if not isinstance(payload, dict):
log.warning("dispatch: non-dict payload on %s: %r", topic, payload)
return
suffix = "/".join(topic.split("/")[-2:])
if self._raw_log:
@@ -511,18 +534,29 @@ class KobraXClient:
log.info("RX %-25s state=%-12s data=%s",
suffix, state, json.dumps(payload.get("data"), ensure_ascii=False))
# Resolve by report topic suffix (e.g. "info/report")
if suffix in self._pending_report:
entry = self._pending_report[suffix]
entry["result"] = payload
entry["event"].set()
msgid = payload.get("msgid")
with self._pending_lock:
report_entry = self._pending_report.get(suffix)
msgid_entry = self._pending_msgid.get(msgid) if msgid else None
# Resolve by report topic suffix (e.g. "info/report"). If the payload
# carries a msgid that doesn't match what this waiter is actually
# expecting, it's a stale/late reply for a different, already-timed-out
# request that happens to share the same report_key - don't deliver it
# to the wrong caller.
if report_entry is not None:
entry_msgid = report_entry.get("msgid")
if not entry_msgid or not msgid or entry_msgid == msgid:
report_entry["result"] = payload
report_entry["event"].set()
else:
log.debug("dispatch: msgid mismatch for %s report (waiting=%s, got=%s) - ignoring stale reply",
suffix, entry_msgid, msgid)
# Resolve by msgid (for generic response ACK)
msgid = payload.get("msgid")
if msgid and msgid in self._pending_msgid:
entry = self._pending_msgid[msgid]
entry["result"] = payload
entry["event"].set()
if msgid_entry is not None:
msgid_entry["result"] = payload
msgid_entry["event"].set()
# User callbacks by topic suffix (last two path components)
if suffix in self.callbacks:
@@ -558,13 +592,18 @@ class KobraXClient:
# Also register by report topic as fallback for responses without msgid.
report_key = f"{msg_type}/report"
event = threading.Event()
entry = {"event": event, "result": None}
self._pending_msgid[msgid] = entry
# Only register report-key waiter if nobody else is waiting on it
# entry carries its own msgid so _dispatch()'s report-suffix path can
# confirm a reply actually belongs to THIS request before delivering
# it - without that, a late reply for an already-timed-out request A
# could be handed to a newer request B waiting on the same report_key.
entry = {"event": event, "result": None, "msgid": msgid}
report_registered = False
if report_key not in self._pending_report:
self._pending_report[report_key] = entry
report_registered = True
with self._pending_lock:
self._pending_msgid[msgid] = entry
# Only register report-key waiter if nobody else is waiting on it
if report_key not in self._pending_report:
self._pending_report[report_key] = entry
report_registered = True
topic = self._pub_topic(msg_type)
# Status poll TX (query/getInfo) is pure noise (every few seconds) ->
@@ -578,9 +617,10 @@ class KobraXClient:
self._sock.sendall(_build_publish(topic, payload))
except Exception as e:
log.error("send error: %s, reconnecting…", e)
self._pending_msgid.pop(msgid, None)
if report_registered:
self._pending_report.pop(report_key, None)
with self._pending_lock:
self._pending_msgid.pop(msgid, None)
if report_registered:
self._pending_report.pop(report_key, None)
# Non-blocking: never hang the poll-loop thread inside publish()
# while a reconnect is running / during backoff (see _reconnect
# docstring) - it must return so kobra_state can flip to "offline".
@@ -590,22 +630,25 @@ class KobraXClient:
try:
with self._lock:
self._sock.sendall(_build_publish(topic, payload))
self._pending_msgid[msgid] = entry
if report_registered:
self._pending_report[report_key] = entry
with self._pending_lock:
self._pending_msgid[msgid] = entry
if report_registered:
self._pending_report[report_key] = entry
except Exception:
return None
if timeout <= 0:
self._pending_msgid.pop(msgid, None)
if report_registered:
self._pending_report.pop(report_key, None)
with self._pending_lock:
self._pending_msgid.pop(msgid, None)
if report_registered:
self._pending_report.pop(report_key, None)
return None
received = event.wait(timeout)
self._pending_msgid.pop(msgid, None)
if report_registered:
self._pending_report.pop(report_key, None)
with self._pending_lock:
self._pending_msgid.pop(msgid, None)
if report_registered:
self._pending_report.pop(report_key, None)
if not received:
return None
return entry["result"]
@@ -710,7 +753,9 @@ class KobraXClient:
raise RuntimeError("Could not get info/report for upload URL")
upload_url = info["data"]["urls"]["fileUploadurl"]
# parse token from URL query string
token = upload_url.split("?s=")[1] if "?s=" in upload_url else ""
if "?s=" not in upload_url:
raise RuntimeError(f"Upload: no session token ('?s=') in upload URL: {upload_url!r}")
token = upload_url.split("?s=")[1]
with open(filepath, "rb") as f:
file_data = f.read()
@@ -760,19 +805,25 @@ class KobraXClient:
# (the printer processes the file before replying).
_ai = socket.getaddrinfo(self.host, 18910, socket.AF_INET, socket.SOCK_STREAM)
sock = socket.create_connection(_ai[0][4], timeout=10)
sock.settimeout(None) # blocking during send
sock.sendall(headers + body)
sock.settimeout(180)
response = b""
try:
while True:
chunk = sock.recv(65536)
if not chunk:
break
response += chunk
except socket.timeout:
pass
sock.close()
sock.settimeout(None) # blocking during send
sock.sendall(headers + body)
sock.settimeout(180)
response = b""
try:
while True:
chunk = sock.recv(65536)
if not chunk:
break
response += chunk
except socket.timeout:
pass
finally:
# Without this, a sendall()/recv() failure other than
# socket.timeout (e.g. ConnectionResetError/BrokenPipeError if
# the printer drops the connection mid-upload) skipped
# sock.close() entirely, leaking the fd on every failed attempt.
sock.close()
# parse HTTP response body
if b"\r\n\r\n" in response:

View File

@@ -713,7 +713,7 @@ class CameraCache:
await asyncio.sleep(2.0)
continue
try:
self._proc_jpeg = await asyncio.create_subprocess_exec(
proc = await asyncio.create_subprocess_exec(
_find_ffmpeg(), "-loglevel", "warning",
*self._input_args(url), "-i", url,
"-vf", "fps=2",
@@ -722,6 +722,7 @@ class CameraCache:
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
self._proc_jpeg = proc
except Exception as e:
log.warning(f"CameraCache: ffmpeg-jpeg start failed: {e}")
await asyncio.sleep(3.0)
@@ -731,7 +732,7 @@ class CameraCache:
rc = None
try:
while True:
chunk = await self._proc_jpeg.stdout.read(self.TS_CHUNK)
chunk = await proc.stdout.read(self.TS_CHUNK)
if not chunk:
break
buf += chunk
@@ -751,26 +752,31 @@ class CameraCache:
except Exception as e:
log.debug(f"CameraCache: jpeg-loop unterbrochen: {e}")
finally:
# Kill + wait - otherwise the child process lingers as a zombie and
# asyncio reports "Unknown child pid ..." on the next reaper tick.
if self._proc_jpeg is not None:
# NOTE: cleanup operates on the local `proc` reference, not on
# self._proc_jpeg - see _run_mjpeg_loop's identical comment.
# If this task got cancelled (e.g. by reset()), a new task may
# already have started and assigned its own process to
# self._proc_jpeg by the time we reach here; killing that
# shared attribute instead of our own local proc would kill
# the WRONG (newer) process and leak this one as an orphan.
try:
proc.kill()
except Exception:
pass
try:
await proc.wait()
except Exception:
pass
rc = proc.returncode
if rc:
try:
self._proc_jpeg.kill()
err = await proc.stderr.read(500)
if err:
log.warning(f"CameraCache: ffmpeg-jpeg stderr: {err.decode(errors='replace').strip()}")
except Exception:
pass
try:
await self._proc_jpeg.wait()
except Exception:
pass
rc = self._proc_jpeg.returncode
if rc:
try:
err = await self._proc_jpeg.stderr.read(500)
if err:
log.warning(f"CameraCache: ffmpeg-jpeg stderr: {err.decode(errors='replace').strip()}")
except Exception:
pass
self._proc_jpeg = None
if self._proc_jpeg is proc:
self._proc_jpeg = None
if rc:
self._fail_count_jpeg += 1
delay = min(2.0 * (2 ** self._fail_count_jpeg), 300.0)
@@ -788,7 +794,7 @@ class CameraCache:
await asyncio.sleep(2.0)
continue
try:
self._proc_h264 = await asyncio.create_subprocess_exec(
proc = await asyncio.create_subprocess_exec(
_find_ffmpeg(), "-loglevel", "warning",
*self._input_args(url), "-i", url,
"-c:v", "copy", "-an",
@@ -796,6 +802,7 @@ class CameraCache:
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
self._proc_h264 = proc
except Exception as e:
log.warning(f"CameraCache: ffmpeg-h264 start failed: {e}")
await asyncio.sleep(3.0)
@@ -804,7 +811,7 @@ class CameraCache:
rc = None
try:
while True:
chunk = await self._proc_h264.stdout.read(self.TS_CHUNK)
chunk = await proc.stdout.read(self.TS_CHUNK)
if not chunk:
break
# Fanout: non-blocking per subscriber; slow clients
@@ -822,24 +829,31 @@ class CameraCache:
except Exception as e:
log.debug(f"CameraCache: h264-loop unterbrochen: {e}")
finally:
if self._proc_h264 is not None:
# NOTE: cleanup operates on the local `proc` reference, not on
# self._proc_h264 - see _run_mjpeg_loop's identical comment.
# If this task got cancelled (e.g. by reset()), a new task may
# already have started and assigned its own process to
# self._proc_h264 by the time we reach here; killing that
# shared attribute instead of our own local proc would kill
# the WRONG (newer) process and leak this one as an orphan.
try:
proc.kill()
except Exception:
pass
try:
await proc.wait()
except Exception:
pass
rc = proc.returncode
if rc:
try:
self._proc_h264.kill()
err = await proc.stderr.read(500)
if err:
log.warning(f"CameraCache: ffmpeg-h264 stderr: {err.decode(errors='replace').strip()}")
except Exception:
pass
try:
await self._proc_h264.wait()
except Exception:
pass
rc = self._proc_h264.returncode
if rc:
try:
err = await self._proc_h264.stderr.read(500)
if err:
log.warning(f"CameraCache: ffmpeg-h264 stderr: {err.decode(errors='replace').strip()}")
except Exception:
pass
self._proc_h264 = None
if self._proc_h264 is proc:
self._proc_h264 = None
if rc:
self._fail_count_h264 += 1
delay = min(2.0 * (2 ** self._fail_count_h264), 300.0)
@@ -5093,7 +5107,10 @@ class KobraXBridge:
async def handle_api_settings_post(self, request):
import configparser
data = await request.json()
try:
data = await request.json()
except Exception:
return self._json_cors({"error": "invalid json"}, status=400)
config_path = self._find_config_path()
config_path.parent.mkdir(parents=True, exist_ok=True)
@@ -5434,6 +5451,21 @@ class KobraXBridge:
async def handle_api_update_check(self, request):
current = self._read_version()
# Testing channel (testing-<sha>) has no Gitea releases at all - it's
# a Docker-only channel. Report that directly instead of falling
# through to the stable path (which would wrongly offer a stable
# "update"). The :testing image is rolling, so there's nothing to
# compare a version against.
if "testing" in current:
return web.json_response({
"current": current,
"latest": current,
"update_available": False,
"tag": current,
"docker_only": True,
"changelog": "Testing channel - updates are delivered via Docker: "
"docker compose pull && docker compose up -d",
})
is_nightly = "nightly" in current
is_dev = "-dev+" in current
if is_nightly:
@@ -5513,11 +5545,16 @@ class KobraXBridge:
]
async def handle_api_update_apply(self, request):
data = await request.json()
try:
data = await request.json()
except Exception:
return web.json_response({"error": "invalid json"}, status=400)
new_tag = data.get("tag", "")
if "nightly" in self._read_version():
_cur = self._read_version()
if "nightly" in _cur or "testing" in _cur:
channel = "testing" if "testing" in _cur else "nightly"
return web.json_response(
{"error": "nightly updates are delivered via Docker: "
{"error": f"{channel} updates are delivered via Docker: "
"docker compose pull && docker compose up -d"}, status=400)
if getattr(sys, "frozen", False):
return web.json_response(

View File

@@ -9,8 +9,11 @@ them as a normalized list with (id, name, vendor, type, color).
from __future__ import annotations
import json
import logging
import re
log = logging.getLogger("kobrax.filaments")
def first_str(value, default: str = "") -> str:
"""Orca profiles store some fields as ['value']. Returns the first
@@ -69,7 +72,17 @@ def parse_profile(data: dict, by_name: dict | None = None,
if system_index:
for p in system_index:
if isinstance(p, dict) and p.get("name"):
sys_by_name[p["name"]] = p
pname = p["name"]
if pname in sys_by_name and sys_by_name[pname] is not p:
# clean_name() deliberately collapses variant-suffixed
# names (e.g. "...@base" vs "...@Anycubic Kobra X 0.4
# nozzle") onto the same cleaned name - expected, but the
# last-write-wins overwrite here was previously silent,
# making an unexpected inherits-parent resolution hard to
# debug.
log.debug("orca_filaments: duplicate system profile name %r - "
"overwriting %r with %r", pname, sys_by_name[pname].get("id"), p.get("id"))
sys_by_name[pname] = p
def _resolve(key: str, depth: int = 5):
cur_list = [data]
@@ -119,7 +132,7 @@ def parse_profile(data: dict, by_name: dict | None = None,
if not fid or not isinstance(fid, str):
return None
name_raw = data.get("name", fid)
name_raw = first_str(data.get("name"), fid)
name = clean_name(name_raw)
vendor = first_str(_resolve_full("filament_vendor")) or (path_vendor or "Generic")
ftype = first_str(_resolve_full("filament_type"), "")

View File

@@ -0,0 +1,83 @@
"""Camera ffmpeg process-handle race (code review finding).
_run_jpeg_loop()/_run_h264_loop() used to operate on the shared instance
attribute (self._proc_jpeg / self._proc_h264) in their cleanup, instead of a
local reference to the process they themselves started - the same bug
_run_mjpeg_loop() already had fixed with a documented local-`proc` pattern.
If a loop's task is cancelled (e.g. via CameraCache.reset() after a stream
URL rotation) while a new task has already started and assigned its own
process to the shared attribute, the cancelled task's cleanup would kill
and null out the NEWER process instead of its own - leaking its own actual
ffmpeg child as an orphan that nothing ever cleans up again.
"""
import asyncio
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from kobrax_moonraker_bridge import CameraCache
def _fake_proc(name):
"""A minimal stand-in for asyncio.subprocess.Process good enough to
drive _run_jpeg_loop()'s read/kill/wait/returncode usage."""
proc = MagicMock(name=name)
proc.returncode = 0
proc.kill = MagicMock()
proc.wait = AsyncMock()
proc.stdout = MagicMock()
proc.stderr = MagicMock()
proc.stderr.read = AsyncMock(return_value=b"")
return proc
@pytest.mark.asyncio
async def test_jpeg_loop_cleanup_does_not_kill_a_newer_process():
cache = CameraCache()
cache._url = "http://printer/live/streamtoken"
old_proc = _fake_proc("old")
new_proc = _fake_proc("new")
# old_proc's stdout.read blocks forever until cancelled - simulating the
# loop being stuck reading from a stale connection, same as the real bug.
stuck = asyncio.Event()
async def old_stdout_read(_n):
await stuck.wait()
return b""
old_proc.stdout.read = old_stdout_read
create_calls = []
async def fake_create_subprocess_exec(*args, **kwargs):
create_calls.append(1)
return old_proc if len(create_calls) == 1 else new_proc
with patch("kobrax_moonraker_bridge.asyncio.create_subprocess_exec",
side_effect=fake_create_subprocess_exec), \
patch("kobrax_moonraker_bridge._find_ffmpeg", return_value="ffmpeg"):
task = asyncio.create_task(cache._run_jpeg_loop())
# Let the loop start and assign old_proc to the shared attribute.
await asyncio.sleep(0.05)
assert cache._proc_jpeg is old_proc
# Simulate a second loop iteration's process already having been
# assigned to the shared attribute before the cancelled task's
# cleanup runs - the exact race window from the bug report.
cache._proc_jpeg = new_proc
task.cancel()
try:
await asyncio.wait_for(task, timeout=2.0)
except (asyncio.CancelledError, asyncio.TimeoutError):
pass
# The cancelled task must have killed/waited on ITS OWN process (old_proc),
# not the newer one that had already taken over the shared attribute.
old_proc.kill.assert_called_once()
new_proc.kill.assert_not_called()
# And it must not have clobbered the newer process's slot.
assert cache._proc_jpeg is new_proc

View File

@@ -0,0 +1,189 @@
"""Robustness fixes found during a targeted code review of kobrax_client.py:
1. _drain()/_dispatch() must not get stuck reprocessing the same malformed
packet forever when the printer sends valid JSON that isn't an object
(e.g. a bare number or list) - previously an exception from _dispatch()
escaped before self._buf was advanced, so the same bytes sat at the
front of the buffer and crashed every subsequent _drain() call, each one
forcing a reconnect via the reader loop's exception handler.
2. publish()'s pending-dict registration/cleanup must be safe against
concurrent callers for the same msg_type, and a stale/late reply must
not be delivered to a newer, unrelated caller waiting on the same
report-topic suffix.
3. upload_gcode() must raise a clear error for a malformed upload URL
(missing "?s=") instead of silently sending an unauthenticated request,
and must not leak the upload socket on a send/recv failure.
"""
import argparse
import json
import socket
import tempfile
import threading
import time
from unittest.mock import MagicMock, patch
import pytest
from kobrax_client import KobraXClient, _build_publish
def _client(**overrides):
kwargs = dict(
host="192.168.1.100", username="u", password="p",
mode_id="20030", device_id="abc123", port=9883,
client_id="test",
)
kwargs.update(overrides)
return KobraXClient(**kwargs)
def _frame(topic: str, payload) -> bytes:
"""Builds a raw MQTT PUBLISH frame carrying `payload` as JSON, the same
wire format _drain() parses."""
return _build_publish(topic, json.dumps(payload))
# --- Fix 1: malformed (non-dict) JSON payload must not wedge the buffer ---
def test_drain_advances_buffer_past_non_dict_payload():
c = _client()
# A bare JSON number is valid JSON but not a dict - json.loads succeeds,
# but _dispatch()'s dict-oriented logic would previously crash on it.
bad = _frame("anycubic/anycubicCloud/v1/printer/public/20030/abc123/info/report", 42)
good = _frame("anycubic/anycubicCloud/v1/printer/public/20030/abc123/info/report", {"state": "done"})
c._buf = bad + good
c._drain() # must not raise
# Both packets must have been consumed - the malformed one logged/skipped,
# the buffer advanced past it so the following valid packet is also
# processed, not left stuck behind it.
assert c._buf == b""
def test_drain_does_not_reprocess_bad_packet_on_repeated_calls():
"""Regression guard for the original bug: before the fix, a crash in
_dispatch() left self._buf untouched, so the bad packet stayed at the
front and every _drain() call re-crashed on it."""
c = _client()
bad = _frame("anycubic/anycubicCloud/v1/printer/public/20030/abc123/info/report", [1, 2, 3])
c._buf = bad
c._drain()
assert c._buf == b"" # consumed, not stuck
# A second call on the now-empty buffer must be a no-op, not a re-crash.
c._drain()
assert c._buf == b""
def test_dispatch_rejects_non_dict_payload_directly():
c = _client()
c._dispatch("anycubic/anycubicCloud/v1/printer/public/20030/abc123/info/report", "not a dict")
# No exception, no registered pending entries get corrupted.
assert c._pending_report == {}
# --- Fix 2: pending-dict thread-safety + msgid correlation ---
def test_publish_concurrent_same_msg_type_both_get_delivered():
"""Two overlapping publish() calls for the same msg_type must each
receive their OWN reply, not have one silently miss out because the
other already claimed the shared report_key slot."""
c = _client()
c._running = True
c._sock = MagicMock()
c._ensure_reader = lambda: None # no real reader thread needed for this test
results = {}
def call(label):
results[label] = c.publish("info", "query", timeout=2.0)
t1 = threading.Thread(target=call, args=("a",))
t2 = threading.Thread(target=call, args=("b",))
t1.start()
time.sleep(0.02)
t2.start()
time.sleep(0.05)
# Simulate the printer replying to whichever msgid-bearing requests are
# currently pending, by msgid (the reliable path both requests can use).
with c._pending_lock:
pending = dict(c._pending_msgid)
for msgid, entry in pending.items():
c._dispatch("anycubic/anycubicCloud/v1/printer/public/20030/abc123/info/report",
{"msgid": msgid, "state": "done", "data": {"ok": True}})
t1.join(timeout=3)
t2.join(timeout=3)
assert results["a"] is not None
assert results["b"] is not None
def test_dispatch_ignores_stale_reply_with_mismatched_msgid():
"""A late reply carrying a msgid that doesn't match what the current
report_key waiter is expecting must not be delivered to it - it belongs
to an earlier, already-resolved/timed-out request."""
c = _client()
c._running = True
c._sock = MagicMock()
event = threading.Event()
entry = {"event": event, "result": None, "msgid": "the-real-one"}
with c._pending_lock:
c._pending_report["info/report"] = entry
# A stale reply for a different msgid must not resolve this waiter.
c._dispatch("anycubic/anycubicCloud/v1/printer/public/20030/abc123/info/report",
{"msgid": "some-other-stale-id", "state": "done"})
assert not event.is_set()
assert entry["result"] is None
# The matching reply must resolve it.
c._dispatch("anycubic/anycubicCloud/v1/printer/public/20030/abc123/info/report",
{"msgid": "the-real-one", "state": "done"})
assert event.is_set()
assert entry["result"]["msgid"] == "the-real-one"
def test_dispatch_delivers_reply_without_msgid_as_before():
"""Regression guard: plenty of printer reports carry no msgid at all
(e.g. spontaneous status pushes) - those must still resolve a waiter
registered without one (entry["msgid"] is falsy)."""
c = _client()
event = threading.Event()
entry = {"event": event, "result": None, "msgid": None}
with c._pending_lock:
c._pending_report["info/report"] = entry
c._dispatch("anycubic/anycubicCloud/v1/printer/public/20030/abc123/info/report",
{"state": "done"})
assert event.is_set()
# --- Fix 3: upload_gcode() URL validation + socket cleanup ---
def test_upload_gcode_raises_clear_error_on_missing_session_token(tmp_path):
c = _client()
f = tmp_path / "test.gcode"
f.write_text("; gcode")
with pytest.raises(RuntimeError, match="session token"):
c.upload_gcode(str(f), upload_url="http://192.168.1.100:18910/gcode_upload")
def test_upload_gcode_closes_socket_on_send_failure(tmp_path):
c = _client()
f = tmp_path / "test.gcode"
f.write_text("; gcode")
fake_sock = MagicMock()
fake_sock.sendall.side_effect = ConnectionResetError("printer went away")
with patch("socket.create_connection", return_value=fake_sock):
with pytest.raises(ConnectionResetError):
c.upload_gcode(str(f), upload_url="http://192.168.1.100:18910/gcode_upload?s=tok123")
fake_sock.close.assert_called_once()

View File

@@ -0,0 +1,66 @@
"""config_loader.py robustness fixes found during code review:
1. _safe_int() must fall back to a default instead of raising - the
module-level numeric shortcuts (MQTT_PORT, POLL_INTERVAL, etc.) run this
at import time, so an uncaught ValueError there previously crashed the
entire bridge on startup if config.ini had a typo'd numeric value
(e.g. "mqtt_port = 98833x"), with a raw traceback instead of a clear
diagnostic. list_printers() already guarded this same class of input the
same way; this applies it to the module-level shortcuts too.
2. migrate_env_to_config() must log a clear error (not a bare traceback)
if writing the migrated config.ini fails (e.g. permission error).
"""
import subprocess
import sys
import textwrap
import config_loader
def test_safe_int_returns_value_for_valid_numeric_string():
assert config_loader._safe_int("42", 0) == 42
def test_safe_int_falls_back_to_default_on_garbage():
assert config_loader._safe_int("98833x", 9883) == 9883
def test_safe_int_falls_back_to_default_on_empty_string():
assert config_loader._safe_int("", 3) == 3
def test_safe_int_falls_back_to_default_on_none():
assert config_loader._safe_int(None, 5) == 5
def test_config_loader_import_survives_malformed_config_ini(tmp_path):
"""End-to-end regression guard: importing config_loader with a
config.ini containing a non-numeric mqtt_port must not raise - it must
fall back to the default instead. Run in a subprocess since
config_loader executes its migration/loading logic at import time and
Python caches modules, so a plain re-import in this test process
wouldn't actually re-exercise the import-time code path."""
config_dir = tmp_path / "config"
config_dir.mkdir()
(config_dir / "config.ini").write_text(
"[connection]\n"
"printer_ip = 192.168.1.50\n"
"mqtt_port = 98833x\n" # malformed - not a number
)
script = textwrap.dedent(f"""
import sys
sys.path.insert(0, {str(tmp_path.parent.parent)!r})
import config_loader
config_loader._BASE = __import__("pathlib").Path({str(tmp_path)!r})
config_loader._find_config_file = lambda: {str(config_dir / "config.ini")!r} \
and __import__("pathlib").Path({str(config_dir / "config.ini")!r})
import importlib
importlib.reload(config_loader)
assert config_loader.MQTT_PORT == 9883, config_loader.MQTT_PORT
print("OK")
""")
result = subprocess.run([sys.executable, "-c", script], capture_output=True, text=True, timeout=10)
assert result.returncode == 0, f"stdout={result.stdout!r} stderr={result.stderr!r}"
assert "OK" in result.stdout

View File

@@ -0,0 +1,182 @@
"""orca_filaments.py parser robustness (code review finding).
No dedicated test file existed for parse_profile()/parse_profile_bytes()/
clean_name() before this - existing tests only used pre-parsed profile dicts
as fixtures, never exercised the actual parsing logic.
"""
import json
import logging
from orca_filaments import clean_name, first_str, parse_profile, parse_profile_bytes
def test_clean_name_strips_base_suffix():
assert clean_name("PolyTerra PLA @base") == "PolyTerra PLA"
def test_clean_name_strips_printer_and_nozzle_suffix():
assert clean_name("Anycubic PLA @Anycubic Kobra X 0.4 nozzle") == "Anycubic PLA"
def test_clean_name_strips_bare_nozzle_suffix():
assert clean_name("Anker Generic PLA 0.4 nozzle") == "Anker Generic PLA"
def test_clean_name_returns_raw_when_stripping_leaves_nothing():
"""An all-suffix name has nothing left after stripping - falls back to
the original raw string rather than returning an empty string."""
assert clean_name("@base") == "@base"
def test_first_str_unwraps_single_element_list():
assert first_str(["PLA"]) == "PLA"
def test_first_str_passes_through_plain_string():
assert first_str("PLA") == "PLA"
def test_first_str_returns_default_for_empty_list():
assert first_str([], "fallback") == "fallback"
def test_first_str_returns_default_for_other_types():
assert first_str(42, "fallback") == "fallback"
assert first_str(None, "fallback") == "fallback"
def test_parse_profile_rejects_non_dict():
assert parse_profile([1, 2, 3]) is None
assert parse_profile("not a dict") is None
assert parse_profile(None) is None
def test_parse_profile_rejects_stub_without_id_or_parent():
data = {"type": "filament"} # no inherits, no filament_id
assert parse_profile(data) is None
def test_parse_profile_rejects_instantiation_false():
data = {"type": "filament", "filament_id": "GFL01", "instantiation": "false"}
assert parse_profile(data) is None
def test_parse_profile_minimal_valid_profile():
data = {
"type": "filament",
"filament_id": "GFL01",
"name": "Generic PLA",
"filament_vendor": ["Generic"],
"filament_type": ["PLA"],
"default_filament_colour": ["#FFFFFF"],
}
result = parse_profile(data)
assert result == {
"id": "GFL01",
"name": "Generic PLA",
"vendor": "Generic",
"type": "PLA",
"color": "#FFFFFF",
}
def test_parse_profile_name_as_list_does_not_crash():
"""Regression guard: `name` wasn't previously routed through first_str()
like the other fields are, unlike filament_vendor/filament_type/
default_filament_colour just below it - a list value here used to raise
TypeError inside clean_name()'s re.sub()."""
data = {
"type": "filament",
"filament_id": "GFL02",
"name": ["Geeetech PLA Basic"],
"filament_vendor": ["Geeetech"],
"filament_type": ["PLA"],
}
result = parse_profile(data)
assert result is not None
assert result["name"] == "Geeetech PLA Basic"
def test_parse_profile_missing_name_falls_back_to_filament_id():
data = {"type": "filament", "filament_id": "GFL03", "filament_vendor": ["Generic"]}
result = parse_profile(data)
assert result["name"] == "GFL03"
def test_parse_profile_missing_optional_fields_default_to_empty_string():
data = {"type": "filament", "filament_id": "GFL04", "name": "Mystery Filament"}
result = parse_profile(data)
assert result["type"] == ""
assert result["color"] == ""
assert result["vendor"] == "Generic" # no path_vendor given either
def test_parse_profile_inherits_via_by_name():
parent = {"type": "filament", "filament_id": "GFL05", "filament_vendor": ["Geeetech"], "filament_type": ["PLA"]}
child = {"type": "filament", "inherits": "Geeetech PLA @base", "name": "Geeetech PLA Basic"}
by_name = {"Geeetech PLA @base": [parent]}
result = parse_profile(child, by_name=by_name)
assert result is not None
assert result["id"] == "GFL05"
assert result["vendor"] == "Geeetech"
assert result["type"] == "PLA"
def test_parse_profile_inherits_via_system_index():
system_index = [{
"id": "GFL06", "name": "Geeetech PLA", "vendor": "Geeetech", "type": "PLA", "color": "",
}]
user_profile = {"type": "filament", "inherits": "Geeetech PLA @base", "name": "My Geeetech Override"}
result = parse_profile(user_profile, system_index=system_index)
assert result is not None
assert result["id"] == "GFL06"
assert result["vendor"] == "Geeetech"
def test_parse_profile_inherits_cycle_does_not_infinite_loop():
"""A inherits B, B inherits A - _resolve()'s hard depth=5 bound must
terminate this rather than recursing forever."""
a = {"type": "filament", "inherits": "B"}
b = {"type": "filament", "inherits": "A"}
by_name = {"A": [a], "B": [b]}
# Neither profile has a filament_id anywhere in the cycle - must return
# None (not hang, not crash) after exhausting the depth limit.
result = parse_profile(a, by_name=by_name)
assert result is None
def test_parse_profile_duplicate_system_names_logs_and_uses_last(caplog):
"""clean_name() deliberately collapses variant-suffixed names onto the
same cleaned name - sys_by_name's last-write-wins overwrite on collision
is expected, but must now be observable via a debug log instead of
silent."""
system_index = [
{"id": "GFL07", "name": "PolyTerra PLA", "vendor": "Polymaker", "type": "PLA", "color": ""},
{"id": "GFL08", "name": "PolyTerra PLA", "vendor": "Polymaker", "type": "PLA", "color": ""},
]
user_profile = {"type": "filament", "inherits": "PolyTerra PLA @base", "name": "Override"}
with caplog.at_level(logging.DEBUG, logger="kobrax.filaments"):
result = parse_profile(user_profile, system_index=system_index)
assert result is not None
assert result["id"] == "GFL08" # last one wins, as before
assert any("duplicate system profile name" in r.message for r in caplog.records)
def test_parse_profile_bytes_valid_json():
blob = json.dumps({
"type": "filament", "filament_id": "GFL09", "name": "Test PLA",
"filament_vendor": ["Test"], "filament_type": ["PLA"],
}).encode("utf-8")
result = parse_profile_bytes(blob)
assert result is not None
assert result["id"] == "GFL09"
def test_parse_profile_bytes_malformed_json_returns_none():
assert parse_profile_bytes(b"{not valid json") is None
def test_parse_profile_bytes_non_dict_json_returns_none():
assert parse_profile_bytes(b"[1, 2, 3]") is None
assert parse_profile_bytes(b'"just a string"') is None
assert parse_profile_bytes(b"42") is None

View File

@@ -39,6 +39,15 @@ async def test_settings_get_returns_configured_values(client_configured):
assert data["device_id"] == "abc123deadbeef"
@pytest.mark.asyncio
async def test_settings_post_invalid_json_returns_400(client):
"""A malformed/non-JSON body must be a clean 400, not an unhandled 500
with a raw JSONDecodeError traceback (code review finding)."""
c, _ = client
resp = await c.post("/api/settings", data=b"not json", headers={"Content-Type": "application/json"})
assert resp.status == 400
@pytest.mark.asyncio
async def test_settings_post_writes_config_ini(client):
"""POST /api/settings schreibt Werte in config.ini (Migration von .env, v0.9.x)."""

View File

@@ -12,6 +12,49 @@ from unittest.mock import AsyncMock, MagicMock, patch
import pytest
@pytest.mark.asyncio
async def test_update_apply_invalid_json_returns_400(client):
"""A malformed/non-JSON body must be a clean 400, not an unhandled 500
with a raw JSONDecodeError traceback (code review finding)."""
c, _ = client
resp = await c.post("/api/update/apply", data=b"not json", headers={"Content-Type": "application/json"})
assert resp.status == 400
@pytest.mark.asyncio
async def test_update_check_testing_channel_is_docker_only(client):
"""A testing-<sha> build has no Gitea releases at all - the check must
report a docker-only channel with nothing to update, NOT fall through to
the stable path and wrongly offer a stable "update". Must not even call
the Gitea API."""
c, bridge = client
bridge._read_version = lambda: "testing-2e4dbf0"
with patch("aiohttp.ClientSession.get") as mock_get:
resp = await c.get("/api/update/check")
data = await resp.json()
assert resp.status == 200
assert data["update_available"] is False
assert data["docker_only"] is True
assert data["current"] == "testing-2e4dbf0"
mock_get.assert_not_called() # no Gitea round-trip for the testing channel
@pytest.mark.asyncio
async def test_update_apply_testing_channel_blocked(client):
"""Self-update must be refused on the testing channel, same as nightly -
testing images are delivered via Docker only."""
c, bridge = client
bridge._read_version = lambda: "testing-2e4dbf0"
resp = await c.post("/api/update/apply", json={"tag": "whatever"})
data = await resp.json()
assert resp.status == 400
assert "testing" in data["error"]
assert "docker" in data["error"].lower()
def _fake_releases_response(payload):
resp = MagicMock()
resp.status = 200