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.
This commit is contained in:
maziggy
2026-05-22 13:24:35 +02:00
parent ed31b8f4a4
commit 6bc6a1d683
27 changed files with 2012 additions and 516 deletions
@@ -384,3 +384,58 @@ class TestVirtualPrinterTailscaleToggleAPI:
assert enable_resp.json()["tailscale_disabled"] is False
assert disable_resp.status_code == 200
assert disable_resp.json()["tailscale_disabled"] is True
class TestVirtualPrinterCaCertificateAPI:
"""Integration tests for GET /api/v1/virtual-printers/ca-certificate."""
@pytest.mark.asyncio
@pytest.mark.integration
async def test_get_ca_certificate_returns_pem(self, async_client: AsyncClient):
"""The shared CA certificate is returned as PEM with identifying metadata."""
response = await async_client.get("/api/v1/virtual-printers/ca-certificate")
assert response.status_code == 200
result = response.json()
assert result["pem"].startswith("-----BEGIN CERTIFICATE-----")
assert "PRIVATE KEY" not in result["pem"] # never expose the CA key
assert len(result["fingerprint_sha256"].split(":")) == 32
assert result["not_valid_after"]
@pytest.mark.asyncio
@pytest.mark.integration
async def test_ca_certificate_route_precedes_vp_id_route(self, async_client: AsyncClient):
"""'ca-certificate' must not be swallowed by the /{vp_id} int route."""
response = await async_client.get("/api/v1/virtual-printers/ca-certificate")
# A 200 (not 422 from int-parsing "ca-certificate") proves route ordering.
assert response.status_code == 200
class TestVirtualPrinterDiagnosticAPI:
"""Integration tests for GET /api/v1/virtual-printers/{vp_id}/diagnostic."""
@pytest.mark.asyncio
@pytest.mark.integration
async def test_diagnose_unknown_vp_returns_404(self, async_client: AsyncClient):
response = await async_client.get("/api/v1/virtual-printers/999999/diagnostic")
assert response.status_code == 404
@pytest.mark.asyncio
@pytest.mark.integration
async def test_diagnose_disabled_vp_reports_problems(self, async_client: AsyncClient):
"""A freshly created (disabled) VP fails the 'enabled' check."""
create_resp = await async_client.post(
"/api/v1/virtual-printers",
json={"name": "TestDiagVP", "mode": "immediate", "access_code": "12345678"},
)
assert create_resp.status_code == 200
vp_id = create_resp.json()["id"]
response = await async_client.get(f"/api/v1/virtual-printers/{vp_id}/diagnostic")
assert response.status_code == 200
result = response.json()
assert result["vp_id"] == vp_id
assert result["overall"] == "problems"
by_id = {c["id"]: c["status"] for c in result["checks"]}
assert by_id["enabled"] == "fail"
assert by_id["running"] == "skip"