Files
bambuddy/backend/tests/unit/test_vp_ftp_stor.py
T
maziggy 597762685c fix(virtual-printer): #1558 Send pre-flight + slicer-surface audit bundle
#1558: cached-as-base push_status only forced gcode_state=IDLE while letting
  the real printer's live-progress fields (mc_percent, stg_cur, layer_num, ...)
  leak through. Bambu Studio's Send pre-flight read them as busy and refused.
  The cached branch now overrides the activity-field set the same way it
  already overrode storage indicators (#1228) and protocol fields.

  Same bundle ships a multi-round VP audit that found adjacent bugs in the
  same family:

  - #1558: cached branch zeroes mc_print_stage / mc_percent / mc_remaining_time / stg / stg_cur / layer_num / total_layer_num / print_error
  - MQTT auth: per-IP rate-limit (5/60s lockout), hmac.compare_digest, access_code redacted in DEBUG log
  - FTP cmd_STOR streams chunks to disk + 4 GiB cap (was buffering whole upload)
  - Sticky-keys allowlist extended with upgrade_state / xcam / hw_switch_state / nozzle_diameter / nozzle_type / online / ams_status
  - _pending_files cleanup in finally for archive / queue / dispatch handlers
  - _add_to_print_queue position uses MAX+1 (was hardcoded 1)
  - DELETE VP removes orphan PendingUpload rows + upload_dir from disk
  - Per-VP cert regenerates on shared-CA rotation (real signature verification, not DN match)
  - DHCP target-IP refresh + queue_force_color_match toggle now restart proxy VPs
  - Per-slicer bridge-response routing (multi-slicer cross-leak fix via sequence_id map)
  - Child-service readiness barrier (FTP / MQTT / Bind / SSDP) — no false is_running before sockets bind
  - H2D Pro O1E / O2D model codes added (experimental, needs field confirmation)
  - FTP passive port range widened 50000-51000; docker-compose + wiki updated
  - VP refresh_loop crash now unbinds raw_message_handler; tailscale catches asyncio.TimeoutError; SlicerProxyManager lifecycle hardening
2026-05-30 13:34:10 +02:00

144 lines
5.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Tests for the FTPSession.cmd_STOR streaming + size-cap behaviour.
The original cmd_STOR buffered the entire upload in a ``list[bytes]`` and
called ``write_bytes`` at the end. For multi-GB ``.gcode.3mf`` files this
peaked at ~2× the file size in RSS (chunks held + the ``b''.join`` of
them) and could OOM low-memory hosts. The streaming rewrite writes each
chunk to disk inline (memory bounded at one chunk) and enforces
``MAX_UPLOAD_BYTES``. These tests pin both behaviours without standing
up a real TLS/FTP server.
"""
import asyncio
import ssl
from unittest.mock import AsyncMock, MagicMock
import pytest
from backend.app.services.virtual_printer.ftp_server import MAX_UPLOAD_BYTES, FTPSession
def _make_session(tmp_path, *, data_chunks: list[bytes]) -> FTPSession:
"""Build an FTPSession primed with a pre-fed StreamReader so cmd_STOR
can iterate through the chunks without a real TCP connection.
"""
control_writer = MagicMock()
control_writer.write = MagicMock()
control_writer.drain = AsyncMock()
control_writer.get_extra_info = MagicMock(return_value=("192.168.1.99", 12345))
upload_dir = tmp_path / "uploads"
upload_dir.mkdir(parents=True, exist_ok=True)
session = FTPSession(
reader=asyncio.StreamReader(),
writer=control_writer,
upload_dir=upload_dir,
access_code="deadbeef",
ssl_context=ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER),
on_file_received=None,
bind_address="127.0.0.1",
vp_name="stor-test",
)
session.authenticated = True
data_reader = asyncio.StreamReader()
for chunk in data_chunks:
data_reader.feed_data(chunk)
data_reader.feed_eof()
session._data_reader = data_reader
data_writer = MagicMock()
data_writer.close = MagicMock()
data_writer.wait_closed = AsyncMock()
session._data_writer = data_writer
session._data_connected.set()
session.data_server = None
return session
@pytest.mark.asyncio
async def test_stor_writes_payload_to_disk(tmp_path):
"""Happy path: chunks fed to the data reader land in the upload_dir
with the right content + the slicer gets 226."""
payload = b"X" * (3 * 64 * 1024 + 123) # 3 chunks + a partial one
chunks = [payload[i : i + 65536] for i in range(0, len(payload), 65536)]
session = _make_session(tmp_path, data_chunks=chunks)
session.send = AsyncMock()
await session.cmd_STOR("Untitled.gcode.3mf")
saved = session.upload_dir / "Untitled.gcode.3mf"
assert saved.exists()
assert saved.stat().st_size == len(payload)
assert saved.read_bytes() == payload
sent_codes = [args[0][0] for args in session.send.call_args_list]
assert 150 in sent_codes # "Opening data connection"
assert 226 in sent_codes # "Transfer complete"
@pytest.mark.asyncio
async def test_stor_rejects_upload_over_max_upload_bytes(tmp_path, monkeypatch):
"""A single chunk taking us over the cap must abort with 426 and
drop the partially-written file so it doesn't masquerade as a
successful upload."""
# Lower the cap to 100 KiB so the test doesn't need to allocate
# 4 GiB to trigger it. The same logic governs the production cap.
monkeypatch.setattr(
"backend.app.services.virtual_printer.ftp_server.MAX_UPLOAD_BYTES",
100 * 1024,
)
over_cap = b"X" * (200 * 1024) # 200 KiB > 100 KiB cap
session = _make_session(tmp_path, data_chunks=[over_cap])
session.send = AsyncMock()
await session.cmd_STOR("toobig.gcode.3mf")
# Partial file must be unlinked.
assert not (session.upload_dir / "toobig.gcode.3mf").exists()
# 426 (transfer failed) sent — not 226.
sent_codes = [args[0][0] for args in session.send.call_args_list]
assert 426 in sent_codes
assert 226 not in sent_codes
@pytest.mark.asyncio
async def test_stor_cleans_up_partial_file_on_read_error(tmp_path):
"""If the data channel raises mid-transfer (slicer RST, TLS error,
timeout, …), the partial file on disk must be removed so the next
upload of the same name starts clean and the user doesn't see a
truncated file in the upload_dir."""
payload = b"X" * 65536 # one full chunk
session = _make_session(tmp_path, data_chunks=[payload])
session.send = AsyncMock()
# Inject an OSError on the NEXT read after the first chunk.
orig_read = session._data_reader.read
state = {"calls": 0}
async def read_then_error(n):
state["calls"] += 1
if state["calls"] == 1:
return await orig_read(n)
raise OSError("simulated connection reset")
session._data_reader.read = read_then_error # type: ignore[assignment]
await session.cmd_STOR("aborted.gcode.3mf")
# Partial file removed.
assert not (session.upload_dir / "aborted.gcode.3mf").exists()
sent_codes = [args[0][0] for args in session.send.call_args_list]
assert 426 in sent_codes
def test_max_upload_bytes_is_at_least_4_gib():
"""The cap exists to prevent OOM, but should be high enough that
legitimate multi-plate .gcode.3mf uploads (~hundreds of MB) succeed
without bumping up against it. 4 GiB is the documented floor."""
assert MAX_UPLOAD_BYTES >= 4 * 1024 * 1024 * 1024