Files
bambuddy/backend/tests/unit/services/conftest.py
maziggy 91acac2b35 Stop retrying a printer whose FTPS handshake fails, and name the cause (#2780)
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.
2026-08-08 09:00:03 +02:00

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