mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 11:12:35 +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.
455 lines
18 KiB
Python
455 lines
18 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"
|