mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-03 12:46:49 +02:00
#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.
168 lines
6.7 KiB
Python
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"]
|