diff --git a/CHANGELOG.md b/CHANGELOG.md index 8cee70929..45397933d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ All notable changes to Bambuddy will be documented in this file. - **Error and warning toasts now stay up twice as long** — Every pop-up notification disappeared after three seconds regardless of what it said. That is about right for "Settings saved", which confirms something you just did and is skimmed rather than read, but errors and warnings are a different kind of message: they carry a reason, often one relayed from the printer or the backend, and they run to a couple of lines. Three seconds was not long enough to finish reading one, and a missed error message is gone for good — there is no notification history to go back to. Errors and warnings now hold for six seconds. Success and informational toasts keep the three-second default, so the common case of clicking something and seeing it confirmed is unchanged, and the close button and the manual dismiss work exactly as before on all of them. The background print-dispatch toast is unaffected: it stays up while it has work in progress and clears itself shortly after the last job settles. Covered by frontend tests. ### Fixed +- **"We need you to confirm you are not a robot" on Bambu Cloud sign-in is now explained instead of just repeated (#2790)** — A reporter tried to connect to Bambu Cloud and got that sentence as an error toast, with no CAPTCHA anywhere to answer and nothing to click. It is Bambu's sentence, not Bambuddy's: their anti-abuse layer had flagged the network and was answering the sign-in with `HTTP 418` and a challenge body. Bambuddy had no idea what that was — the reply is well-formed JSON, so the existing Cloudflare-interstitial detector never fired on it, and the generic error path simply lifted Bambu's text out and showed it. The user was left to conclude their password was wrong, or that Bambuddy was broken; four sign-in attempts inside eighteen seconds appear in their log, each one more evidence for the thing that had flagged them. Bambuddy now recognises the challenge by its shape rather than by its wording, and the login form says what is actually happening: your email and password are not the problem, the block is tied to your public IP address rather than to your account, it normally clears by itself within a few hours, and retrying repeatedly extends it. The panel stays on screen — a toast is the wrong shape for a problem you cannot act on — and carries a one-click route to **Use access token instead**, which is the one way to connect while it lasts, since the token path does not go through the challenged endpoint. Sign-in requests are held back for five minutes after a challenge so Bambuddy stops making it worse, tracked per region and per host so a challenge on the API host cannot strand somebody halfway through a two-factor sign-in on the web one. MakerWorld imports, which meet the same challenge from the same edge, now share the detection instead of requiring the literal word "robot" in the error text. The System Health scanner has a matching signature, so the next support bundle from an affected install names the problem instead of coming back empty. The scanner's advice for a failed FTPS handshake was corrected at the same time: it still blamed firewalls and firmware, which last release's investigation (#2780) ruled out — it is the printer's own file service wedging, and the fix is to restart the printer. - **A job queued to "Any {model}" now switches a printer on, like a job queued to one printer always has (#2786, reported by @TheUltimateC0der)** — Queue a print against a printer class -- **Any X1C**, or a Slicer Pipeline whose target type is **Printer class** -- with every printer of that class switched off at the wall, and nothing happened. The job sat pending, no smart plug was touched, and the only way out was to edit the item onto a specific printer, at which point Bambuddy powered that printer on immediately. The reporter's log holds that comparison exactly: thirteen minutes of the job being polled and passed over, then the edit, then a power-on on the very next check -- same job, same plug, same Auto Power On setting. Powering a printer on had only ever been written into the branch that handles a job pinned to one printer; the branch that picks a printer by model listed an offline one as a reason to keep waiting and never looked at its plugs. It does now. It also picks with a little more care than the older branch: a printer waiting for a plate-clear acknowledgment is passed over, because switching it on only leaves it idling behind that gate -- which is what the reporter's own log shows happening for the eighty minutes after their manual edit -- and a printer whose class the file cannot legally run on is never switched on at all. One printer comes up per queue check rather than a whole shelf at once, so several queued jobs wake several printers over the following minutes. Finally, a printer that is off and has no enabled Auto Power On plug now says that in the job's waiting reason instead of hiding behind the same "Offline" as the printers Bambuddy can bring back itself -- that distinction was the first question the reporter had to be asked. - **A printer whose file service stops answering is named as such instead of quietly emptying your archives (#2780, reported by @Utility9298 and @AntonPalmqvist)** — Two printers went on printing normally while every archive they produced arrived holding nothing but a filename: no filament totals, no layer count, no cover image, no timelapse. The Connection Diagnostic reported the file-transfer port as reachable, because it was — the printer accepted the connection and then answered it with something that was not TLS at all, and the sliced file could never be read back. Bambuddy said nothing about that on screen; it retried. Because each candidate location opened its own connection, one reporter's log carried 1813 identical handshake failures and another's 3511, against a printer that could not have answered any of them. This is not a model or a firmware problem — the same printers worked for days before and after the fault, and other installs run the same models untouched. It is the printer's own file service getting stuck, and a power-cycle clears it. Bambuddy now stops after the first failed handshake and leaves that printer alone for five minutes, so the log carries a handful of entries that say what went wrong and what to do about it rather than thousands that say neither. The Connection Diagnostic now completes a real handshake instead of merely opening the port, so a printer in this state reads as a warning that names a restart — not as a green tick. Scanning for a timelapse on such a printer reports the file service, where it used to return one error message that covered both "the printer is unreachable" and "this printer has no timelapse folder", and asking for a cover image says the same thing instead of the "no cover for this print" it used to claim. Printing is unaffected throughout: the control connection is a separate service, which is exactly why the fault was invisible. - **Prints queued from a Slicer Pipeline or the Library are checked for enough filament again (#2779, reported by @wylyn3d)** — A job needing 20.5 g was dispatched onto a spool holding 9 g, and the printer started. The same file, printed from the Print dialog, was correctly refused. The check that stands between the queue and the printer reads the sliced file to learn how much each slot needs, and it looked for that file in the wrong place: a file in the Library records where it lives relative to Bambuddy's data directory, and this one check read that as a path from wherever the process happened to be running. It found nothing, and a source it cannot find has always meant "nothing to verify" rather than "stop" — so the job passed a check that never actually ran. Every path that queues a Library file was affected: Slicer Pipeline jobs, which are always Library-backed, and anything added through the Library's **Add to queue**. Both the automatic dispatcher and the Play button on the queue were equally blind, so the deficit could not be caught by starting the job by hand either. Prints queued from print history were never affected, and neither was the Print dialog, which finds the file its own way. Two things changed: the check now resolves a Library file the same way the eleven other places that read one already did, and a source file that is configured but missing now writes a warning to the log naming the item and the path it looked at. That case still dispatches — the upload needs the same file moments later and fails there, where blocking would strand a queue on a file the user may have moved — but it no longer passes in silence, which is what let this go unnoticed. Covered by backend tests, including the reporter's exact 20.5 g against 9 g. diff --git a/backend/app/api/routes/cloud.py b/backend/app/api/routes/cloud.py index d44a88425..2dcad6c4f 100644 --- a/backend/app/api/routes/cloud.py +++ b/backend/app/api/routes/cloud.py @@ -528,6 +528,7 @@ async def login( message=result.get("message", "Unknown error"), verification_type=result.get("verification_type"), tfa_key=result.get("tfa_key"), + reason=result.get("reason"), ) except BambuCloudAuthError as e: raise HTTPException(status_code=401, detail=str(e)) @@ -573,6 +574,7 @@ async def verify_code( success=result.get("success", False), needs_verification=False, message=result.get("message", "Unknown error"), + reason=result.get("reason"), ) except BambuCloudAuthError as e: raise HTTPException(status_code=401, detail=str(e)) diff --git a/backend/app/schemas/cloud.py b/backend/app/schemas/cloud.py index aff4caf9f..8a1c7433c 100644 --- a/backend/app/schemas/cloud.py +++ b/backend/app/schemas/cloud.py @@ -30,6 +30,12 @@ class CloudLoginResponse(BaseModel): message: str verification_type: str | None = None # "email" or "totp" tfa_key: str | None = None # Key needed for TOTP verification + # Machine-readable cause of a failure, when we know it. Currently only + # "captcha" — Bambu's anti-abuse layer is challenging this network and no + # credential will be accepted until it clears (#2790). The UI needs this to + # explain the situation in place, rather than flashing ``message`` as a + # toast that vanishes and leaves the user retrying a password that is fine. + reason: str | None = None class CloudAuthStatus(BaseModel): diff --git a/backend/app/services/bambu_cloud.py b/backend/app/services/bambu_cloud.py index 813870bdb..f2acd67d0 100644 --- a/backend/app/services/bambu_cloud.py +++ b/backend/app/services/bambu_cloud.py @@ -124,6 +124,110 @@ def _detect_cloudflare_challenge(response) -> str | None: return None +# Bambu's own anti-abuse layer — distinct from the Cloudflare edge above — +# answers a request it has flagged with HTTP 418 and a challenge body: +# +# {"captchaId": "...", "error": "We need you to confirm you are not a robot"} +# +# The flag is keyed to the source IP and covers api.bambulab.com as a whole: +# the same 418 turns up on the login endpoint and on the design-service +# endpoints MakerWorld imports use. It clears on its own after a few hours of +# quiet traffic, and there is no server-side solve — a CAPTCHA is designed to be +# unanswerable without a real browser, and the challenge id is of no use to us +# because we have nowhere to render the widget. +# +# It reaches ``login_request`` as a perfectly well-formed JSON body, so +# ``_detect_cloudflare_challenge`` above never fires on it. Before #2790 the +# generic error path then lifted Bambu's sentence out of ``error`` and showed it +# as a bare toast: the reporter saw "We need you to confirm you are not a robot" +# with no challenge, no explanation and nothing to click, and filed it as a +# Bambuddy bug. +_CAPTCHA_HTTP_STATUS = 418 + +# Markers that identify a 418 as the CAPTCHA challenge rather than some other +# refusal. ``captchaId`` is the reliable one; the wording is matched too because +# Bambu has shipped the challenge under more than one phrasing. +_CAPTCHA_BODY_MARKERS = ("captchaid", "captcha", "robot") + +CAPTCHA_USER_MESSAGE = ( + "Bambu Cloud is challenging this network with a CAPTCHA before it will accept a sign-in, " + "and there is no way to answer it from Bambuddy. Your email and password are not the " + "problem. The block is tied to your public IP address and normally clears by itself within " + "a few hours — retrying repeatedly extends it. To sign in now, use 'Use access token " + "instead' and paste a token taken from a browser session." +) + +# How long to stop sending sign-in requests to a Bambu region after it answered +# with a CAPTCHA challenge. The reporter's log shows four attempts in eighteen +# seconds, which is exactly the traffic pattern that deepens the block: every +# extra request is more evidence for the thing that flagged us. Five minutes is +# short against the hours the block itself lasts — the point is not to wait it +# out here, only to stop Bambuddy from making it worse while the user reads the +# explanation. +_CAPTCHA_COOLOFF_SECONDS = 300.0 + +# API base URL -> monotonic time its cool-off expires. Keyed by base URL because +# the block lives at the edge in front of one region: being challenged on +# api.bambulab.com says nothing about api.bambulab.cn. +_captcha_blocked_until: dict[str, float] = {} + + +def is_captcha_challenge(response) -> bool: + """Whether Bambu answered with an anti-abuse CAPTCHA challenge. + + Requires the 418 status *and* a challenge marker in the body, so an + unrelated 418 is not reported to the user as "solve a CAPTCHA" — that would + send them looking for a widget that was never there, which is the exact + confusion #2790 is about. Callers that want to say something about a bare + 418 must handle it themselves. + + Shared by the Bambu Cloud and MakerWorld services: same edge, same body. + """ + try: + status = int(getattr(response, "status_code", 0) or 0) + except (TypeError, ValueError): + return False + if status != _CAPTCHA_HTTP_STATUS: + return False + try: + data = response.json() + except Exception: + data = None + if isinstance(data, dict): + # Field *names* count as well as their text: the challenge is + # identified by carrying a ``captchaId`` at all, whatever it says. + parts = [str(key) for key in data] + parts += [str(data[key]) for key in ("captchaId", "error", "message", "detail") if data.get(key)] + haystack = " ".join(parts).lower() + else: + # Not JSON (or not an object) — fall back to the raw body so a + # challenge served as HTML is still recognised rather than reported as + # an unexplained failure. + try: + haystack = (response.text or "").lower() + except Exception: + return False + return any(marker in haystack for marker in _CAPTCHA_BODY_MARKERS) + + +def captcha_cooloff_active(base_url: str) -> bool: + """Whether sign-in requests to ``base_url`` are still held back after a + CAPTCHA challenge. Expired entries are dropped on the way past, so the dict + cannot grow past one entry per region.""" + deadline = _captcha_blocked_until.get(base_url) + if deadline is None: + return False + if time.monotonic() >= deadline: + del _captcha_blocked_until[base_url] + return False + return True + + +def note_captcha_challenge(base_url: str) -> None: + """Start the cool-off for ``base_url`` after a challenge was seen.""" + _captcha_blocked_until[base_url] = time.monotonic() + _CAPTCHA_COOLOFF_SECONDS + + # The `/v1/iot-service/api/slicer/setting` endpoint subtree — the plural GET # for the list, the singular GET/DELETE for a specific preset by setting_id, and # the POST for create — requires a `version` query parameter in the XX.YY.ZZ.WW @@ -317,12 +421,62 @@ class BambuCloudService: headers["Authorization"] = f"Bearer {self.access_token}" return headers + def _captcha_refusal(self) -> dict: + """The result every sign-in call returns while Bambu is challenging us. + + ``reason`` is what lets the UI tell this apart from a wrong password and + render the explanation next to the access-token route, instead of + flashing Bambu's own one-liner as a toast that then disappears (#2790). + """ + return { + "success": False, + "needs_verification": False, + "reason": "captcha", + "message": CAPTCHA_USER_MESSAGE, + } + + def _captcha_cooloff_holds(self, origin: str | None = None) -> bool: + """Whether to refuse a sign-in locally because Bambu just challenged us. + + Keyed by the origin the call actually goes to. The TOTP step talks to + ``bambulab.com`` while everything else talks to ``api.bambulab.com``, and + a challenge seen on one must not strand a user halfway through a + two-factor sign-in on the other. + """ + origin = origin or self.base_url + if not captcha_cooloff_active(origin): + return False + logger.warning( + "Bambu Cloud is challenging this network with a CAPTCHA — not sending the sign-in to %s. " + "The challenge cannot be answered from Bambuddy and normally clears within a few hours.", + origin, + ) + return True + + def _note_captcha(self, response, origin: str | None = None) -> bool: + """Record and log a CAPTCHA challenge. Returns whether it was one.""" + if not is_captcha_challenge(response): + return False + origin = origin or self.base_url + logger.warning( + "Bambu Cloud is challenging this network with a CAPTCHA (HTTP %s from %s). Sign-in cannot " + "complete until the challenge clears; pausing sign-in requests for %.0fs so retries do not " + "extend the block.", + response.status_code, + origin, + _CAPTCHA_COOLOFF_SECONDS, + ) + note_captcha_challenge(origin) + return True + async def login_request(self, email: str, password: str) -> dict: """ Initiate login - this will trigger either email verification or TOTP prompt. Returns dict with login status, verification type, and tfaKey if needed. """ + if self._captcha_cooloff_holds(): + return self._captcha_refusal() try: response = await self._client.post( f"{self.base_url}/v1/user-service/user/login", @@ -333,6 +487,9 @@ class BambuCloudService: }, ) + if self._note_captcha(response): + return self._captcha_refusal() + try: data = response.json() except Exception as json_err: @@ -388,6 +545,8 @@ class BambuCloudService: """ Complete login with email verification code. """ + if self._captcha_cooloff_holds(): + return self._captcha_refusal() try: response = await self._client.post( f"{self.base_url}/v1/user-service/user/login", @@ -398,6 +557,9 @@ class BambuCloudService: }, ) + if self._note_captcha(response): + return self._captcha_refusal() + try: data = response.json() except Exception as json_err: @@ -472,6 +634,9 @@ class BambuCloudService: web_origin = "https://bambulab.cn" if "bambulab.cn" in self.base_url else "https://bambulab.com" tfa_url = f"{web_origin}/api/sign-in/tfa" + if self._captcha_cooloff_holds(web_origin): + return self._captcha_refusal() + # #2696: the web origin is CSRF-protected (double submit). Without # both halves the endpoint 403s before it ever evaluates the code, # which surfaced to users as a permanent, misleading "Invalid code". @@ -509,6 +674,9 @@ class BambuCloudService: f"TOTP verify response: status={response.status_code}, body={response.text[:200] if response.text else '(empty)'}" ) + if self._note_captcha(response, web_origin): + return self._captcha_refusal() + # Handle empty response if not response.text or not response.text.strip(): logger.warning("TOTP verification returned empty response (status %s)", response.status_code) diff --git a/backend/app/services/log_health.py b/backend/app/services/log_health.py index 7333bb246..7fc279391 100644 --- a/backend/app/services/log_health.py +++ b/backend/app/services/log_health.py @@ -123,6 +123,17 @@ SIGNATURES: tuple[LogSignature, ...] = ( logger_prefix="backend.app.services.camera", min_count=3, ), + LogSignature( + # Bambu's anti-abuse layer is challenging this network with a CAPTCHA, + # so no Bambu Cloud sign-in can complete. Nothing in the install is + # broken and no credential will help — see bambu_cloud.is_captcha_challenge. + id="bambu-cloud-captcha", + patterns=_compile(r"challenging this network with a CAPTCHA"), + severity="warning", + category="environment", + wiki_anchor="bambu-cloud-captcha", + logger_prefix="backend.app.services.bambu_cloud", + ), LogSignature( # SQLite write contention. Surfaces inside exception tracebacks; folded # continuation lines are part of the entry message, so this still diff --git a/backend/app/services/makerworld.py b/backend/app/services/makerworld.py index 592285b6c..bd19e041a 100644 --- a/backend/app/services/makerworld.py +++ b/backend/app/services/makerworld.py @@ -28,7 +28,7 @@ from urllib.parse import urlparse import certifi import httpx -from backend.app.services.bambu_cloud import is_expiry_401 +from backend.app.services.bambu_cloud import is_captcha_challenge, is_expiry_401 logger = logging.getLogger(__name__) @@ -331,18 +331,24 @@ class MakerWorldService: if response.status_code == 404: raise MakerWorldNotFoundError(f"MakerWorld resource not found: {path}") if response.status_code == 418: - # MakerWorld's anti-abuse layer challenges the source IP with a - # CAPTCHA (``{"captchaId":"...","error":"We need to confirm..."}``). - # This is application-level, not Cloudflare-edge, and clears - # on its own within 1–4 hours of quiet traffic. There's no - # server-side solve — CAPTCHAs are intentionally unsolvable - # without a real browser. Surface the upstream message so the - # user can recognise it and reach for the "Open on MakerWorld" - # fallback instead of thinking the feature is broken. - upstream = _extract_upstream_error(response) - if upstream and "robot" in upstream.lower(): + # Bambu's anti-abuse layer challenges the source IP with a CAPTCHA + # (``{"captchaId":"...","error":"We need to confirm..."}``). This is + # application-level, not Cloudflare-edge, and clears on its own + # within 1–4 hours of quiet traffic. There's no server-side solve — + # CAPTCHAs are intentionally unsolvable without a real browser. + # Surface the upstream message so the user can recognise it and + # reach for the "Open on MakerWorld" fallback instead of thinking + # the feature is broken. + # + # The same challenge also lands on the Bambu Cloud sign-in endpoint, + # so the shape test lives in ``bambu_cloud`` and is shared (#2790). + # It used to be a bare "robot" substring check on the error text, + # which missed a challenge worded any other way. + if is_captcha_challenge(response): + upstream = _extract_upstream_error(response) + detail = f" ({upstream})" if upstream else "" raise MakerWorldUnavailableError( - f"MakerWorld is challenging this IP with a CAPTCHA ({upstream}). " + f"MakerWorld is challenging this IP with a CAPTCHA{detail}. " "This usually clears within a few hours. In the meantime, use " "'Open on MakerWorld' below to download the 3MF manually." ) diff --git a/backend/tests/unit/test_cloud_captcha_2790.py b/backend/tests/unit/test_cloud_captcha_2790.py new file mode 100644 index 000000000..bbe5da712 --- /dev/null +++ b/backend/tests/unit/test_cloud_captcha_2790.py @@ -0,0 +1,242 @@ +"""Tests for Bambu's anti-abuse CAPTCHA challenge on sign-in (#2790). + +Bambu's own anti-abuse layer -- not the Cloudflare edge -- answers a request it +has flagged with ``HTTP 418`` and ``{"captchaId": ..., "error": "We need you to +confirm you are not a robot"}``. It is keyed to the source IP, no credential +will be accepted until it clears, and there is no server-side solve. + +That body is well-formed JSON, so the Cloudflare detector never fired on it and +``login_request`` fell through to its generic error path, which lifted Bambu's +sentence out of ``error`` and returned it verbatim. The reporter got a bare +toast reading "We need you to confirm you are not a robot" -- no challenge to +answer, no explanation, nothing to click -- and filed it as a Bambuddy bug. + +These tests pin: the challenge is recognised by shape rather than by wording, +all three sign-in calls report it as ``reason="captcha"`` with an explanation +instead of Bambu's raw string, retries are held back per-origin so Bambuddy +stops deepening the block, and the scanner names it in the next support bundle. +""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock, patch + +import httpx +import pytest + +from backend.app.services import bambu_cloud as bc +from backend.app.services.bambu_cloud import BambuCloudService + +# Bambu's actual challenge body, as seen on both the login endpoint and the +# design-service endpoints MakerWorld imports use. +_CAPTCHA_BODY = { + "captchaId": "3f2a9c1e64b04d7f", + "error": "We need you to confirm you are not a robot", +} + + +@pytest.fixture(autouse=True) +def _clear_captcha_cooloff(): + """The cool-off map is module-level; don't leak it across tests.""" + bc._captcha_blocked_until.clear() + yield + bc._captcha_blocked_until.clear() + + +def _response(status_code: int, body: object | None = None, *, text: str | None = None): + resp = MagicMock() + resp.status_code = status_code + if body is None and text is not None: + resp.json = MagicMock(side_effect=ValueError("not json")) + else: + resp.json = MagicMock(return_value=body if body is not None else {}) + resp.text = text if text is not None else "{}" + return resp + + +def _service(response) -> BambuCloudService: + svc = BambuCloudService(client=MagicMock(spec=httpx.AsyncClient)) + svc._client.post = AsyncMock(return_value=response) + svc._client.get = AsyncMock(return_value=response) + return svc + + +class TestChallengeIsRecognisedByShape: + def test_captcha_id_marks_the_challenge(self): + assert bc.is_captcha_challenge(_response(418, _CAPTCHA_BODY)) is True + + def test_wording_alone_is_enough(self): + """No captchaId, but the text says what it is. Bambu has shipped the + challenge under more than one body shape.""" + assert bc.is_captcha_challenge(_response(418, {"error": "please confirm you are not a robot"})) is True + + def test_a_418_without_a_marker_is_not_reported_as_a_captcha(self): + """Telling a user to solve a CAPTCHA that was never offered is the exact + confusion this issue is about -- don't invent one for any stray 418.""" + assert bc.is_captcha_challenge(_response(418, {"error": "Too many requests"})) is False + + def test_status_alone_does_not_decide_it(self): + """A captchaId on a 200 is not a refusal -- only the 418 is.""" + assert bc.is_captcha_challenge(_response(200, _CAPTCHA_BODY)) is False + + def test_a_non_json_challenge_is_still_recognised(self): + resp = _response(418, None, text="
captcha required") + assert bc.is_captcha_challenge(resp) is True + + def test_a_non_json_body_without_markers_is_not(self): + resp = _response(418, None, text="Service unavailable") + assert bc.is_captcha_challenge(resp) is False + + +class TestSignInReportsTheChallenge: + @pytest.mark.asyncio + async def test_login_explains_instead_of_echoing_bambu(self): + svc = _service(_response(418, _CAPTCHA_BODY)) + + result = await svc.login_request("user@example.com", "pw") + + assert result["success"] is False + assert result["needs_verification"] is False + assert result["reason"] == "captcha" + # The regression in one line: this used to BE Bambu's sentence. + assert result["message"] != _CAPTCHA_BODY["error"] + assert "CAPTCHA" in result["message"] + # The two things the reporter had no way to know. + assert "password" in result["message"].lower() + assert "access token" in result["message"].lower() + + @pytest.mark.asyncio + async def test_email_code_verification_reports_it_too(self): + svc = _service(_response(418, _CAPTCHA_BODY)) + + result = await svc.verify_code("user@example.com", "123456") + + assert result["reason"] == "captcha" + assert result["message"] != _CAPTCHA_BODY["error"] + + @pytest.mark.asyncio + async def test_totp_verification_reports_it_too(self): + svc = _service(_response(418, _CAPTCHA_BODY)) + with patch.object(svc, "_fetch_csrf_token", AsyncMock(return_value="csrf-token")): + result = await svc.verify_totp("tfa-key", "123456") + + assert result["reason"] == "captcha" + assert result["message"] != _CAPTCHA_BODY["error"] + + @pytest.mark.asyncio + async def test_an_ordinary_rejection_is_unchanged(self): + """Wrong password still says what Bambu said, and carries no reason -- + the UI must keep toasting those rather than showing the CAPTCHA panel.""" + svc = _service(_response(400, {"error": "Login failed"})) + + result = await svc.login_request("user@example.com", "wrong") + + assert result["message"] == "Login failed" + assert result.get("reason") is None + assert not bc.captcha_cooloff_active(svc.base_url) + + +class TestRetriesAreHeldBack: + @pytest.mark.asyncio + async def test_a_second_attempt_is_not_sent_to_bambu(self): + """The reporter's log shows four attempts in eighteen seconds. Every one + of them is more evidence for the thing that flagged us.""" + svc = _service(_response(418, _CAPTCHA_BODY)) + await svc.login_request("user@example.com", "pw") + assert svc._client.post.await_count == 1 + + result = await svc.login_request("user@example.com", "pw") + + assert svc._client.post.await_count == 1 + assert result["reason"] == "captcha" + + @pytest.mark.asyncio + async def test_the_cooloff_covers_a_fresh_service_instance(self): + """Services are built per request, so the cool-off has to outlive one.""" + await _service(_response(418, _CAPTCHA_BODY)).login_request("user@example.com", "pw") + + second = _service(_response(200, {"loginType": "verifyCode"})) + result = await second.login_request("user@example.com", "pw") + + second._client.post.assert_not_awaited() + assert result["reason"] == "captcha" + + @pytest.mark.asyncio + async def test_the_cooloff_expires(self): + svc = _service(_response(418, _CAPTCHA_BODY)) + await svc.login_request("user@example.com", "pw") + + bc._captcha_blocked_until[svc.base_url] = bc.time.monotonic() - 1 + svc._client.post = AsyncMock(return_value=_response(200, {"loginType": "verifyCode"})) + result = await svc.login_request("user@example.com", "pw") + + assert result["needs_verification"] is True + assert bc._captcha_blocked_until == {}, "the expired entry should be dropped on the way past" + + @pytest.mark.asyncio + async def test_a_challenge_on_the_api_host_does_not_strand_a_totp_sign_in(self): + """TOTP verification goes to bambulab.com, everything else to + api.bambulab.com. Blocking one on the other's behalf would leave a user + halfway through two-factor with no way forward.""" + svc = _service(_response(418, _CAPTCHA_BODY)) + await svc.login_request("user@example.com", "pw") + + svc._client.post = AsyncMock(return_value=_response(200, {"accessToken": "tok"})) + with patch.object(svc, "_fetch_csrf_token", AsyncMock(return_value="csrf-token")): + result = await svc.verify_totp("tfa-key", "123456") + + assert result["success"] is True + + @pytest.mark.asyncio + async def test_the_china_region_is_tracked_separately(self): + """The block lives at the edge in front of one region.""" + await _service(_response(418, _CAPTCHA_BODY)).login_request("user@example.com", "pw") + + cn = BambuCloudService(region="china", client=MagicMock(spec=httpx.AsyncClient)) + cn._client.post = AsyncMock(return_value=_response(200, {"loginType": "verifyCode"})) + result = await cn.login_request("user@example.com", "pw") + + cn._client.post.assert_awaited_once() + assert result["needs_verification"] is True + + +class TestMakerWorldSharesTheDetector: + @pytest.mark.asyncio + async def test_a_challenge_worded_differently_is_still_named(self): + """MakerWorld used to require the literal word "robot" in the error text + and reported anything else as an unexplained block.""" + from backend.app.services.makerworld import MakerWorldService, MakerWorldUnavailableError + + svc = MakerWorldService(client=MagicMock(spec=httpx.AsyncClient), auth_token="tok") + svc._client.get = AsyncMock(return_value=_response(418, {"captchaId": "abc", "error": "verification required"})) + + with pytest.raises(MakerWorldUnavailableError) as exc: + await svc._get_json("/design/1") + + assert "CAPTCHA" in str(exc.value) + assert "Open on MakerWorld" in str(exc.value) + + +class TestTheSupportBundleNamesIt: + def test_the_warning_we_log_matches_the_signature(self, tmp_path, monkeypatch, caplog): + """The reporter's bundle came back with zero log-health findings while + the log was full of the failure -- tie the two ends together.""" + from backend.app.core.config import settings as app_settings + from backend.app.services.log_health import scan_logs + + svc = _service(_response(418, _CAPTCHA_BODY)) + with caplog.at_level("WARNING", logger="backend.app.services.bambu_cloud"): + svc._note_captcha(_response(418, _CAPTCHA_BODY)) + logged = caplog.records[-1].getMessage() + + log_file = tmp_path / "bambuddy.log" + log_file.write_text( + f"2026-08-08 05:15:37,068 WARNING [backend.app.services.bambu_cloud] {logged}\n", + encoding="utf-8", + ) + monkeypatch.setattr(app_settings, "log_dir", tmp_path) + + findings = scan_logs().findings + + assert [f.signature_id for f in findings] == ["bambu-cloud-captcha"] + assert findings[0].wiki_anchor == "bambu-cloud-captcha" diff --git a/frontend/src/__tests__/pages/CloudLoginCaptcha.test.tsx b/frontend/src/__tests__/pages/CloudLoginCaptcha.test.tsx new file mode 100644 index 000000000..23b00e478 --- /dev/null +++ b/frontend/src/__tests__/pages/CloudLoginCaptcha.test.tsx @@ -0,0 +1,78 @@ +/** + * Bambu Cloud sign-in when Bambu is challenging the network with a CAPTCHA (#2790). + * + * The backend answers `reason: 'captcha'`, meaning no credential will be + * accepted until the challenge clears and there is nothing in Bambuddy that can + * answer it. A toast is the wrong shape for that: it names a problem the user + * cannot act on and then disappears. The reporter saw Bambu's own sentence, + * "We need you to confirm you are not a robot", flash by with no challenge + * behind it and filed it as a bug. + */ + +import { describe, it, expect } from 'vitest'; +import { screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { useTranslation } from 'react-i18next'; +import { http, HttpResponse } from 'msw'; +import { render } from '../utils'; +import { LoginForm } from '../../pages/ProfilesPage'; +import { server } from '../mocks/server'; + +function Harness() { + const { t } = useTranslation(); + return{t('profiles.login.subtitle')}
+ {captchaBlocked && step !== 'token' && ( +{t('profiles.login.captchaTitle')}
+{t('profiles.login.captchaBody')}
+ +