Files
bambuddy/backend/tests/integration/test_virtual_printer_api.py
T
maziggy 19073f5c84 fix(vp): auto-derive access code from target printer in non-proxy modes
Non-proxy VPs (Archive / Review / Queue) with a target printer set up
  a live-mirror bridge that forwards the slicer's MQTT and RTSPS auth
  bytes through to the real printer. The slicer holds one code in its
  profile (the one it bound the VP with), and that code has to satisfy
  both the VP listener and the real printer at the far end of the
  bridge. If the codes diverge the bridge silently fails at the second
  hop — slicer reaches .49:8883, FINs before sending a ClientHello,
  retries identically. The wiki framed the code-match requirement as a
  camera-only concern; it isn't, all bridged protocols inherit.

  Fix removes the foot-gun instead of re-documenting it. When a target
  is selected on a non-proxy VP the access-code field switches to a
  read-only display showing the target's code with an Eye-toggle
  reveal; the backend auto-inherits on every create / update (any
  explicit access_code submitted alongside a target is silently
  overridden as belt-and-braces for non-UI clients). The required-when-
  enabling check now treats target-set as satisfying the access-code
  requirement. Standalone (no-target) non-proxy VPs still get the
  editable input + Save button.

  One-shot startup migration corrects any pre-existing mismatched
  rows: SELECTs diverged VPs and logs one INFO line per row for the
  audit trail, then UPDATEs via correlated subquery. Idempotent and
  portable between SQLite and Postgres.
2026-06-05 17:19:16 +02:00

602 lines
23 KiB
Python

"""Integration tests for Virtual Printer API endpoints.
Tests the full request/response cycle for /api/v1/settings/virtual-printer endpoints.
"""
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from httpx import AsyncClient
class TestVirtualPrinterSettingsAPI:
"""Integration tests for /api/v1/settings/virtual-printer endpoints."""
# ========================================================================
# Get settings
# ========================================================================
@pytest.mark.asyncio
@pytest.mark.integration
async def test_get_virtual_printer_settings(self, async_client: AsyncClient):
"""Verify virtual printer settings can be retrieved."""
response = await async_client.get("/api/v1/settings/virtual-printer")
assert response.status_code == 200
result = response.json()
assert "enabled" in result
assert "access_code_set" in result
assert "mode" in result
assert "status" in result
@pytest.mark.asyncio
@pytest.mark.integration
async def test_get_settings_has_status(self, async_client: AsyncClient):
"""Verify settings include status details."""
response = await async_client.get("/api/v1/settings/virtual-printer")
assert response.status_code == 200
result = response.json()
status = result["status"]
assert "enabled" in status
assert "running" in status
assert "mode" in status
assert "name" in status
assert "serial" in status
assert "pending_files" in status
# ========================================================================
# Update settings
# ========================================================================
@pytest.mark.asyncio
@pytest.mark.integration
async def test_update_mode(self, async_client: AsyncClient):
"""Verify mode can be updated."""
response = await async_client.put("/api/v1/settings/virtual-printer?mode=review")
assert response.status_code == 200
result = response.json()
assert result["mode"] == "review"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_update_mode_to_queue(self, async_client: AsyncClient):
"""Verify mode can be set to the canonical 'queue' value."""
response = await async_client.put("/api/v1/settings/virtual-printer?mode=queue")
assert response.status_code == 200
result = response.json()
assert result["mode"] == "queue"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_update_mode_legacy_print_queue_normalises_to_queue(self, async_client: AsyncClient):
"""Legacy `print_queue` is accepted on input and translated to `queue` on
storage so the UI button label and the support-bundle field agree
(#1429 mode-label discrepancy)."""
response = await async_client.put("/api/v1/settings/virtual-printer?mode=print_queue")
assert response.status_code == 200
result = response.json()
assert result["mode"] == "queue"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_update_mode_legacy_immediate_normalises_to_archive(self, async_client: AsyncClient):
"""Legacy `immediate` is accepted on input and translated to `archive`
on storage (#1429 mode-label discrepancy)."""
response = await async_client.put("/api/v1/settings/virtual-printer?mode=immediate")
assert response.status_code == 200
result = response.json()
assert result["mode"] == "archive"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_update_mode_to_archive(self, async_client: AsyncClient):
"""Verify mode can be set to the canonical 'archive' value."""
response = await async_client.put("/api/v1/settings/virtual-printer?mode=archive")
assert response.status_code == 200
result = response.json()
assert result["mode"] == "archive"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_update_access_code(self, async_client: AsyncClient):
"""Verify access code can be set."""
response = await async_client.put("/api/v1/settings/virtual-printer?access_code=12345678")
assert response.status_code == 200
result = response.json()
assert result["access_code_set"] is True
@pytest.mark.asyncio
@pytest.mark.integration
async def test_update_access_code_wrong_length(self, async_client: AsyncClient):
"""Verify access code validation for length."""
response = await async_client.put("/api/v1/settings/virtual-printer?access_code=123")
# Should fail validation
assert response.status_code == 400
@pytest.mark.asyncio
@pytest.mark.integration
async def test_enable_without_access_code(self, async_client: AsyncClient):
"""Verify enabling fails without access code set."""
# First ensure no access code is set by checking current state
# Then try to enable
response = await async_client.put("/api/v1/settings/virtual-printer?enabled=true")
# If access code wasn't set, this should fail
# If it was already set, it will succeed
# Both are valid test outcomes
assert response.status_code in [200, 400]
@pytest.mark.asyncio
@pytest.mark.integration
async def test_enable_with_access_code(self, async_client: AsyncClient):
"""Verify enabling succeeds when access code is set."""
# First set access code
await async_client.put("/api/v1/settings/virtual-printer?access_code=12345678")
# Then enable (this will start the servers which may fail in test env)
# We mock the manager to avoid actually starting servers
with patch("backend.app.services.virtual_printer.virtual_printer_manager") as mock_manager:
mock_manager.configure = AsyncMock()
mock_manager.get_status = MagicMock(
return_value={
"enabled": True,
"running": True,
"mode": "archive",
"name": "Bambuddy",
"serial": "00M09A391800001",
"pending_files": 0,
}
)
response = await async_client.put("/api/v1/settings/virtual-printer?enabled=true")
assert response.status_code == 200
@pytest.mark.asyncio
@pytest.mark.integration
async def test_disable_virtual_printer(self, async_client: AsyncClient):
"""Verify virtual printer can be disabled."""
with patch("backend.app.services.virtual_printer.virtual_printer_manager") as mock_manager:
mock_manager.configure = AsyncMock()
mock_manager.get_status = MagicMock(
return_value={
"enabled": False,
"running": False,
"mode": "archive",
"name": "Bambuddy",
"serial": "00M09A391800001",
"pending_files": 0,
}
)
response = await async_client.put("/api/v1/settings/virtual-printer?enabled=false")
assert response.status_code == 200
result = response.json()
assert result["enabled"] is False
class TestPendingUploadsAPI:
"""Integration tests for /api/v1/pending-uploads/ endpoints."""
@pytest.fixture
def mock_pending_uploads(self, db_session):
"""Create mock pending uploads in database."""
async def _create_pending(filename: str = "test.3mf"):
from datetime import datetime
from backend.app.models.pending_upload import PendingUpload
upload = PendingUpload(
filename=filename,
file_path=f"/tmp/{filename}",
file_size=1024,
source_ip="192.168.1.100",
status="pending",
)
db_session.add(upload)
await db_session.commit()
await db_session.refresh(upload)
return upload
return _create_pending
# ========================================================================
# List pending uploads
# ========================================================================
@pytest.mark.asyncio
@pytest.mark.integration
async def test_list_pending_uploads_empty(self, async_client: AsyncClient):
"""Verify empty list is returned when no pending uploads."""
response = await async_client.get("/api/v1/pending-uploads/")
assert response.status_code == 200
result = response.json()
assert isinstance(result, list)
@pytest.mark.asyncio
@pytest.mark.integration
async def test_get_pending_uploads_count(self, async_client: AsyncClient):
"""Verify count endpoint returns correct count."""
response = await async_client.get("/api/v1/pending-uploads/count")
assert response.status_code == 200
result = response.json()
assert "count" in result
assert isinstance(result["count"], int)
# ========================================================================
# Archive pending upload
# ========================================================================
@pytest.mark.asyncio
@pytest.mark.integration
async def test_archive_nonexistent_upload(self, async_client: AsyncClient):
"""Verify archiving non-existent upload returns 404."""
response = await async_client.post("/api/v1/pending-uploads/99999/archive")
assert response.status_code == 404
# ========================================================================
# Discard pending upload
# ========================================================================
@pytest.mark.asyncio
@pytest.mark.integration
async def test_discard_nonexistent_upload(self, async_client: AsyncClient):
"""Verify discarding non-existent upload returns 404."""
response = await async_client.delete("/api/v1/pending-uploads/99999")
assert response.status_code == 404
# ========================================================================
# Bulk operations
# ========================================================================
@pytest.mark.asyncio
@pytest.mark.integration
async def test_archive_all_empty(self, async_client: AsyncClient):
"""Verify archive all with no pending uploads."""
response = await async_client.post("/api/v1/pending-uploads/archive-all")
assert response.status_code == 200
result = response.json()
assert "archived" in result
assert "failed" in result
@pytest.mark.asyncio
@pytest.mark.integration
async def test_discard_all_empty(self, async_client: AsyncClient):
"""Verify discard all with no pending uploads."""
response = await async_client.delete("/api/v1/pending-uploads/discard-all")
assert response.status_code == 200
result = response.json()
assert "discarded" in result
class TestVirtualPrinterAutoDispatchAPI:
"""Integration tests for auto_dispatch on /api/v1/virtual-printers endpoints."""
@pytest.mark.asyncio
@pytest.mark.integration
async def test_create_virtual_printer_auto_dispatch_default(self, async_client: AsyncClient):
"""Verify creating a VP without auto_dispatch defaults to true."""
response = await async_client.post(
"/api/v1/virtual-printers",
json={
"name": "TestDefaultDispatch",
"mode": "queue",
"access_code": "12345678",
},
)
assert response.status_code == 200
result = response.json()
assert result["auto_dispatch"] is True
@pytest.mark.asyncio
@pytest.mark.integration
async def test_create_virtual_printer_auto_dispatch_false(self, async_client: AsyncClient):
"""Verify creating a VP with auto_dispatch=false persists correctly."""
response = await async_client.post(
"/api/v1/virtual-printers",
json={
"name": "TestManualDispatch",
"mode": "queue",
"access_code": "12345678",
"auto_dispatch": False,
},
)
assert response.status_code == 200
result = response.json()
assert result["auto_dispatch"] is False
@pytest.mark.asyncio
@pytest.mark.integration
async def test_update_virtual_printer_auto_dispatch(self, async_client: AsyncClient):
"""Verify auto_dispatch can be toggled via PUT and persists."""
# Create with auto_dispatch=True (default)
create_resp = await async_client.post(
"/api/v1/virtual-printers",
json={
"name": "TestToggleDispatch",
"mode": "queue",
"access_code": "12345678",
},
)
assert create_resp.status_code == 200
vp_id = create_resp.json()["id"]
# Update to auto_dispatch=False
update_resp = await async_client.put(
f"/api/v1/virtual-printers/{vp_id}",
json={"auto_dispatch": False},
)
assert update_resp.status_code == 200
assert update_resp.json()["auto_dispatch"] is False
# Verify it persists by fetching
get_resp = await async_client.get(f"/api/v1/virtual-printers/{vp_id}")
assert get_resp.status_code == 200
assert get_resp.json()["auto_dispatch"] is False
class TestVirtualPrinterTailscaleToggleAPI:
"""The Tailscale toggle is informational — toggling either way always succeeds.
There used to be a 409 guard rejecting "enable" when the daemon was unreachable,
back when the toggle controlled LE cert provisioning. That path was removed:
the slicer's printer-MQTT trust validates against its bundled BBL CA, not the
system trust store, so even an LE cert wouldn't be accepted. The toggle now
only surfaces the host's Tailscale IP/FQDN on the VP card; daemon presence is
irrelevant to whether the toggle can be flipped.
"""
@pytest.mark.asyncio
@pytest.mark.integration
async def test_toggle_does_not_consult_tailscale_daemon(self, async_client: AsyncClient):
"""PUT tailscale_disabled never calls tailscale_service.get_status — always succeeds."""
create_resp = await async_client.post(
"/api/v1/virtual-printers",
json={
"name": "TestTailscaleToggle",
"mode": "archive",
"access_code": "12345678",
},
)
assert create_resp.status_code == 200
vp_id = create_resp.json()["id"]
assert create_resp.json()["tailscale_disabled"] is True
with patch(
"backend.app.services.virtual_printer.tailscale.tailscale_service.get_status",
new=AsyncMock(side_effect=AssertionError("get_status must not be called for toggle")),
):
enable_resp = await async_client.put(
f"/api/v1/virtual-printers/{vp_id}",
json={"tailscale_disabled": False},
)
disable_resp = await async_client.put(
f"/api/v1/virtual-printers/{vp_id}",
json={"tailscale_disabled": True},
)
assert enable_resp.status_code == 200
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": "archive", "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"
class TestVirtualPrinterAccessCodeInheritance:
"""Non-proxy VPs with a target printer must inherit the target's access
code at write time.
The live-mirror bridge forwards the slicer's MQTT/RTSPS auth bytes to
the real printer — if the codes diverge the slicer binds the VP but the
bridge fails at the second hop. The route layer force-derives the code
on every create / update so a non-UI client can't introduce a divergence
either.
"""
@pytest.mark.asyncio
@pytest.mark.integration
async def test_create_with_target_ignores_submitted_access_code(
self, async_client: AsyncClient, printer_factory, db_session
):
from sqlalchemy import select
from backend.app.models.virtual_printer import VirtualPrinter
target = await printer_factory(name="Real X1C", access_code="REALCODE")
response = await async_client.post(
"/api/v1/virtual-printers",
json={
"name": "QueueVP",
"mode": "queue",
"access_code": "WRONGAAA",
"target_printer_id": target.id,
},
)
assert response.status_code == 200
vp_id = response.json()["id"]
assert response.json()["access_code_set"] is True
vp = (await db_session.execute(select(VirtualPrinter).where(VirtualPrinter.id == vp_id))).scalar_one()
assert vp.access_code == "REALCODE"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_create_with_target_and_no_access_code_still_enables(
self, async_client: AsyncClient, printer_factory
):
"""A non-proxy VP with a target set can be enabled without supplying
access_code separately — the inheritance makes the explicit field
redundant, and the validator now knows this."""
target = await printer_factory(name="Real X1C", access_code="REALCODE")
response = await async_client.post(
"/api/v1/virtual-printers",
json={
"name": "QueueVP",
"mode": "queue",
"target_printer_id": target.id,
"bind_ip": "192.168.1.50",
"enabled": True,
},
)
assert response.status_code == 200
assert response.json()["access_code_set"] is True
@pytest.mark.asyncio
@pytest.mark.integration
async def test_create_without_target_still_requires_access_code_on_enable(self, async_client: AsyncClient):
"""The relaxation only kicks in when a target is set. A standalone
non-proxy VP still needs its own access code."""
response = await async_client.post(
"/api/v1/virtual-printers",
json={
"name": "StandaloneVP",
"mode": "archive",
"bind_ip": "192.168.1.51",
"enabled": True,
},
)
assert response.status_code == 400
assert "access code" in response.json()["detail"].lower()
@pytest.mark.asyncio
@pytest.mark.integration
async def test_update_target_resyncs_access_code(self, async_client: AsyncClient, printer_factory, db_session):
from sqlalchemy import select
from backend.app.models.virtual_printer import VirtualPrinter
first = await printer_factory(name="Printer A", access_code="AAAAAAAA")
second = await printer_factory(name="Printer B", access_code="BBBBBBBB")
create_resp = await async_client.post(
"/api/v1/virtual-printers",
json={
"name": "MovingTarget",
"mode": "queue",
"target_printer_id": first.id,
},
)
assert create_resp.status_code == 200
vp_id = create_resp.json()["id"]
vp = (await db_session.execute(select(VirtualPrinter).where(VirtualPrinter.id == vp_id))).scalar_one()
assert vp.access_code == "AAAAAAAA"
# Repoint to the second printer — access code should follow.
update_resp = await async_client.put(
f"/api/v1/virtual-printers/{vp_id}",
json={"target_printer_id": second.id},
)
assert update_resp.status_code == 200
await db_session.refresh(vp)
assert vp.access_code == "BBBBBBBB"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_update_explicit_access_code_with_target_is_overridden(
self, async_client: AsyncClient, printer_factory, db_session
):
"""An update that submits both an explicit access_code AND keeps a
target_printer_id silently uses the target's code — belt-and-braces
for non-UI clients that might try to set a divergent value."""
from sqlalchemy import select
from backend.app.models.virtual_printer import VirtualPrinter
target = await printer_factory(name="Real X1C", access_code="REALCODE")
create_resp = await async_client.post(
"/api/v1/virtual-printers",
json={
"name": "BeltBraces",
"mode": "queue",
"target_printer_id": target.id,
},
)
assert create_resp.status_code == 200
vp_id = create_resp.json()["id"]
update_resp = await async_client.put(
f"/api/v1/virtual-printers/{vp_id}",
json={"access_code": "FORGEDCD"},
)
assert update_resp.status_code == 200
vp = (await db_session.execute(select(VirtualPrinter).where(VirtualPrinter.id == vp_id))).scalar_one()
assert vp.access_code == "REALCODE"