Files
bambuddy/backend/tests/unit/services/test_vp_diagnostic.py
T
maziggy 6bc6a1d683 feat(virtual-printer): setup diagnostic + one-click slicer-certificate export
Two recurring virtual-printer support pains, both on the Virtual Printers
  settings page.

  Setup check: a stethoscope action on each VP card runs a pass/fail/warn/skip
  checklist — VP enabled, services running, bind interface still exists, access
  code set, target printer (proxy mode), and a live TCP probe of the FTP / MQTT
  / discovery ports on the bind IP. start_server swallows per-service bind
  errors, so a service object can exist while nothing is listening; probing the
  bind IP from outside is the only reliable signal and it catches the common
  "VP not visible in the slicer" bind-IP-conflict and stale-interface cases.

  Slicer certificate: virtual printers present a TLS cert signed by a shared CA
  the slicer must trust. Until now users had to docker exec in and cat
  bbl_ca.crt. A "Slicer certificate" row on the settings card now offers Copy
  and Download (bambuddy-virtual-printer-ca.crt) plus the SHA-256 fingerprint.
  GET /virtual-printers/ca-certificate returns only the public certificate; the
  CA private key never leaves the backend. The CA is generated on demand so the
  button works before the first VP is enabled.

  Backend:
  - services/virtual_printer/diagnostic.py — run_vp_diagnostic + port probes
  - schemas/virtual_printer.py — VPDiagnosticResult
  - CertificateService.get_ca_certificate_info() + manager helper
  - routes: GET /virtual-printers/ca-certificate, /{vp_id}/diagnostic

  Frontend:
  - VirtualPrinterDiagnosticModal.tsx; stethoscope button on VirtualPrinterCard
  - caCert row on VirtualPrinterList; utils/clipboard.ts (shared copy w/
    non-secure-context fallback + downloadTextFile), de-duplicating the
    existing FQDN-copy logic
  - vpDiagnostic.* + virtualPrinter.caCert.* across all 9 locales

  9 backend unit tests + 4 route integration tests + 6 frontend tests.
  Backend ruff clean, frontend build clean, i18n parity green.
2026-05-22 13:24:35 +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": "immediate",
"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"]