|
|
@@ -0,0 +1,133 @@
|
|
|
+"""Tests for the per-VP FTP passive-port slice helper (#1646).
|
|
|
+
|
|
|
+Each VP is allocated a non-overlapping 10-port slice from the
|
|
|
+PASSIVE_PORT_BASE pool. Bridge-mode Docker users only have to expose
|
|
|
+`PASSIVE_SLICE_SIZE * N_vps` ports instead of the historical 1001-port
|
|
|
+pool that spawned ~2000 docker-proxy host processes (~3.5 GB RAM).
|
|
|
+
|
|
|
+Slicing properties pinned here:
|
|
|
+ - Slice 0 covers PASSIVE_PORT_BASE..+SLICE_SIZE-1 (the only slice that
|
|
|
+ aligns with the compose file's narrowest default exposure).
|
|
|
+ - Each subsequent vp_id advances by exactly SLICE_SIZE — no overlap, no
|
|
|
+ gap.
|
|
|
+ - vp_ids beyond MAX_SLOTS wrap around (modulo) so installs that have
|
|
|
+ churned through many VPs over time still produce a valid in-range
|
|
|
+ slice; same-slot collisions fall back to the per-session 10-attempt
|
|
|
+ random retry, which is the same behaviour as pre-#1646.
|
|
|
+ - Defensive: a non-positive vp_id (shouldn't occur, but DBs are
|
|
|
+ surprising) clamps to slot 0 rather than producing a negative port.
|
|
|
+"""
|
|
|
+
|
|
|
+from __future__ import annotations
|
|
|
+
|
|
|
+import pytest
|
|
|
+
|
|
|
+from backend.app.services.virtual_printer.ftp_server import (
|
|
|
+ PASSIVE_MAX_SLOTS,
|
|
|
+ PASSIVE_PORT_BASE,
|
|
|
+ PASSIVE_SLICE_SIZE,
|
|
|
+ compute_passive_port_slice,
|
|
|
+)
|
|
|
+
|
|
|
+
|
|
|
+class TestComputePassivePortSlice:
|
|
|
+ def test_vp_id_one_starts_at_base(self):
|
|
|
+ port_min, port_max = compute_passive_port_slice(1)
|
|
|
+ assert port_min == PASSIVE_PORT_BASE
|
|
|
+ assert port_max == PASSIVE_PORT_BASE + PASSIVE_SLICE_SIZE - 1
|
|
|
+
|
|
|
+ def test_consecutive_vp_ids_get_adjacent_non_overlapping_slices(self):
|
|
|
+ slice1_min, slice1_max = compute_passive_port_slice(1)
|
|
|
+ slice2_min, slice2_max = compute_passive_port_slice(2)
|
|
|
+ slice3_min, slice3_max = compute_passive_port_slice(3)
|
|
|
+ assert slice1_max + 1 == slice2_min # no gap
|
|
|
+ assert slice2_max + 1 == slice3_min # no gap
|
|
|
+ # Slice width matches the constant — no off-by-one.
|
|
|
+ assert slice1_max - slice1_min + 1 == PASSIVE_SLICE_SIZE
|
|
|
+ assert slice2_max - slice2_min + 1 == PASSIVE_SLICE_SIZE
|
|
|
+ assert slice3_max - slice3_min + 1 == PASSIVE_SLICE_SIZE
|
|
|
+
|
|
|
+ def test_no_two_distinct_vp_ids_within_max_slots_share_a_port(self):
|
|
|
+ seen: dict[int, int] = {}
|
|
|
+ for vp_id in range(1, PASSIVE_MAX_SLOTS + 1):
|
|
|
+ lo, hi = compute_passive_port_slice(vp_id)
|
|
|
+ for port in range(lo, hi + 1):
|
|
|
+ assert port not in seen, f"VP {vp_id} clashes with VP {seen[port]} on port {port}"
|
|
|
+ seen[port] = vp_id
|
|
|
+
|
|
|
+ def test_wraps_modulo_max_slots(self):
|
|
|
+ """VP id past MAX_SLOTS lands on the same slice as its modulo-N
|
|
|
+ neighbour — the per-session retry handles the rare collision."""
|
|
|
+ first = compute_passive_port_slice(1)
|
|
|
+ wrapped = compute_passive_port_slice(PASSIVE_MAX_SLOTS + 1)
|
|
|
+ assert wrapped == first
|
|
|
+
|
|
|
+ def test_top_slot_is_within_base_pool(self):
|
|
|
+ """The last valid slot must stay below PASSIVE_PORT_BASE +
|
|
|
+ MAX_SLOTS*SLICE_SIZE so the slice never escapes the pool that
|
|
|
+ the docker-compose comments document for users."""
|
|
|
+ _, hi = compute_passive_port_slice(PASSIVE_MAX_SLOTS)
|
|
|
+ assert hi < PASSIVE_PORT_BASE + PASSIVE_MAX_SLOTS * PASSIVE_SLICE_SIZE
|
|
|
+
|
|
|
+ @pytest.mark.parametrize("bad_id", [0, -1, -999])
|
|
|
+ def test_non_positive_ids_clamp_to_slot_zero(self, bad_id):
|
|
|
+ """Defensive: a bad vp_id from a corrupted row mustn't produce a
|
|
|
+ negative port and crash asyncio.start_server."""
|
|
|
+ assert compute_passive_port_slice(bad_id) == compute_passive_port_slice(1)
|
|
|
+
|
|
|
+
|
|
|
+class TestFTPServerHonoursPerInstanceRange:
|
|
|
+ """`VirtualPrinterFTPServer` now stores the slice on `self`. Two
|
|
|
+ instances constructed with different slices must hand each
|
|
|
+ `FTPSession` the right per-instance range — no class-level leak."""
|
|
|
+
|
|
|
+ def test_two_instances_independent_ranges(self, tmp_path):
|
|
|
+ from backend.app.services.virtual_printer.ftp_server import VirtualPrinterFTPServer
|
|
|
+
|
|
|
+ cert = tmp_path / "cert.pem"
|
|
|
+ cert.write_text("not a real cert")
|
|
|
+ key = tmp_path / "key.pem"
|
|
|
+ key.write_text("not a real key")
|
|
|
+
|
|
|
+ a = VirtualPrinterFTPServer(
|
|
|
+ upload_dir=tmp_path,
|
|
|
+ access_code="x",
|
|
|
+ cert_path=cert,
|
|
|
+ key_path=key,
|
|
|
+ passive_port_min=50000,
|
|
|
+ passive_port_max=50009,
|
|
|
+ )
|
|
|
+ b = VirtualPrinterFTPServer(
|
|
|
+ upload_dir=tmp_path,
|
|
|
+ access_code="x",
|
|
|
+ cert_path=cert,
|
|
|
+ key_path=key,
|
|
|
+ passive_port_min=50050,
|
|
|
+ passive_port_max=50059,
|
|
|
+ )
|
|
|
+
|
|
|
+ assert (a.passive_port_min, a.passive_port_max) == (50000, 50009)
|
|
|
+ assert (b.passive_port_min, b.passive_port_max) == (50050, 50059)
|
|
|
+ # Mutating one must not affect the other (regression guard against
|
|
|
+ # the pre-fix class-constant layout).
|
|
|
+ b.passive_port_min = 60000
|
|
|
+ assert a.passive_port_min == 50000
|
|
|
+
|
|
|
+ def test_default_construction_gives_a_one_slice_window(self, tmp_path):
|
|
|
+ """A consumer that doesn't pass passive_port_min/max should still
|
|
|
+ get a valid, minimal range — handy for tests and direct callers."""
|
|
|
+ from backend.app.services.virtual_printer.ftp_server import VirtualPrinterFTPServer
|
|
|
+
|
|
|
+ cert = tmp_path / "cert.pem"
|
|
|
+ cert.write_text("x")
|
|
|
+ key = tmp_path / "key.pem"
|
|
|
+ key.write_text("x")
|
|
|
+
|
|
|
+ server = VirtualPrinterFTPServer(
|
|
|
+ upload_dir=tmp_path,
|
|
|
+ access_code="x",
|
|
|
+ cert_path=cert,
|
|
|
+ key_path=key,
|
|
|
+ )
|
|
|
+ assert server.passive_port_min == PASSIVE_PORT_BASE
|
|
|
+ assert server.passive_port_max - server.passive_port_min + 1 == PASSIVE_SLICE_SIZE
|