test_vp_ftp_stor.py 5.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143
  1. """Tests for the FTPSession.cmd_STOR streaming + size-cap behaviour.
  2. The original cmd_STOR buffered the entire upload in a ``list[bytes]`` and
  3. called ``write_bytes`` at the end. For multi-GB ``.gcode.3mf`` files this
  4. peaked at ~2× the file size in RSS (chunks held + the ``b''.join`` of
  5. them) and could OOM low-memory hosts. The streaming rewrite writes each
  6. chunk to disk inline (memory bounded at one chunk) and enforces
  7. ``MAX_UPLOAD_BYTES``. These tests pin both behaviours without standing
  8. up a real TLS/FTP server.
  9. """
  10. import asyncio
  11. import ssl
  12. from unittest.mock import AsyncMock, MagicMock
  13. import pytest
  14. from backend.app.services.virtual_printer.ftp_server import MAX_UPLOAD_BYTES, FTPSession
  15. def _make_session(tmp_path, *, data_chunks: list[bytes]) -> FTPSession:
  16. """Build an FTPSession primed with a pre-fed StreamReader so cmd_STOR
  17. can iterate through the chunks without a real TCP connection.
  18. """
  19. control_writer = MagicMock()
  20. control_writer.write = MagicMock()
  21. control_writer.drain = AsyncMock()
  22. control_writer.get_extra_info = MagicMock(return_value=("192.168.1.99", 12345))
  23. upload_dir = tmp_path / "uploads"
  24. upload_dir.mkdir(parents=True, exist_ok=True)
  25. session = FTPSession(
  26. reader=asyncio.StreamReader(),
  27. writer=control_writer,
  28. upload_dir=upload_dir,
  29. access_code="deadbeef",
  30. ssl_context=ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER),
  31. on_file_received=None,
  32. bind_address="127.0.0.1",
  33. vp_name="stor-test",
  34. )
  35. session.authenticated = True
  36. data_reader = asyncio.StreamReader()
  37. for chunk in data_chunks:
  38. data_reader.feed_data(chunk)
  39. data_reader.feed_eof()
  40. session._data_reader = data_reader
  41. data_writer = MagicMock()
  42. data_writer.close = MagicMock()
  43. data_writer.wait_closed = AsyncMock()
  44. session._data_writer = data_writer
  45. session._data_connected.set()
  46. session.data_server = None
  47. return session
  48. @pytest.mark.asyncio
  49. async def test_stor_writes_payload_to_disk(tmp_path):
  50. """Happy path: chunks fed to the data reader land in the upload_dir
  51. with the right content + the slicer gets 226."""
  52. payload = b"X" * (3 * 64 * 1024 + 123) # 3 chunks + a partial one
  53. chunks = [payload[i : i + 65536] for i in range(0, len(payload), 65536)]
  54. session = _make_session(tmp_path, data_chunks=chunks)
  55. session.send = AsyncMock()
  56. await session.cmd_STOR("Untitled.gcode.3mf")
  57. saved = session.upload_dir / "Untitled.gcode.3mf"
  58. assert saved.exists()
  59. assert saved.stat().st_size == len(payload)
  60. assert saved.read_bytes() == payload
  61. sent_codes = [args[0][0] for args in session.send.call_args_list]
  62. assert 150 in sent_codes # "Opening data connection"
  63. assert 226 in sent_codes # "Transfer complete"
  64. @pytest.mark.asyncio
  65. async def test_stor_rejects_upload_over_max_upload_bytes(tmp_path, monkeypatch):
  66. """A single chunk taking us over the cap must abort with 426 and
  67. drop the partially-written file so it doesn't masquerade as a
  68. successful upload."""
  69. # Lower the cap to 100 KiB so the test doesn't need to allocate
  70. # 4 GiB to trigger it. The same logic governs the production cap.
  71. monkeypatch.setattr(
  72. "backend.app.services.virtual_printer.ftp_server.MAX_UPLOAD_BYTES",
  73. 100 * 1024,
  74. )
  75. over_cap = b"X" * (200 * 1024) # 200 KiB > 100 KiB cap
  76. session = _make_session(tmp_path, data_chunks=[over_cap])
  77. session.send = AsyncMock()
  78. await session.cmd_STOR("toobig.gcode.3mf")
  79. # Partial file must be unlinked.
  80. assert not (session.upload_dir / "toobig.gcode.3mf").exists()
  81. # 426 (transfer failed) sent — not 226.
  82. sent_codes = [args[0][0] for args in session.send.call_args_list]
  83. assert 426 in sent_codes
  84. assert 226 not in sent_codes
  85. @pytest.mark.asyncio
  86. async def test_stor_cleans_up_partial_file_on_read_error(tmp_path):
  87. """If the data channel raises mid-transfer (slicer RST, TLS error,
  88. timeout, …), the partial file on disk must be removed so the next
  89. upload of the same name starts clean and the user doesn't see a
  90. truncated file in the upload_dir."""
  91. payload = b"X" * 65536 # one full chunk
  92. session = _make_session(tmp_path, data_chunks=[payload])
  93. session.send = AsyncMock()
  94. # Inject an OSError on the NEXT read after the first chunk.
  95. orig_read = session._data_reader.read
  96. state = {"calls": 0}
  97. async def read_then_error(n):
  98. state["calls"] += 1
  99. if state["calls"] == 1:
  100. return await orig_read(n)
  101. raise OSError("simulated connection reset")
  102. session._data_reader.read = read_then_error # type: ignore[assignment]
  103. await session.cmd_STOR("aborted.gcode.3mf")
  104. # Partial file removed.
  105. assert not (session.upload_dir / "aborted.gcode.3mf").exists()
  106. sent_codes = [args[0][0] for args in session.send.call_args_list]
  107. assert 426 in sent_codes
  108. def test_max_upload_bytes_is_at_least_4_gib():
  109. """The cap exists to prevent OOM, but should be high enough that
  110. legitimate multi-plate .gcode.3mf uploads (~hundreds of MB) succeed
  111. without bumping up against it. 4 GiB is the documented floor."""
  112. assert MAX_UPLOAD_BYTES >= 4 * 1024 * 1024 * 1024