mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 11:12:35 +02:00
Since #2562, a Bambu Cloud sign-in flipped to "expired" and forced constant re-logins even while cloud features worked. #2562 made a 401 durably record the stored token as dead, but treated *any* 401 from any cloud/MakerWorld call as expiry. Bambu 401s for benign reasons (endpoint/region/scope refusals, Cloudflare edge, transient blips), so one stray 401 -- including from a background poll -- signed the whole cloud integration out until manual re-login. The flag lives in the DB, so a setup with more than one instance against the same database signed the user out across all of them. Invalidate only on Bambu's documented expiry body {"code":4,"error":"Please login."}. A plain/unparseable 401 is treated as transient: the request fails but the session stays signed in. validate_token maps a signature-less 401 to None (unknown), never expired. A shared is_expiry_401() gates both the Bambu Cloud and MakerWorld services (same token). Genuine expiry is still detected and surfaced exactly as before.
373 lines
14 KiB
Python
373 lines
14 KiB
Python
"""Tests for Bambu Cloud sign-in expiry detection.
|
|
|
|
Bambu's access token is opaque — no readable expiry — and Bambuddy does not
|
|
persist the refresh token, so the only authority on whether a stored token still
|
|
works is Bambu itself. Bambuddy used to pretend otherwise: ``set_token()``
|
|
stamped ``token_expiry = now + 30 days`` *every time a stored token was loaded*,
|
|
which reset the expiry check on every request and made ``is_authenticated``
|
|
incapable of ever returning False. ``/cloud/status`` therefore reported
|
|
"connected" indefinitely while every cloud call 401'd, and the user was shown
|
|
Bambu's own ``{"error": "Please login."}`` as a toast — on a UI that was
|
|
simultaneously telling them they were signed in.
|
|
|
|
These tests pin: the expiry is no longer invented, a 401 is recorded durably,
|
|
a Bambu outage does not masquerade as an expired sign-in, and a fresh login
|
|
clears the flag.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime, timezone
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
|
|
import httpx
|
|
import pytest
|
|
|
|
from backend.app.api.routes.cloud import (
|
|
CLOUD_EMAIL_KEY,
|
|
CLOUD_REGION_KEY,
|
|
CLOUD_TOKEN_INVALID_KEY,
|
|
CLOUD_TOKEN_KEY,
|
|
clear_token,
|
|
is_cloud_token_invalid,
|
|
store_token,
|
|
)
|
|
from backend.app.models.settings import Settings
|
|
from backend.app.services import bambu_cloud as bc
|
|
from backend.app.services.bambu_cloud import BambuCloudService
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _clear_validation_cache():
|
|
"""The validation verdict cache is module-level; don't leak across tests."""
|
|
bc.invalidate_validation_cache()
|
|
yield
|
|
bc.invalidate_validation_cache()
|
|
|
|
|
|
# Bambu's genuine "token expired" 401 body — the only 401 that means sign-out.
|
|
_EXPIRY_401_BODY = {"code": 4, "error": "Please login.", "message": ""}
|
|
|
|
|
|
def _service(
|
|
status_code: int = 200,
|
|
*,
|
|
on_auth_failure=None,
|
|
raises: Exception | None = None,
|
|
body: object | None = None,
|
|
json_raises: bool = False,
|
|
):
|
|
svc = BambuCloudService(client=MagicMock(spec=httpx.AsyncClient), on_auth_failure=on_auth_failure)
|
|
resp = MagicMock()
|
|
resp.status_code = status_code
|
|
# A 401 defaults to Bambu's expiry body so existing "rejected token" cases
|
|
# mean a real expiry; pass body= to exercise a transient/benign 401.
|
|
if json_raises:
|
|
resp.json = MagicMock(side_effect=ValueError("not json"))
|
|
else:
|
|
resp.json = MagicMock(return_value=_EXPIRY_401_BODY if (body is None and status_code == 401) else (body or {}))
|
|
svc._client.get = AsyncMock(side_effect=raises) if raises else AsyncMock(return_value=resp)
|
|
return svc
|
|
|
|
|
|
class TestNoInventedExpiry:
|
|
def test_set_token_records_no_expiry(self):
|
|
"""The bug in one line: this used to be ``now + 30 days``, re-derived on
|
|
every request from a token of entirely unknown age."""
|
|
svc = _service()
|
|
svc.set_token("stored-token-of-unknown-age")
|
|
assert svc.token_expiry is None
|
|
|
|
def test_is_authenticated_means_loaded_not_accepted(self):
|
|
"""It still answers True for a loaded token — that is all it ever knew.
|
|
The point is that nobody may now read it as "Bambu accepts this"."""
|
|
svc = _service()
|
|
assert svc.is_authenticated is False
|
|
svc.set_token("stored-token")
|
|
assert svc.is_authenticated is True
|
|
|
|
|
|
class TestValidateToken:
|
|
@pytest.mark.asyncio
|
|
async def test_accepted_token_returns_true(self):
|
|
svc = _service(200)
|
|
svc.set_token("good-token")
|
|
assert await svc.validate_token() is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_rejected_token_returns_false(self):
|
|
svc = _service(401) # defaults to Bambu's genuine expiry body
|
|
svc.set_token("dead-token")
|
|
assert await svc.validate_token() is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_transient_401_is_unknown_not_invalid(self):
|
|
"""A 401 WITHOUT Bambu's expiry signature is edge/endpoint noise, not a
|
|
dead token — it must read as unknown, never sign the user out. This is
|
|
the regression that logged users out on a single stray 401."""
|
|
svc = _service(401, body={"code": 1, "error": "forbidden"})
|
|
svc.set_token("good-token")
|
|
assert await svc.validate_token() is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_unparseable_401_is_unknown_not_invalid(self):
|
|
svc = _service(401, json_raises=True)
|
|
svc.set_token("good-token")
|
|
assert await svc.validate_token() is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_expiry_signature_via_please_login_text(self):
|
|
"""The `code:4` field is primary, but the "Please login." text alone
|
|
(no/other code) is still accepted as the expiry signal."""
|
|
svc = _service(401, body={"error": "Please login.", "message": ""})
|
|
svc.set_token("dead-token")
|
|
assert await svc.validate_token() is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_no_token_is_not_authenticated(self):
|
|
svc = _service(200)
|
|
assert await svc.validate_token() is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_network_failure_is_unknown_not_invalid(self):
|
|
"""A Bambu outage must never present as "your sign-in expired" — that
|
|
would sign every user out of a perfectly good session."""
|
|
svc = _service(raises=httpx.ConnectError("no route to host"))
|
|
svc.set_token("good-token")
|
|
assert await svc.validate_token() is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_server_error_is_unknown_not_invalid(self):
|
|
svc = _service(503)
|
|
svc.set_token("good-token")
|
|
assert await svc.validate_token() is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cloudflare_challenge_is_unknown_not_invalid(self):
|
|
"""418/403 from Bambu's anti-abuse edge means the *request* was refused,
|
|
not the token. Declaring the credential dead there would log users out
|
|
whenever Cloudflare gets suspicious of their IP."""
|
|
svc = _service(418)
|
|
svc.set_token("good-token")
|
|
assert await svc.validate_token() is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_verdict_is_cached(self):
|
|
"""/cloud/status is polled by several components; without the cache each
|
|
render would put a Bambu round-trip in front of the settings page."""
|
|
svc = _service(200)
|
|
svc.set_token("good-token")
|
|
assert await svc.validate_token() is True
|
|
assert await svc.validate_token() is True
|
|
assert svc._client.get.await_count == 1
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cache_is_keyed_per_token(self):
|
|
svc = _service(200)
|
|
svc.set_token("token-a")
|
|
assert await svc.validate_token() is True
|
|
|
|
other = _service(401)
|
|
other.set_token("token-b")
|
|
assert await other.validate_token() is False, "a different token must not inherit the cached verdict"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_login_drops_a_cached_rejection(self):
|
|
"""Re-login must not leave the user staring at "sign-in expired" for the
|
|
rest of the cache TTL."""
|
|
svc = _service(401)
|
|
svc.set_token("tok")
|
|
assert await svc.validate_token() is False
|
|
|
|
fresh = _service(200)
|
|
fresh._set_tokens({"accessToken": "tok"}) # same string, freshly minted upstream
|
|
assert await fresh.validate_token() is True
|
|
|
|
|
|
class TestAuthFailureCallback:
|
|
@pytest.mark.asyncio
|
|
async def test_401_fires_the_callback(self):
|
|
calls: list[int] = []
|
|
|
|
async def _cb() -> None:
|
|
calls.append(1)
|
|
|
|
svc = _service(401, on_auth_failure=_cb)
|
|
svc.set_token("dead-token")
|
|
await svc.validate_token()
|
|
assert calls == [1]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_reported_once_per_service(self):
|
|
"""A route that makes several cloud calls must not write the flag once
|
|
per call."""
|
|
calls: list[int] = []
|
|
|
|
async def _cb() -> None:
|
|
calls.append(1)
|
|
|
|
svc = _service(401, on_auth_failure=_cb)
|
|
svc.set_token("dead-token")
|
|
resp = MagicMock()
|
|
resp.status_code = 401
|
|
resp.json = MagicMock(return_value=_EXPIRY_401_BODY)
|
|
await svc._note_response(resp)
|
|
await svc._note_response(resp)
|
|
await svc._note_response(resp)
|
|
assert calls == [1]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_transient_401_does_not_fire_the_callback(self):
|
|
"""A benign 401 must not durably invalidate — the callback that persists
|
|
the dead-token flag stays untouched."""
|
|
calls: list[int] = []
|
|
|
|
async def _cb() -> None:
|
|
calls.append(1)
|
|
|
|
svc = _service(401, on_auth_failure=_cb, body={"code": 1, "error": "forbidden"})
|
|
svc.set_token("good-token")
|
|
await svc.validate_token()
|
|
assert calls == []
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_success_does_not_fire_the_callback(self):
|
|
calls: list[int] = []
|
|
|
|
async def _cb() -> None:
|
|
calls.append(1)
|
|
|
|
svc = _service(200, on_auth_failure=_cb)
|
|
svc.set_token("good-token")
|
|
await svc.validate_token()
|
|
assert calls == []
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_callback_failure_does_not_mask_the_401(self):
|
|
"""Recording the dead credential is bookkeeping. If it throws, the caller
|
|
must still get the auth failure it was actually waiting for."""
|
|
|
|
async def _cb() -> None:
|
|
raise RuntimeError("database is on fire")
|
|
|
|
svc = _service(401, on_auth_failure=_cb)
|
|
svc.set_token("dead-token")
|
|
assert await svc.validate_token() is False
|
|
|
|
|
|
class TestPersistedFlag:
|
|
"""Auth-disabled deployments keep cloud credentials in the Settings table."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_absent_by_default(self, db_session):
|
|
assert await is_cloud_token_invalid(db_session, None) is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_set_flag_is_read_back(self, db_session):
|
|
db_session.add(Settings(key=CLOUD_TOKEN_INVALID_KEY, value=datetime.now(timezone.utc).isoformat()))
|
|
await db_session.commit()
|
|
assert await is_cloud_token_invalid(db_session, None) is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_fresh_login_clears_the_flag(self, db_session):
|
|
"""Otherwise the new sign-in is reported as expired the instant it's stored."""
|
|
db_session.add(Settings(key=CLOUD_TOKEN_INVALID_KEY, value="2026-07-14T07:00:00+00:00"))
|
|
await db_session.commit()
|
|
|
|
await store_token(db_session, "brand-new-token", "user@example.com", "global", None)
|
|
|
|
assert await is_cloud_token_invalid(db_session, None) is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_logout_clears_the_flag(self, db_session):
|
|
for key, value in [
|
|
(CLOUD_TOKEN_KEY, "dead"),
|
|
(CLOUD_EMAIL_KEY, "user@example.com"),
|
|
(CLOUD_REGION_KEY, "global"),
|
|
(CLOUD_TOKEN_INVALID_KEY, "2026-07-14T07:00:00+00:00"),
|
|
]:
|
|
db_session.add(Settings(key=key, value=value))
|
|
await db_session.commit()
|
|
|
|
await clear_token(db_session, None)
|
|
|
|
assert await is_cloud_token_invalid(db_session, None) is False
|
|
|
|
|
|
class TestStatusRoute:
|
|
"""The endpoint that was lying. ``GET /cloud/status`` drives the "Connected
|
|
as ..." bar on the Profiles page and the green dot in Settings."""
|
|
|
|
async def _store(self, db_session, *, invalid: bool = False):
|
|
db_session.add(Settings(key=CLOUD_TOKEN_KEY, value="stored-token"))
|
|
db_session.add(Settings(key=CLOUD_EMAIL_KEY, value="user@example.com"))
|
|
db_session.add(Settings(key=CLOUD_REGION_KEY, value="global"))
|
|
if invalid:
|
|
db_session.add(Settings(key=CLOUD_TOKEN_INVALID_KEY, value="2026-07-14T07:00:00+00:00"))
|
|
await db_session.commit()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_no_token_is_not_expired(self, async_client, db_session):
|
|
body = (await async_client.get("/api/v1/cloud/status")).json()
|
|
assert body["is_authenticated"] is False
|
|
assert body["sign_in_expired"] is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_token_bambu_rejects_reports_expired(self, async_client, db_session, monkeypatch):
|
|
"""The whole bug: a stored token Bambu no longer accepts used to come back
|
|
as ``is_authenticated: true``, forever."""
|
|
await self._store(db_session)
|
|
monkeypatch.setattr(BambuCloudService, "validate_token", AsyncMock(return_value=False))
|
|
|
|
body = (await async_client.get("/api/v1/cloud/status")).json()
|
|
|
|
assert body["is_authenticated"] is False
|
|
assert body["sign_in_expired"] is True
|
|
assert body["email"] is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_token_bambu_accepts_reports_connected(self, async_client, db_session, monkeypatch):
|
|
await self._store(db_session)
|
|
monkeypatch.setattr(BambuCloudService, "validate_token", AsyncMock(return_value=True))
|
|
|
|
body = (await async_client.get("/api/v1/cloud/status")).json()
|
|
|
|
assert body["is_authenticated"] is True
|
|
assert body["sign_in_expired"] is False
|
|
assert body["email"] == "user@example.com"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_bambu_unreachable_keeps_the_user_signed_in(self, async_client, db_session, monkeypatch):
|
|
"""Unknown is not invalid. A Bambu outage must not log the whole install
|
|
out of the cloud."""
|
|
await self._store(db_session)
|
|
monkeypatch.setattr(BambuCloudService, "validate_token", AsyncMock(return_value=None))
|
|
|
|
body = (await async_client.get("/api/v1/cloud/status")).json()
|
|
|
|
assert body["is_authenticated"] is True
|
|
assert body["sign_in_expired"] is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_bambu_unreachable_does_not_resurrect_a_known_dead_token(self, async_client, db_session, monkeypatch):
|
|
"""...but "unknown" must fall back to what we last knew, not to True."""
|
|
await self._store(db_session, invalid=True)
|
|
monkeypatch.setattr(BambuCloudService, "validate_token", AsyncMock(return_value=None))
|
|
|
|
body = (await async_client.get("/api/v1/cloud/status")).json()
|
|
|
|
assert body["is_authenticated"] is False
|
|
assert body["sign_in_expired"] is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_known_dead_token_does_not_re_ask_bambu(self, async_client, db_session, monkeypatch):
|
|
"""Only a new login can revive it, and that clears the flag — so polling
|
|
Bambu on every status call would be pure waste."""
|
|
await self._store(db_session, invalid=True)
|
|
validate = AsyncMock(return_value=False)
|
|
monkeypatch.setattr(BambuCloudService, "validate_token", validate)
|
|
|
|
await async_client.get("/api/v1/cloud/status")
|
|
|
|
validate.assert_not_awaited()
|