Files
bambuddy/backend/tests/unit/services/test_vp_diagnostic.py
maziggy 5d6d928b3f fix(virtual-printer): #1429 net.info[*].ip cache leak + mode wire-value rename
#1429 (reported by @TrickShotMLG02, confirmed by @Mape6 on a flat single-LAN
  that rules out subnet / mDNS-reflector theories): with the physical printer
  off the slicer's "Send" landed in Bambuddy's archive; once the printer
  powered on every subsequent "Send" went straight to the printer's SD card
  and bypassed Bambuddy. Bundle analysis: mape6-before showed clean FTP
  receive + archive lines, mape6-after had zero FTP attempts to Bambuddy
  once the printer was online.

  Cause: mqtt_bridge.py::_resolve_client encoded _target_ip_uint32_le /
  _vp_ip_uint32_le ONLY on client-identity change and early-returned on
  every refresh tick when the same client object was still bound. If
  target_client.ip_address was empty at first bind (DB row stale, or client
  constructed before SSDP refresh filled it in), the encoding stayed None,
  the net.info[*].ip rewrite block was skipped, the cache filled with the
  real printer IP, sticky-key preservation kept the poisoned net value
  alive across every subsequent incremental push, and the slicer followed
  the leaked IP. Only Bambuddy-restart-with-printer-off cleared it — the
  workaround both reporters independently arrived at. Same shape on
  multi-NIC printers (X1C, H2D Pro): the rewrite only matched entries
  whose ip equalled _target_ip_uint32_le, so a secondary interface IP
  Bambuddy never saw would leak through unchanged.

  Bridge fix:
  - _resolve_client calls a new _refresh_ip_encoding() on every refresh
    tick, even when client identity is unchanged; self-heals once
    ip_address becomes valid.
  - _refresh_ip_encoding() sweeps the existing _latest_print_state when
    encoding becomes valid for the first time. Without the sweep,
    sticky-key preservation keeps the pre-arm poisoned cache alive
    forever — incremental pushes that don't include net carry the bad
    value forward.
  - _rewrite_net_info_ips() rewrites EVERY non-zero net.info[].ip entry
    that doesn't already equal the VP IP, not just entries matching
    _target_ip_uint32_le. Multi-NIC printers stop leaking secondary
    interfaces. Zero-IP placeholders are left alone so "active interface"
    detection still works.
  - INFO logging on encoding arm/update and on cache sweep so future
    bundles directly answer "did the rewrite fire?".

  Mode wire-value rename (#1429 follow-up, separate confusion source):
  - Both reporters' support bundles showed mode: immediate while the UI
    said "Archive"; @TrickShotMLG02 quoted: "I have no idea why it says
    immediate in the support-info.json file. In the webui the printer is
    set to archive". UI button "Archive" had always saved immediate, and
    "Queue" had always saved print_queue. Canonical wire values are now
    archive / review / queue / proxy, matching the button labels 1:1.
  - New normalize_vp_mode() + VP_MODE_* constants in
    models/virtual_printer.py; manager.py normalises on construction so
    a legacy row read pre-migration still dispatches correctly.
  - core/database.py::run_migrations rewrites existing virtual_printers
    and settings rows; idempotent (re-runs are no-ops); identical SQL
    under SQLite and Postgres.
  - API routes accept both legacy and canonical on input, normalise
    before storage. GET /settings/virtual-printer normalises on read so
    the frontend's mode-button highlight works for stale legacy values.
  - Three frontend VP components (VirtualPrinterSettings,
    VirtualPrinterCard, VirtualPrinterAddDialog) switched click handlers
    and type aliases to canonical; each got its own normalizeMode()
    helper so a stale-cached settings payload still highlights the right
    button. Two pre-existing `printer.mode === 'queue' ? 'review'`
    legacy mappings in VirtualPrinterCard were the source of a test
    failure caught mid-implementation where the new canonical 'queue'
    was being mis-aliased back to 'review' and hiding the auto-dispatch
    + force-color-match toggles.

  mode handler is NOT the dispatch bug: manager.py::_archive_file (the
  handler for archive mode) doesn't dispatch to the physical printer.
  The "files end up on the printer's SD card" symptom was the IP-leak
  from the bridge cache. The mode rename is purely clarity / support-
  bundle accuracy.
2026-06-02 14:33:20 +02:00

168 lines
6.7 KiB
Python

"""Unit tests for the virtual printer setup diagnostic."""
import tempfile
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
import pytest
from backend.app.services.virtual_printer.certificate import CertificateService
from backend.app.services.virtual_printer.diagnostic import run_vp_diagnostic
_DIAG = "backend.app.services.virtual_printer.diagnostic._check_port"
_FIND_IFACE = "backend.app.services.network_utils.find_interface_for_ip"
def _vp(**overrides):
"""A virtual-printer DB row stand-in with sensible healthy defaults."""
base = {
"id": 1,
"name": "Test VP",
"mode": "archive",
"enabled": True,
"bind_ip": "192.168.1.50",
"access_code": "12345678",
"target_printer_id": None,
}
base.update(overrides)
return SimpleNamespace(**base)
class _FakeInstance:
"""Minimal VirtualPrinterInstance stand-in for the diagnostic."""
def __init__(self, running=True, cert_exists=True, proxy_status=None):
self.is_running = running
self._cert_exists = cert_exists
self._proxy_status = proxy_status
@property
def cert_path(self):
return SimpleNamespace(exists=lambda: self._cert_exists)
def get_status(self):
return {"proxy": self._proxy_status} if self._proxy_status is not None else {}
def _checks(result):
return {c.id: c.status for c in result.checks}
class TestRunVpDiagnostic:
@pytest.mark.asyncio
async def test_disabled_vp_reports_problems(self):
"""A disabled VP fails the 'enabled' check; running/port checks skip."""
result = await run_vp_diagnostic(_vp(enabled=False, bind_ip=None, access_code=None), None)
c = _checks(result)
assert result.overall == "problems"
assert c["enabled"] == "fail"
assert c["running"] == "skip"
assert c["port_ftps"] == c["port_mqtt"] == c["port_bind"] == "skip"
assert c["certificate"] == "skip"
@pytest.mark.asyncio
async def test_running_server_vp_all_pass(self):
"""Enabled + running + every port listening + cert present → overall ok."""
with (
patch(_DIAG, AsyncMock(return_value=True)),
patch(_FIND_IFACE, return_value={"name": "eth0", "ip": "192.168.1.50"}),
):
result = await run_vp_diagnostic(_vp(), _FakeInstance())
c = _checks(result)
assert result.overall == "ok"
assert c["enabled"] == "pass"
assert c["running"] == "pass"
assert c["bind_interface"] == "pass"
assert c["access_code"] == "pass"
assert c["target_printer"] == "skip" # not proxy mode
assert c["port_ftps"] == c["port_mqtt"] == c["port_bind"] == "pass"
assert c["certificate"] == "pass"
@pytest.mark.asyncio
async def test_port_not_listening_is_a_problem(self):
"""A service object can exist while its socket never bound — the probe
is what catches it, so a dead port must surface as a failure."""
with (
patch(_DIAG, AsyncMock(return_value=False)),
patch(_FIND_IFACE, return_value={"name": "eth0", "ip": "192.168.1.50"}),
):
result = await run_vp_diagnostic(_vp(), _FakeInstance())
c = _checks(result)
assert result.overall == "problems"
assert c["port_ftps"] == c["port_mqtt"] == c["port_bind"] == "fail"
@pytest.mark.asyncio
async def test_stale_bind_ip_fails_interface_check(self):
"""A bind IP that no longer matches any interface fails the check."""
with (
patch(_DIAG, AsyncMock(return_value=True)),
patch(_FIND_IFACE, return_value=None),
):
result = await run_vp_diagnostic(_vp(), _FakeInstance())
c = _checks(result)
assert c["bind_interface"] == "fail"
assert result.overall == "problems"
@pytest.mark.asyncio
async def test_missing_access_code_fails_non_proxy(self):
with (
patch(_DIAG, AsyncMock(return_value=True)),
patch(_FIND_IFACE, return_value={"name": "eth0", "ip": "192.168.1.50"}),
):
result = await run_vp_diagnostic(_vp(access_code=None), _FakeInstance())
assert _checks(result)["access_code"] == "fail"
@pytest.mark.asyncio
async def test_proxy_mode_skips_access_code_and_bind_port(self):
"""Proxy mode has no access code and runs no bind/detect server."""
instance = _FakeInstance(proxy_status={"ftp_port": 3001, "mqtt_port": 3003})
with (
patch(_DIAG, AsyncMock(return_value=True)),
patch(_FIND_IFACE, return_value={"name": "eth0", "ip": "192.168.1.50"}),
):
result = await run_vp_diagnostic(_vp(mode="proxy", target_printer_id=7), instance)
c = _checks(result)
assert c["access_code"] == "skip"
assert c["port_bind"] == "skip"
assert c["port_ftps"] == "pass"
assert c["port_mqtt"] == "pass"
@pytest.mark.asyncio
async def test_proxy_without_target_fails(self):
"""Proxy mode with no target printer fails the target check."""
with (
patch(_DIAG, AsyncMock(return_value=True)),
patch(_FIND_IFACE, return_value={"name": "eth0", "ip": "192.168.1.50"}),
):
result = await run_vp_diagnostic(
_vp(mode="proxy", target_printer_id=None, access_code=None), _FakeInstance()
)
c = _checks(result)
assert c["target_printer"] == "fail"
assert result.overall == "problems"
class TestCaCertificateInfo:
def test_get_ca_certificate_info_generates_and_returns_pem(self):
"""The CA is generated on demand; the returned PEM is the public cert."""
with tempfile.TemporaryDirectory() as d:
service = CertificateService(cert_dir=Path(d), shared_ca_dir=Path(d))
info = service.get_ca_certificate_info()
assert info["pem"].startswith("-----BEGIN CERTIFICATE-----")
assert "-----END CERTIFICATE-----" in info["pem"]
# SHA-256 fingerprint: 32 colon-separated uppercase hex bytes.
parts = info["fingerprint_sha256"].split(":")
assert len(parts) == 32
assert all(len(p) == 2 and p == p.upper() for p in parts)
assert info["not_valid_after"]
def test_ca_certificate_info_is_stable_across_calls(self):
"""A second call reuses the persisted CA — same fingerprint, no key leak."""
with tempfile.TemporaryDirectory() as d:
service = CertificateService(cert_dir=Path(d), shared_ca_dir=Path(d))
first = service.get_ca_certificate_info()
second = service.get_ca_certificate_info()
assert first["fingerprint_sha256"] == second["fingerprint_sha256"]
assert "PRIVATE KEY" not in first["pem"]