test_vp_ftp_port_slicing.py 5.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133
  1. """Tests for the per-VP FTP passive-port slice helper (#1646).
  2. Each VP is allocated a non-overlapping 10-port slice from the
  3. PASSIVE_PORT_BASE pool. Bridge-mode Docker users only have to expose
  4. `PASSIVE_SLICE_SIZE * N_vps` ports instead of the historical 1001-port
  5. pool that spawned ~2000 docker-proxy host processes (~3.5 GB RAM).
  6. Slicing properties pinned here:
  7. - Slice 0 covers PASSIVE_PORT_BASE..+SLICE_SIZE-1 (the only slice that
  8. aligns with the compose file's narrowest default exposure).
  9. - Each subsequent vp_id advances by exactly SLICE_SIZE — no overlap, no
  10. gap.
  11. - vp_ids beyond MAX_SLOTS wrap around (modulo) so installs that have
  12. churned through many VPs over time still produce a valid in-range
  13. slice; same-slot collisions fall back to the per-session 10-attempt
  14. random retry, which is the same behaviour as pre-#1646.
  15. - Defensive: a non-positive vp_id (shouldn't occur, but DBs are
  16. surprising) clamps to slot 0 rather than producing a negative port.
  17. """
  18. from __future__ import annotations
  19. import pytest
  20. from backend.app.services.virtual_printer.ftp_server import (
  21. PASSIVE_MAX_SLOTS,
  22. PASSIVE_PORT_BASE,
  23. PASSIVE_SLICE_SIZE,
  24. compute_passive_port_slice,
  25. )
  26. class TestComputePassivePortSlice:
  27. def test_vp_id_one_starts_at_base(self):
  28. port_min, port_max = compute_passive_port_slice(1)
  29. assert port_min == PASSIVE_PORT_BASE
  30. assert port_max == PASSIVE_PORT_BASE + PASSIVE_SLICE_SIZE - 1
  31. def test_consecutive_vp_ids_get_adjacent_non_overlapping_slices(self):
  32. slice1_min, slice1_max = compute_passive_port_slice(1)
  33. slice2_min, slice2_max = compute_passive_port_slice(2)
  34. slice3_min, slice3_max = compute_passive_port_slice(3)
  35. assert slice1_max + 1 == slice2_min # no gap
  36. assert slice2_max + 1 == slice3_min # no gap
  37. # Slice width matches the constant — no off-by-one.
  38. assert slice1_max - slice1_min + 1 == PASSIVE_SLICE_SIZE
  39. assert slice2_max - slice2_min + 1 == PASSIVE_SLICE_SIZE
  40. assert slice3_max - slice3_min + 1 == PASSIVE_SLICE_SIZE
  41. def test_no_two_distinct_vp_ids_within_max_slots_share_a_port(self):
  42. seen: dict[int, int] = {}
  43. for vp_id in range(1, PASSIVE_MAX_SLOTS + 1):
  44. lo, hi = compute_passive_port_slice(vp_id)
  45. for port in range(lo, hi + 1):
  46. assert port not in seen, f"VP {vp_id} clashes with VP {seen[port]} on port {port}"
  47. seen[port] = vp_id
  48. def test_wraps_modulo_max_slots(self):
  49. """VP id past MAX_SLOTS lands on the same slice as its modulo-N
  50. neighbour — the per-session retry handles the rare collision."""
  51. first = compute_passive_port_slice(1)
  52. wrapped = compute_passive_port_slice(PASSIVE_MAX_SLOTS + 1)
  53. assert wrapped == first
  54. def test_top_slot_is_within_base_pool(self):
  55. """The last valid slot must stay below PASSIVE_PORT_BASE +
  56. MAX_SLOTS*SLICE_SIZE so the slice never escapes the pool that
  57. the docker-compose comments document for users."""
  58. _, hi = compute_passive_port_slice(PASSIVE_MAX_SLOTS)
  59. assert hi < PASSIVE_PORT_BASE + PASSIVE_MAX_SLOTS * PASSIVE_SLICE_SIZE
  60. @pytest.mark.parametrize("bad_id", [0, -1, -999])
  61. def test_non_positive_ids_clamp_to_slot_zero(self, bad_id):
  62. """Defensive: a bad vp_id from a corrupted row mustn't produce a
  63. negative port and crash asyncio.start_server."""
  64. assert compute_passive_port_slice(bad_id) == compute_passive_port_slice(1)
  65. class TestFTPServerHonoursPerInstanceRange:
  66. """`VirtualPrinterFTPServer` now stores the slice on `self`. Two
  67. instances constructed with different slices must hand each
  68. `FTPSession` the right per-instance range — no class-level leak."""
  69. def test_two_instances_independent_ranges(self, tmp_path):
  70. from backend.app.services.virtual_printer.ftp_server import VirtualPrinterFTPServer
  71. cert = tmp_path / "cert.pem"
  72. cert.write_text("not a real cert")
  73. key = tmp_path / "key.pem"
  74. key.write_text("not a real key")
  75. a = VirtualPrinterFTPServer(
  76. upload_dir=tmp_path,
  77. access_code="x",
  78. cert_path=cert,
  79. key_path=key,
  80. passive_port_min=50000,
  81. passive_port_max=50009,
  82. )
  83. b = VirtualPrinterFTPServer(
  84. upload_dir=tmp_path,
  85. access_code="x",
  86. cert_path=cert,
  87. key_path=key,
  88. passive_port_min=50050,
  89. passive_port_max=50059,
  90. )
  91. assert (a.passive_port_min, a.passive_port_max) == (50000, 50009)
  92. assert (b.passive_port_min, b.passive_port_max) == (50050, 50059)
  93. # Mutating one must not affect the other (regression guard against
  94. # the pre-fix class-constant layout).
  95. b.passive_port_min = 60000
  96. assert a.passive_port_min == 50000
  97. def test_default_construction_gives_a_one_slice_window(self, tmp_path):
  98. """A consumer that doesn't pass passive_port_min/max should still
  99. get a valid, minimal range — handy for tests and direct callers."""
  100. from backend.app.services.virtual_printer.ftp_server import VirtualPrinterFTPServer
  101. cert = tmp_path / "cert.pem"
  102. cert.write_text("x")
  103. key = tmp_path / "key.pem"
  104. key.write_text("x")
  105. server = VirtualPrinterFTPServer(
  106. upload_dir=tmp_path,
  107. access_code="x",
  108. cert_path=cert,
  109. key_path=key,
  110. )
  111. assert server.passive_port_min == PASSIVE_PORT_BASE
  112. assert server.passive_port_max - server.passive_port_min + 1 == PASSIVE_SLICE_SIZE