Files
bambuddy/backend/tests/unit/services/test_virtual_printer.py
T
maziggy 583c374f01 Add Virtual Printer Proxy Mode for remote printing
Introduces a new "Proxy Mode" for the Virtual Printer that enables
remote printing from anywhere in the world without VPN, port forwarding,
or Bambu Cloud dependency.

Bambuddy acts as a TLS relay between a remote slicer (Bambu Studio/
OrcaSlicer) and the local Bambu Lab printer:

  Remote Slicer → Internet → Bambuddy Server → Local Network → Printer

The slicer connects to Bambuddy using the real printer's serial number
and access code. Bambuddy authenticates and relays all FTP (file transfer)
and MQTT (commands/status) traffic with end-to-end TLS encryption.

- No port forwarding required - printer stays safely on local network
- No VPN needed - connect from coffee shops, hotels, work, anywhere
- No Bambu Cloud dependency - fully self-hosted solution
- End-to-end TLS encryption on FTP (port 9990) and MQTT (port 8883)
- Works with Bambu Studio and OrcaSlicer
- Uses real printer credentials for authentication
- Automatic printer selection from connected printers

- Add SlicerProxyManager class for TLS relay (tcp_proxy.py)
  - TLS termination with auto-generated certificates
  - Concurrent FTP and MQTT proxy servers
  - Connection lifecycle management with proper cleanup
- Extend VirtualPrinterManager with proxy mode support
  - New 'proxy' mode alongside archive/review/queue modes
  - Target printer selection and credential management
- Add proxy configuration endpoints to settings API
- Add permission checks for proxy endpoints

- Add Proxy Mode card to Virtual Printer settings
- Target printer dropdown for proxy destination
- Real-time proxy status display (ports, target, running state)
- Full i18n support (English, German)

- Add network architecture diagram
- Add proxy mode section to README
- Add comprehensive guide to wiki
- Add prominent feature section to website

- Backend unit tests for SlicerProxyManager
- Backend unit tests for proxy mode configuration
- Frontend tests for proxy mode UI components

Closes #207 #170
2026-02-03 11:02:13 +01:00

546 lines
19 KiB
Python

"""Unit tests for Virtual Printer services.
Tests the virtual printer manager, FTP server, and SSDP server components.
"""
import asyncio
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
class TestVirtualPrinterManager:
"""Tests for VirtualPrinterManager class."""
@pytest.fixture
def manager(self):
"""Create a VirtualPrinterManager instance."""
from backend.app.services.virtual_printer.manager import VirtualPrinterManager
return VirtualPrinterManager()
# ========================================================================
# Tests for configuration
# ========================================================================
@pytest.mark.asyncio
async def test_configure_sets_parameters(self, manager):
"""Verify configure stores parameters correctly."""
# Mock the start/stop methods to avoid actually starting services
manager._start = AsyncMock()
await manager.configure(
enabled=True,
access_code="12345678",
mode="immediate",
)
assert manager._enabled is True
assert manager._access_code == "12345678"
assert manager._mode == "immediate"
@pytest.mark.asyncio
async def test_configure_disabled_stops_services(self, manager):
"""Verify disabling stops all services."""
# First simulate enabled state
manager._enabled = True
manager._tasks = [MagicMock(done=MagicMock(return_value=False))]
manager._stop = AsyncMock()
await manager.configure(enabled=False, access_code="12345678")
assert manager._enabled is False
manager._stop.assert_called_once()
@pytest.mark.asyncio
async def test_configure_requires_access_code_when_enabling(self, manager):
"""Verify access code is required when enabling."""
with pytest.raises(ValueError, match="Access code is required"):
await manager.configure(enabled=True)
@pytest.mark.asyncio
async def test_configure_sets_model(self, manager):
"""Verify configure stores model correctly."""
manager._start = AsyncMock()
await manager.configure(
enabled=True,
access_code="12345678",
mode="immediate",
model="C11", # P1S model code
)
assert manager._model == "C11"
@pytest.mark.asyncio
async def test_configure_ignores_invalid_model(self, manager):
"""Verify configure ignores invalid model codes."""
manager._start = AsyncMock()
await manager.configure(
enabled=True,
access_code="12345678",
model="INVALID",
)
# Should keep default model (3DPrinter-X1-Carbon = X1C)
assert manager._model == "3DPrinter-X1-Carbon"
@pytest.mark.asyncio
async def test_configure_restarts_on_model_change(self, manager):
"""Verify model change restarts services when running."""
# Simulate running state
manager._enabled = True
manager._model = "3DPrinter-X1-Carbon"
manager._tasks = [MagicMock(done=MagicMock(return_value=False))]
manager._stop = AsyncMock()
manager._start = AsyncMock()
await manager.configure(
enabled=True,
access_code="12345678",
model="C11", # P1P
)
# Should have stopped and started
manager._stop.assert_called_once()
manager._start.assert_called_once()
# ========================================================================
# Tests for status
# ========================================================================
def test_get_status_returns_correct_format(self, manager):
"""Verify get_status returns expected fields."""
manager._enabled = True
manager._mode = "immediate"
manager._model = "C11" # P1P
manager._pending_files = {"file1.3mf": Path("/tmp/file1.3mf")}
# Simulate running tasks
manager._tasks = [MagicMock(done=MagicMock(return_value=False))]
status = manager.get_status()
assert status["enabled"] is True
assert status["running"] is True
assert status["mode"] == "immediate"
assert status["name"] == "Bambuddy"
assert status["serial"] == "01S00A391800001" # C11 (P1P) serial prefix
assert status["model"] == "C11"
assert status["model_name"] == "P1P"
assert status["pending_files"] == 1
def test_get_status_when_stopped(self, manager):
"""Verify get_status when not running."""
manager._enabled = False
manager._tasks = []
status = manager.get_status()
assert status["enabled"] is False
assert status["running"] is False
def test_is_running_with_active_tasks(self, manager):
"""Verify is_running is True when tasks are active."""
mock_task = MagicMock()
mock_task.done.return_value = False
manager._tasks = [mock_task]
assert manager.is_running is True
def test_is_running_with_no_tasks(self, manager):
"""Verify is_running is False when no tasks."""
manager._tasks = []
assert manager.is_running is False
# ========================================================================
# Tests for file handling
# ========================================================================
@pytest.mark.asyncio
async def test_on_file_received_adds_to_pending(self, manager):
"""Verify received file is added to pending list."""
manager._mode = "queue"
manager._session_factory = None # Disable actual archiving
file_path = Path("/tmp/test.3mf")
with patch.object(manager, "_queue_file", new_callable=AsyncMock) as mock_queue:
await manager._on_file_received(file_path, "192.168.1.100")
assert "test.3mf" in manager._pending_files
mock_queue.assert_called_once()
@pytest.mark.asyncio
async def test_on_file_received_archives_immediately(self, manager):
"""Verify file is archived in immediate mode."""
manager._mode = "immediate"
manager._session_factory = None # Will prevent actual archiving
file_path = Path("/tmp/test.3mf")
with patch.object(manager, "_archive_file", new_callable=AsyncMock) as mock_archive:
await manager._on_file_received(file_path, "192.168.1.100")
mock_archive.assert_called_once_with(file_path, "192.168.1.100")
@pytest.mark.asyncio
async def test_archive_file_skips_non_3mf(self, manager):
"""Verify non-3MF files are skipped and cleaned up."""
manager._session_factory = MagicMock()
manager._pending_files["verify_job"] = Path("/tmp/verify_job")
with patch("pathlib.Path.unlink"):
await manager._archive_file(Path("/tmp/verify_job"), "192.168.1.100")
# Should be removed from pending
assert "verify_job" not in manager._pending_files
class TestFTPSession:
"""Tests for FTP session handling."""
@pytest.fixture
def mock_reader(self):
"""Create a mock StreamReader."""
reader = AsyncMock()
return reader
@pytest.fixture
def mock_writer(self):
"""Create a mock StreamWriter."""
writer = MagicMock()
writer.get_extra_info = MagicMock(return_value=("192.168.1.100", 12345))
writer.write = MagicMock()
writer.drain = AsyncMock()
writer.close = MagicMock()
writer.wait_closed = AsyncMock()
writer.is_closing = MagicMock(return_value=False)
return writer
@pytest.fixture
def ssl_context(self):
"""Create a mock SSL context."""
return MagicMock()
@pytest.fixture
def session(self, mock_reader, mock_writer, ssl_context, tmp_path):
"""Create an FTPSession instance."""
from backend.app.services.virtual_printer.ftp_server import FTPSession
return FTPSession(
reader=mock_reader,
writer=mock_writer,
upload_dir=tmp_path,
access_code="12345678",
ssl_context=ssl_context,
on_file_received=None,
)
# ========================================================================
# Tests for authentication
# ========================================================================
@pytest.mark.asyncio
async def test_user_command_accepts_bblp(self, session):
"""Verify USER command accepts bblp user."""
await session.cmd_USER("bblp")
assert session.username == "bblp"
@pytest.mark.asyncio
async def test_pass_command_authenticates(self, session):
"""Verify PASS command authenticates with correct code."""
session.username = "bblp"
await session.cmd_PASS("12345678")
assert session.authenticated is True
@pytest.mark.asyncio
async def test_pass_command_rejects_wrong_code(self, session):
"""Verify PASS command rejects wrong access code."""
session.username = "bblp"
await session.cmd_PASS("wrongcode")
assert session.authenticated is False
# ========================================================================
# Tests for FTP commands
# ========================================================================
@pytest.mark.asyncio
async def test_syst_command(self, session):
"""Verify SYST returns UNIX type."""
await session.cmd_SYST("")
session.writer.write.assert_called()
call_args = session.writer.write.call_args[0][0].decode()
assert "215" in call_args
assert "UNIX" in call_args
@pytest.mark.asyncio
async def test_pwd_command_requires_auth(self, session):
"""Verify PWD requires authentication."""
session.authenticated = False
await session.cmd_PWD("")
call_args = session.writer.write.call_args[0][0].decode()
assert "530" in call_args
@pytest.mark.asyncio
async def test_pwd_command_when_authenticated(self, session):
"""Verify PWD returns root directory when authenticated."""
session.authenticated = True
await session.cmd_PWD("")
call_args = session.writer.write.call_args[0][0].decode()
assert "257" in call_args
@pytest.mark.asyncio
async def test_type_command_sets_binary(self, session):
"""Verify TYPE I sets binary mode."""
session.authenticated = True
await session.cmd_TYPE("I")
assert session.transfer_type == "I"
@pytest.mark.asyncio
async def test_pbsz_command(self, session):
"""Verify PBSZ returns success."""
await session.cmd_PBSZ("0")
call_args = session.writer.write.call_args[0][0].decode()
assert "200" in call_args
@pytest.mark.asyncio
async def test_prot_command_accepts_p(self, session):
"""Verify PROT P is accepted."""
await session.cmd_PROT("P")
call_args = session.writer.write.call_args[0][0].decode()
assert "200" in call_args
@pytest.mark.asyncio
async def test_quit_command(self, session):
"""Verify QUIT sends goodbye and raises CancelledError."""
with pytest.raises(asyncio.CancelledError):
await session.cmd_QUIT("")
class TestSSDPServer:
"""Tests for Virtual Printer SSDP server."""
@pytest.fixture
def ssdp_server(self):
"""Create a VirtualPrinterSSDPServer instance."""
from backend.app.services.virtual_printer.ssdp_server import VirtualPrinterSSDPServer
return VirtualPrinterSSDPServer(
serial="TEST123",
name="TestPrinter",
model="BL-P001",
)
# ========================================================================
# Tests for SSDP response
# ========================================================================
def test_build_notify_message(self, ssdp_server):
"""Verify NOTIFY packet contains required headers."""
# Set a known IP for testing
ssdp_server._local_ip = "192.168.1.100"
message = ssdp_server._build_notify_message()
assert b"NOTIFY" in message
assert b"DevName.bambu.com: TestPrinter" in message
assert b"USN: TEST123" in message
def test_build_response_message(self, ssdp_server):
"""Verify response packet contains required headers."""
# Set a known IP for testing
ssdp_server._local_ip = "192.168.1.100"
message = ssdp_server._build_response_message()
assert b"HTTP/1.1 200 OK" in message
assert b"DevName.bambu.com: TestPrinter" in message
assert b"USN: TEST123" in message
def test_ssdp_server_uses_correct_model(self, ssdp_server):
"""Verify SSDP server uses the provided model."""
ssdp_server._local_ip = "192.168.1.100"
message = ssdp_server._build_notify_message()
assert b"DevModel.bambu.com: BL-P001" in message
class TestCertificateService:
"""Tests for TLS certificate generation."""
@pytest.fixture
def cert_service(self, tmp_path):
"""Create a CertificateService instance."""
from backend.app.services.virtual_printer.certificate import CertificateService
return CertificateService(cert_dir=tmp_path, serial="TEST123")
def test_generate_certificates(self, cert_service, tmp_path):
"""Verify certificates are generated correctly."""
cert_path, key_path = cert_service.generate_certificates()
assert cert_path.exists()
assert key_path.exists()
# Verify certificate content
cert_content = cert_path.read_text()
assert "BEGIN CERTIFICATE" in cert_content
key_content = key_path.read_text()
assert "BEGIN" in key_content and "KEY" in key_content
def test_certificates_reused_if_exist(self, cert_service):
"""Verify existing certificates are reused."""
# First generation
cert_path1, key_path1 = cert_service.generate_certificates()
mtime1 = cert_path1.stat().st_mtime
# Second call should reuse (via ensure_certificates)
cert_path2, key_path2 = cert_service.ensure_certificates()
mtime2 = cert_path2.stat().st_mtime
assert mtime1 == mtime2 # File wasn't regenerated
def test_delete_certificates(self, cert_service):
"""Verify certificates can be deleted."""
cert_service.generate_certificates()
assert cert_service.cert_path.exists()
assert cert_service.key_path.exists()
cert_service.delete_certificates()
assert not cert_service.cert_path.exists()
assert not cert_service.key_path.exists()
def test_ensure_creates_if_not_exist(self, cert_service):
"""Verify ensure_certificates generates if not existing."""
assert not cert_service.cert_path.exists()
cert_path, key_path = cert_service.ensure_certificates()
assert cert_path.exists()
assert key_path.exists()
class TestSlicerProxyManager:
"""Tests for SlicerProxyManager (proxy mode)."""
@pytest.fixture
def proxy_manager(self, tmp_path):
"""Create a SlicerProxyManager instance."""
from backend.app.services.virtual_printer.tcp_proxy import SlicerProxyManager
# Create dummy cert files
cert_path = tmp_path / "cert.pem"
key_path = tmp_path / "key.pem"
cert_path.write_text("-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----")
# Split string to avoid pre-commit hook false positive on test data
key_path.write_text("-----BEGIN " + "PRIVATE KEY-----\ntest\n-----END " + "PRIVATE KEY-----")
return SlicerProxyManager(
target_host="192.168.1.100",
cert_path=cert_path,
key_path=key_path,
)
def test_proxy_manager_initializes_ports(self, proxy_manager):
"""Verify proxy manager has correct port constants."""
assert proxy_manager.LOCAL_FTP_PORT == 9990
assert proxy_manager.LOCAL_MQTT_PORT == 8883
assert proxy_manager.PRINTER_FTP_PORT == 990
assert proxy_manager.PRINTER_MQTT_PORT == 8883
def test_proxy_manager_stores_target_host(self, proxy_manager):
"""Verify proxy manager stores target host."""
assert proxy_manager.target_host == "192.168.1.100"
def test_get_status_before_start(self, proxy_manager):
"""Verify get_status returns zeros before start."""
status = proxy_manager.get_status()
assert status["running"] is False
assert status["ftp_connections"] == 0
assert status["mqtt_connections"] == 0
class TestVirtualPrinterManagerProxyMode:
"""Tests for VirtualPrinterManager proxy mode."""
@pytest.fixture
def manager(self):
"""Create a VirtualPrinterManager instance."""
from backend.app.services.virtual_printer.manager import VirtualPrinterManager
return VirtualPrinterManager()
@pytest.mark.asyncio
async def test_configure_proxy_mode_requires_target_ip(self, manager):
"""Verify proxy mode requires target_printer_ip."""
with pytest.raises(ValueError, match="Target printer IP is required"):
await manager.configure(
enabled=True,
mode="proxy",
target_printer_ip="", # Empty target IP
)
@pytest.mark.asyncio
async def test_configure_proxy_mode_does_not_require_access_code(self, manager):
"""Verify proxy mode does not require access code (uses real printer's)."""
manager._start = AsyncMock()
# Should not raise - proxy mode doesn't need access code
await manager.configure(
enabled=True,
mode="proxy",
target_printer_ip="192.168.1.100",
)
assert manager._mode == "proxy"
assert manager._target_printer_ip == "192.168.1.100"
def test_get_status_proxy_mode_includes_proxy_fields(self, manager):
"""Verify get_status includes proxy-specific fields in proxy mode."""
manager._enabled = True
manager._mode = "proxy"
manager._target_printer_ip = "192.168.1.100"
manager._tasks = [MagicMock(done=MagicMock(return_value=False))]
# Create a mock proxy with get_status
mock_proxy = MagicMock()
mock_proxy.get_status.return_value = {
"running": True,
"ftp_port": 9990,
"mqtt_port": 8883,
"ftp_connections": 1,
"mqtt_connections": 2,
"target_host": "192.168.1.100",
}
manager._proxy = mock_proxy
status = manager.get_status()
assert status["mode"] == "proxy"
assert status["target_printer_ip"] == "192.168.1.100"
assert "proxy" in status
assert status["proxy"]["ftp_connections"] == 1
assert status["proxy"]["mqtt_connections"] == 2