test_vp_personal_access_codes.py 8.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241
  1. """Personal access codes at a virtual printer's FTP and MQTT sign-in (#1430).
  2. The VP's own code signs in as before. Any other code is handed to the
  3. instance's lookup; a user id back means it is that user's personal code, and
  4. the FTP upload is passed on as theirs. Wrong codes count towards a per-IP
  5. limit on FTP, as on MQTT.
  6. """
  7. import asyncio
  8. import ssl
  9. from unittest.mock import AsyncMock, MagicMock
  10. import pytest
  11. from backend.app.services.virtual_printer.ftp_server import AuthRateLimiter, FTPSession
  12. from backend.tests.unit.test_vp_mqtt_server import _build_connect_payload, _make_server
  13. def _writer():
  14. writer = MagicMock()
  15. writer.write = MagicMock()
  16. writer.drain = AsyncMock()
  17. writer.get_extra_info = MagicMock(return_value=("192.168.1.99", 12345))
  18. return writer
  19. def _session(tmp_path, *, authenticate_user=None, rate_limiter=None, on_file_received=None) -> FTPSession:
  20. session = FTPSession(
  21. reader=asyncio.StreamReader(),
  22. writer=_writer(),
  23. upload_dir=tmp_path,
  24. access_code="12345678",
  25. ssl_context=ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER),
  26. on_file_received=on_file_received,
  27. bind_address="127.0.0.1",
  28. vp_name="codes-test",
  29. authenticate_user=authenticate_user,
  30. rate_limiter=rate_limiter,
  31. )
  32. session.username = "bblp"
  33. return session
  34. def _replies(session) -> list[str]:
  35. return [call.args[0].decode() for call in session.writer.write.call_args_list]
  36. class TestFtpSignIn:
  37. @pytest.mark.asyncio
  38. async def test_the_vp_code_signs_in_without_a_user(self, tmp_path):
  39. lookup = AsyncMock(return_value=7)
  40. session = _session(tmp_path, authenticate_user=lookup)
  41. await session.cmd_PASS("12345678")
  42. assert session.authenticated and session.user_id is None
  43. lookup.assert_not_awaited()
  44. @pytest.mark.asyncio
  45. async def test_a_personal_code_signs_in_as_its_user(self, tmp_path):
  46. lookup = AsyncMock(return_value=7)
  47. session = _session(tmp_path, authenticate_user=lookup)
  48. await session.cmd_PASS("87654321")
  49. lookup.assert_awaited_once_with("87654321")
  50. assert session.authenticated and session.user_id == 7
  51. assert _replies(session)[-1].startswith("230")
  52. @pytest.mark.asyncio
  53. async def test_a_code_nobody_has_is_refused(self, tmp_path):
  54. session = _session(tmp_path, authenticate_user=AsyncMock(return_value=None))
  55. await session.cmd_PASS("87654321")
  56. assert not session.authenticated
  57. assert _replies(session)[-1].startswith("530")
  58. @pytest.mark.parametrize(("user_id", "expected_kwargs"), [(7, {"user_id": 7}), (None, {})])
  59. @pytest.mark.asyncio
  60. async def test_the_upload_is_passed_on_as_the_users(self, tmp_path, user_id, expected_kwargs):
  61. from backend.tests.unit.test_vp_ftp_stor import _make_session, _valid_3mf_bytes
  62. session = _make_session(tmp_path, data_chunks=[_valid_3mf_bytes()])
  63. session.send = AsyncMock()
  64. session.user_id = user_id
  65. session.on_file_received = AsyncMock()
  66. await session.cmd_STOR("plate.gcode.3mf")
  67. args, kwargs = session.on_file_received.call_args
  68. assert args == (session.upload_dir / "plate.gcode.3mf", "192.168.1.99")
  69. assert kwargs == expected_kwargs
  70. @pytest.mark.asyncio
  71. async def test_five_wrong_codes_lock_the_address_out_even_for_the_right_one(self, tmp_path):
  72. limiter = AuthRateLimiter(max_attempts=5, window_seconds=60)
  73. for _ in range(5):
  74. await _session(tmp_path, rate_limiter=limiter).cmd_PASS("00000000")
  75. locked = _session(tmp_path, rate_limiter=limiter, authenticate_user=AsyncMock(return_value=7))
  76. await locked.cmd_PASS("12345678")
  77. assert not locked.authenticated
  78. locked.authenticate_user.assert_not_awaited()
  79. @pytest.mark.asyncio
  80. async def test_a_good_sign_in_does_not_wipe_earlier_failures(self, tmp_path):
  81. # Four wrong codes, one good one, one wrong: the IP is out. Wiping on
  82. # success let a user guess others' codes between their own sign-ins.
  83. limiter = AuthRateLimiter(max_attempts=5, window_seconds=60)
  84. for _ in range(4):
  85. await _session(tmp_path, rate_limiter=limiter).cmd_PASS("00000000")
  86. good = _session(tmp_path, rate_limiter=limiter)
  87. await good.cmd_PASS("12345678")
  88. assert good.authenticated
  89. await _session(tmp_path, rate_limiter=limiter).cmd_PASS("00000000")
  90. assert limiter.is_limited("192.168.1.99")
  91. @pytest.mark.asyncio
  92. async def test_no_second_sign_in_on_a_session_already_in(self, tmp_path):
  93. lookup = AsyncMock(return_value=7)
  94. session = _session(tmp_path, authenticate_user=lookup)
  95. await session.cmd_PASS("12345678")
  96. await session.cmd_PASS("87654321")
  97. lookup.assert_not_awaited()
  98. assert session.user_id is None
  99. assert _replies(session)[-1].startswith("503")
  100. @pytest.mark.asyncio
  101. async def test_parallel_sign_ins_are_counted_before_the_lookup(self, tmp_path):
  102. # The lookup awaits the database; attempts still in flight must count,
  103. # or many parallel connections all pass the limit check first.
  104. limiter = AuthRateLimiter(max_attempts=5, window_seconds=60)
  105. gate = asyncio.Event()
  106. lookups = 0
  107. async def slow_lookup(_code):
  108. nonlocal lookups
  109. lookups += 1
  110. await gate.wait()
  111. return None
  112. sessions = [_session(tmp_path, rate_limiter=limiter, authenticate_user=slow_lookup) for _ in range(8)]
  113. tasks = [asyncio.create_task(s.cmd_PASS("87654321")) for s in sessions]
  114. await asyncio.sleep(0.01)
  115. gate.set()
  116. await asyncio.gather(*tasks)
  117. assert lookups == 5
  118. assert all(not s.authenticated for s in sessions)
  119. class TestMqttSignIn:
  120. def test_a_personal_code_signs_in(self):
  121. server = _make_server()
  122. server.authenticate_user = AsyncMock(return_value=7)
  123. result = asyncio.run(server._handle_connect(_build_connect_payload(60, access_code="87654321"), _writer()))
  124. assert result == (True, 60)
  125. server.authenticate_user.assert_awaited_once_with("87654321")
  126. def test_a_code_nobody_has_is_refused(self):
  127. server = _make_server()
  128. server.authenticate_user = AsyncMock(return_value=None)
  129. result = asyncio.run(server._handle_connect(_build_connect_payload(60, access_code="87654321"), _writer()))
  130. assert result == (False, 0)
  131. def test_only_for_the_bblp_user(self):
  132. server = _make_server()
  133. server.authenticate_user = AsyncMock(return_value=7)
  134. result = asyncio.run(
  135. server._handle_connect(_build_connect_payload(60, access_code="87654321", username="root"), _writer())
  136. )
  137. assert result == (False, 0)
  138. server.authenticate_user.assert_not_awaited()
  139. class TestMqttBridgeCommands:
  140. """A personal code lets someone send files. Commands the bridge would pass
  141. to the real printer (stop, pause, G-code, AMS) go only with printers:control,
  142. asked fresh for every command; the VP's own code is unchanged."""
  143. def _publish(self, server, writer, command=None):
  144. from backend.tests.unit.test_vp_mqtt_server import _build_publish_payload
  145. server._client_serials["c1"] = server.serial
  146. payload = _build_publish_payload(
  147. "device/01P00AABCDEFGHI/request", {"print": command or {"command": "stop", "sequence_id": "1"}}
  148. )
  149. asyncio.run(server._handle_publish(0x30, payload, writer, "c1"))
  150. def _server(self, authorize_control):
  151. server = _make_server()
  152. server._bridge = MagicMock(is_active=True)
  153. server.authorize_control = authorize_control
  154. return server
  155. def test_a_personal_code_without_control_reaches_no_printer(self):
  156. server = self._server(AsyncMock(return_value=False))
  157. writer = _writer()
  158. server._connection_users[writer] = 7
  159. self._publish(server, writer)
  160. server.authorize_control.assert_awaited_once_with(7)
  161. server._bridge.forward_to_printer.assert_not_called()
  162. def test_with_control_it_does(self):
  163. server = self._server(AsyncMock(return_value=True))
  164. writer = _writer()
  165. server._connection_users[writer] = 7
  166. self._publish(server, writer)
  167. server._bridge.forward_to_printer.assert_called_once()
  168. def test_the_vp_code_needs_no_check(self):
  169. server = self._server(AsyncMock(return_value=False))
  170. self._publish(server, _writer())
  171. server.authorize_control.assert_not_awaited()
  172. server._bridge.forward_to_printer.assert_called_once()
  173. def test_the_personal_sign_in_is_remembered_for_the_connection(self):
  174. server = _make_server()
  175. server.authenticate_user = AsyncMock(return_value=7)
  176. writer = _writer()
  177. asyncio.run(server._handle_connect(_build_connect_payload(60, access_code="87654321"), writer))
  178. assert server._connection_users[writer] == 7