mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-01 03:31:25 +02:00
Two printers went on printing while every archive they produced held nothing but a filename. Bambuddy opened port 990, the printer accepted the connection and answered with something that was not TLS, and connect() logged a warning and returned False -- indistinguishable, to every caller, from "the file is not at this path". So the 3MF lookup walked all six filename variants across five directories with four retries each, the cover endpoint ran its own sixteen-path sweep, and the timelapse scan added four more, all against a sixteen-path sweep, and the timelapse scan added four more, all against a printer that could not have answered any of them. One reporter's log carried 1813 identical handshake failures, another's 3511. The evidence says this is the printer's own file service getting stuck, not a model, firmware or TLS-configuration problem. In #2780's bundle the same two printers ran clean from 22 July to 4 August and failed again from the 5th; a second bundle shows an X2D serving files for five days, flipping on 19 July, then failing every connection for eight days with zero successes. The same models and firmware appear in roughly twenty other bundles with no occurrences at all. Both bundles show it happening with cap_tls_v1_2 in effect -- the X2D and H2C entries in ftp_profiles were added on analogy with P2S to fix exactly this symptom, and the reporter's own debug line proves they do not. An ssl.SSLError from connect() now opens a five-minute cool-off for that printer. Subsequent connects return False without touching the network, so a wedged printer is contacted twice an hour instead of hundreds of times a minute, and the single warning that is logged names the remedy. The cool-off is dropped on expiry rather than kept, so the map holds one key per currently wedged printer. ftps_handshake_blocked() lets the sweeps stop: the 3MF lookup abandons the remaining paths and skips the directory-walk fallback, the cover endpoint returns 503 naming the file service instead of a 404 that reads as "this print has no thumbnail", and the timelapse scan separates 503 (cannot reach the printer) from 404 (no timelapse directory) -- one 500 used to cover both, which is what the reporter hit when reproducing. The Connection Diagnostic completed a bare TCP connect to 990, which is why it reported the port green throughout: the port is open, it is what is behind it that is broken. It now completes a real implicit-TLS handshake using the model's own ftp_profiles cap, so a pass means the FTP client would also get through. An open port that cannot negotiate reports warn with reason no_tls, selecting a new message in all 13 locales that points at a printer restart rather than at the firewall. No login is attempted, so this stays valid in the pre-save Add Printer flow. The cool-off tests run against a real socket that accepts on 990 and replies with a plaintext FTP banner, reproducing WRONG_VERSION_NUMBER rather than mocking ssl. The autouse fixture clearing _mode_cache now clears the cool-off map too -- every test here talks to 127.0.0.1, so one left behind would make the next test's connect() a no-op.
144 lines
4.5 KiB
Python
144 lines
4.5 KiB
Python
"""Test fixtures for FTP service tests.
|
|
|
|
Provides a real implicit FTPS server (via mock_ftp_server) and client factory
|
|
for integration-style testing of BambuFTPClient against a live server.
|
|
|
|
The server fixture is class-scoped to avoid the overhead of starting a new
|
|
TLS server for every test (~67 TLS handshakes → ~9 per class).
|
|
"""
|
|
|
|
import os
|
|
import shutil
|
|
import socket
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
|
|
from backend.app.services.bambu_ftp import BambuFTPClient
|
|
from backend.app.services.virtual_printer.certificate import CertificateService
|
|
from backend.tests.unit.services.mock_ftp_server import MockBambuFTPServer
|
|
|
|
BAMBU_DIRS = ("cache", "timelapse", "model", "data", "data/Metadata")
|
|
|
|
|
|
@pytest.fixture(scope="session")
|
|
def ftp_certs(tmp_path_factory):
|
|
"""Generate self-signed TLS certificates once per test session."""
|
|
cert_dir = tmp_path_factory.mktemp("ftp_certs")
|
|
svc = CertificateService(cert_dir, serial="TEST_FTP_SERVER")
|
|
cert_path, key_path = svc.generate_certificates()
|
|
return str(cert_path), str(key_path)
|
|
|
|
|
|
def _find_free_port() -> int:
|
|
"""Find a free TCP port on localhost."""
|
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
s.bind(("127.0.0.1", 0))
|
|
return s.getsockname()[1]
|
|
|
|
|
|
@pytest.fixture(scope="class")
|
|
def ftp_root(tmp_path_factory):
|
|
"""Create temp directory with standard Bambu printer directory structure."""
|
|
root = tmp_path_factory.mktemp("ftp_root")
|
|
for d in BAMBU_DIRS:
|
|
(root / d).mkdir(parents=True, exist_ok=True)
|
|
return root
|
|
|
|
|
|
@pytest.fixture(scope="class")
|
|
def ftp_server(ftp_certs, ftp_root):
|
|
"""Start a mock implicit FTPS server, yield it, stop on cleanup."""
|
|
cert_path, key_path = ftp_certs
|
|
port = _find_free_port()
|
|
server = MockBambuFTPServer(
|
|
host="127.0.0.1",
|
|
port=port,
|
|
root_dir=str(ftp_root),
|
|
cert_path=cert_path,
|
|
key_path=key_path,
|
|
access_code="12345678",
|
|
)
|
|
server.start()
|
|
yield server
|
|
server.stop()
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _ftp_test_cleanup(request):
|
|
"""Reset server state between tests within a class.
|
|
|
|
Clears injected failures and restores the Bambu directory structure
|
|
so each test starts with a clean filesystem. Skips cleanup for test
|
|
classes that don't use the class-scoped ftp_server (e.g.
|
|
TestDisconnectServerGone).
|
|
"""
|
|
yield
|
|
# Only clean up if this test class uses the class-scoped fixtures
|
|
ftp_root = request.node.funcargs.get("ftp_root")
|
|
if ftp_root is None:
|
|
return
|
|
server = request.node.funcargs.get("ftp_server")
|
|
if server is not None:
|
|
server.clear_failures()
|
|
# Restore clean directory structure
|
|
root = str(ftp_root)
|
|
for entry in os.listdir(root):
|
|
path = os.path.join(root, entry)
|
|
if os.path.isdir(path):
|
|
shutil.rmtree(path)
|
|
else:
|
|
os.remove(path)
|
|
for d in BAMBU_DIRS:
|
|
os.makedirs(os.path.join(root, d), exist_ok=True)
|
|
|
|
|
|
@pytest.fixture()
|
|
def ftp_client_factory(ftp_server):
|
|
"""Factory that creates BambuFTPClient instances pointed at the mock server."""
|
|
|
|
def _make_client(
|
|
printer_model: str = "X1C",
|
|
force_prot_c: bool = False,
|
|
access_code: str = "12345678",
|
|
timeout: float = 10.0,
|
|
) -> BambuFTPClient:
|
|
client = BambuFTPClient(
|
|
ip_address="127.0.0.1",
|
|
access_code=access_code,
|
|
timeout=timeout,
|
|
printer_model=printer_model,
|
|
force_prot_c=force_prot_c,
|
|
)
|
|
# Override port to point at mock server
|
|
client.FTP_PORT = ftp_server.port
|
|
return client
|
|
|
|
return _make_client
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def clear_ftp_mode_cache():
|
|
"""Clear BambuFTPClient's per-printer caches before and after each test.
|
|
|
|
Both are class-level dicts keyed by IP, and every test here talks to
|
|
127.0.0.1 — a handshake cool-off left behind by one test would make the
|
|
next one's ``connect()`` return False without touching the server (#2780).
|
|
"""
|
|
BambuFTPClient._mode_cache.clear()
|
|
BambuFTPClient._handshake_blocked_until.clear()
|
|
yield
|
|
BambuFTPClient._mode_cache.clear()
|
|
BambuFTPClient._handshake_blocked_until.clear()
|
|
|
|
|
|
@pytest.fixture()
|
|
def patch_ftp_port(ftp_server):
|
|
"""Patch FTP_PORT at class level for async wrapper tests.
|
|
|
|
Async wrappers create their own BambuFTPClient instances internally,
|
|
so we need to patch the class-level default port.
|
|
"""
|
|
with patch.object(BambuFTPClient, "FTP_PORT", ftp_server.port):
|
|
yield ftp_server
|