test_cloud_captcha_2790.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242
  1. """Tests for Bambu's anti-abuse CAPTCHA challenge on sign-in (#2790).
  2. Bambu's own anti-abuse layer -- not the Cloudflare edge -- answers a request it
  3. has flagged with ``HTTP 418`` and ``{"captchaId": ..., "error": "We need you to
  4. confirm you are not a robot"}``. It is keyed to the source IP, no credential
  5. will be accepted until it clears, and there is no server-side solve.
  6. That body is well-formed JSON, so the Cloudflare detector never fired on it and
  7. ``login_request`` fell through to its generic error path, which lifted Bambu's
  8. sentence out of ``error`` and returned it verbatim. The reporter got a bare
  9. toast reading "We need you to confirm you are not a robot" -- no challenge to
  10. answer, no explanation, nothing to click -- and filed it as a Bambuddy bug.
  11. These tests pin: the challenge is recognised by shape rather than by wording,
  12. all three sign-in calls report it as ``reason="captcha"`` with an explanation
  13. instead of Bambu's raw string, retries are held back per-origin so Bambuddy
  14. stops deepening the block, and the scanner names it in the next support bundle.
  15. """
  16. from __future__ import annotations
  17. from unittest.mock import AsyncMock, MagicMock, patch
  18. import httpx
  19. import pytest
  20. from backend.app.services import bambu_cloud as bc
  21. from backend.app.services.bambu_cloud import BambuCloudService
  22. # Bambu's actual challenge body, as seen on both the login endpoint and the
  23. # design-service endpoints MakerWorld imports use.
  24. _CAPTCHA_BODY = {
  25. "captchaId": "3f2a9c1e64b04d7f",
  26. "error": "We need you to confirm you are not a robot",
  27. }
  28. @pytest.fixture(autouse=True)
  29. def _clear_captcha_cooloff():
  30. """The cool-off map is module-level; don't leak it across tests."""
  31. bc._captcha_blocked_until.clear()
  32. yield
  33. bc._captcha_blocked_until.clear()
  34. def _response(status_code: int, body: object | None = None, *, text: str | None = None):
  35. resp = MagicMock()
  36. resp.status_code = status_code
  37. if body is None and text is not None:
  38. resp.json = MagicMock(side_effect=ValueError("not json"))
  39. else:
  40. resp.json = MagicMock(return_value=body if body is not None else {})
  41. resp.text = text if text is not None else "{}"
  42. return resp
  43. def _service(response) -> BambuCloudService:
  44. svc = BambuCloudService(client=MagicMock(spec=httpx.AsyncClient))
  45. svc._client.post = AsyncMock(return_value=response)
  46. svc._client.get = AsyncMock(return_value=response)
  47. return svc
  48. class TestChallengeIsRecognisedByShape:
  49. def test_captcha_id_marks_the_challenge(self):
  50. assert bc.is_captcha_challenge(_response(418, _CAPTCHA_BODY)) is True
  51. def test_wording_alone_is_enough(self):
  52. """No captchaId, but the text says what it is. Bambu has shipped the
  53. challenge under more than one body shape."""
  54. assert bc.is_captcha_challenge(_response(418, {"error": "please confirm you are not a robot"})) is True
  55. def test_a_418_without_a_marker_is_not_reported_as_a_captcha(self):
  56. """Telling a user to solve a CAPTCHA that was never offered is the exact
  57. confusion this issue is about -- don't invent one for any stray 418."""
  58. assert bc.is_captcha_challenge(_response(418, {"error": "Too many requests"})) is False
  59. def test_status_alone_does_not_decide_it(self):
  60. """A captchaId on a 200 is not a refusal -- only the 418 is."""
  61. assert bc.is_captcha_challenge(_response(200, _CAPTCHA_BODY)) is False
  62. def test_a_non_json_challenge_is_still_recognised(self):
  63. resp = _response(418, None, text="<html><body>captcha required</body></html>")
  64. assert bc.is_captcha_challenge(resp) is True
  65. def test_a_non_json_body_without_markers_is_not(self):
  66. resp = _response(418, None, text="<html><body>Service unavailable</body></html>")
  67. assert bc.is_captcha_challenge(resp) is False
  68. class TestSignInReportsTheChallenge:
  69. @pytest.mark.asyncio
  70. async def test_login_explains_instead_of_echoing_bambu(self):
  71. svc = _service(_response(418, _CAPTCHA_BODY))
  72. result = await svc.login_request("user@example.com", "pw")
  73. assert result["success"] is False
  74. assert result["needs_verification"] is False
  75. assert result["reason"] == "captcha"
  76. # The regression in one line: this used to BE Bambu's sentence.
  77. assert result["message"] != _CAPTCHA_BODY["error"]
  78. assert "CAPTCHA" in result["message"]
  79. # The two things the reporter had no way to know.
  80. assert "password" in result["message"].lower()
  81. assert "access token" in result["message"].lower()
  82. @pytest.mark.asyncio
  83. async def test_email_code_verification_reports_it_too(self):
  84. svc = _service(_response(418, _CAPTCHA_BODY))
  85. result = await svc.verify_code("user@example.com", "123456")
  86. assert result["reason"] == "captcha"
  87. assert result["message"] != _CAPTCHA_BODY["error"]
  88. @pytest.mark.asyncio
  89. async def test_totp_verification_reports_it_too(self):
  90. svc = _service(_response(418, _CAPTCHA_BODY))
  91. with patch.object(svc, "_fetch_csrf_token", AsyncMock(return_value="csrf-token")):
  92. result = await svc.verify_totp("tfa-key", "123456")
  93. assert result["reason"] == "captcha"
  94. assert result["message"] != _CAPTCHA_BODY["error"]
  95. @pytest.mark.asyncio
  96. async def test_an_ordinary_rejection_is_unchanged(self):
  97. """Wrong password still says what Bambu said, and carries no reason --
  98. the UI must keep toasting those rather than showing the CAPTCHA panel."""
  99. svc = _service(_response(400, {"error": "Login failed"}))
  100. result = await svc.login_request("user@example.com", "wrong")
  101. assert result["message"] == "Login failed"
  102. assert result.get("reason") is None
  103. assert not bc.captcha_cooloff_active(svc.base_url)
  104. class TestRetriesAreHeldBack:
  105. @pytest.mark.asyncio
  106. async def test_a_second_attempt_is_not_sent_to_bambu(self):
  107. """The reporter's log shows four attempts in eighteen seconds. Every one
  108. of them is more evidence for the thing that flagged us."""
  109. svc = _service(_response(418, _CAPTCHA_BODY))
  110. await svc.login_request("user@example.com", "pw")
  111. assert svc._client.post.await_count == 1
  112. result = await svc.login_request("user@example.com", "pw")
  113. assert svc._client.post.await_count == 1
  114. assert result["reason"] == "captcha"
  115. @pytest.mark.asyncio
  116. async def test_the_cooloff_covers_a_fresh_service_instance(self):
  117. """Services are built per request, so the cool-off has to outlive one."""
  118. await _service(_response(418, _CAPTCHA_BODY)).login_request("user@example.com", "pw")
  119. second = _service(_response(200, {"loginType": "verifyCode"}))
  120. result = await second.login_request("user@example.com", "pw")
  121. second._client.post.assert_not_awaited()
  122. assert result["reason"] == "captcha"
  123. @pytest.mark.asyncio
  124. async def test_the_cooloff_expires(self):
  125. svc = _service(_response(418, _CAPTCHA_BODY))
  126. await svc.login_request("user@example.com", "pw")
  127. bc._captcha_blocked_until[svc.base_url] = bc.time.monotonic() - 1
  128. svc._client.post = AsyncMock(return_value=_response(200, {"loginType": "verifyCode"}))
  129. result = await svc.login_request("user@example.com", "pw")
  130. assert result["needs_verification"] is True
  131. assert bc._captcha_blocked_until == {}, "the expired entry should be dropped on the way past"
  132. @pytest.mark.asyncio
  133. async def test_a_challenge_on_the_api_host_does_not_strand_a_totp_sign_in(self):
  134. """TOTP verification goes to bambulab.com, everything else to
  135. api.bambulab.com. Blocking one on the other's behalf would leave a user
  136. halfway through two-factor with no way forward."""
  137. svc = _service(_response(418, _CAPTCHA_BODY))
  138. await svc.login_request("user@example.com", "pw")
  139. svc._client.post = AsyncMock(return_value=_response(200, {"accessToken": "tok"}))
  140. with patch.object(svc, "_fetch_csrf_token", AsyncMock(return_value="csrf-token")):
  141. result = await svc.verify_totp("tfa-key", "123456")
  142. assert result["success"] is True
  143. @pytest.mark.asyncio
  144. async def test_the_china_region_is_tracked_separately(self):
  145. """The block lives at the edge in front of one region."""
  146. await _service(_response(418, _CAPTCHA_BODY)).login_request("user@example.com", "pw")
  147. cn = BambuCloudService(region="china", client=MagicMock(spec=httpx.AsyncClient))
  148. cn._client.post = AsyncMock(return_value=_response(200, {"loginType": "verifyCode"}))
  149. result = await cn.login_request("user@example.com", "pw")
  150. cn._client.post.assert_awaited_once()
  151. assert result["needs_verification"] is True
  152. class TestMakerWorldSharesTheDetector:
  153. @pytest.mark.asyncio
  154. async def test_a_challenge_worded_differently_is_still_named(self):
  155. """MakerWorld used to require the literal word "robot" in the error text
  156. and reported anything else as an unexplained block."""
  157. from backend.app.services.makerworld import MakerWorldService, MakerWorldUnavailableError
  158. svc = MakerWorldService(client=MagicMock(spec=httpx.AsyncClient), auth_token="tok")
  159. svc._client.get = AsyncMock(return_value=_response(418, {"captchaId": "abc", "error": "verification required"}))
  160. with pytest.raises(MakerWorldUnavailableError) as exc:
  161. await svc._get_json("/design/1")
  162. assert "CAPTCHA" in str(exc.value)
  163. assert "Open on MakerWorld" in str(exc.value)
  164. class TestTheSupportBundleNamesIt:
  165. def test_the_warning_we_log_matches_the_signature(self, tmp_path, monkeypatch, caplog):
  166. """The reporter's bundle came back with zero log-health findings while
  167. the log was full of the failure -- tie the two ends together."""
  168. from backend.app.core.config import settings as app_settings
  169. from backend.app.services.log_health import scan_logs
  170. svc = _service(_response(418, _CAPTCHA_BODY))
  171. with caplog.at_level("WARNING", logger="backend.app.services.bambu_cloud"):
  172. svc._note_captcha(_response(418, _CAPTCHA_BODY))
  173. logged = caplog.records[-1].getMessage()
  174. log_file = tmp_path / "bambuddy.log"
  175. log_file.write_text(
  176. f"2026-08-08 05:15:37,068 WARNING [backend.app.services.bambu_cloud] {logged}\n",
  177. encoding="utf-8",
  178. )
  179. monkeypatch.setattr(app_settings, "log_dir", tmp_path)
  180. findings = scan_logs().findings
  181. assert [f.signature_id for f in findings] == ["bambu-cloud-captcha"]
  182. assert findings[0].wiki_anchor == "bambu-cloud-captcha"