test_bambu_cloud_credentials.py 3.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106
  1. """Tests for ``services/bambu_cloud_credentials`` — the credential seam.
  2. The read paths are covered indirectly by the cloud-token expiry and
  3. migration suites; these pin the write path that review blocker 5 hinged on:
  4. ``mark_cloud_token_invalid`` must record a rejection for *both* identity
  5. shapes, because auth-disabled single-user installs (the default) hold their
  6. token in global ``Settings`` — ``user_id=None`` is a real, expected input,
  7. not a degenerate one.
  8. """
  9. from __future__ import annotations
  10. from datetime import datetime
  11. import pytest
  12. from sqlalchemy import select
  13. from backend.app.core.auth import get_password_hash
  14. from backend.app.models.settings import Settings
  15. from backend.app.models.user import User
  16. from backend.app.services import bambu_cloud_credentials as creds
  17. from backend.app.services.bambu_cloud_credentials import (
  18. CLOUD_TOKEN_INVALID_KEY,
  19. mark_cloud_token_invalid,
  20. )
  21. pytestmark = pytest.mark.asyncio
  22. class _SharedSessionCtx:
  23. """Route ``mark`` through the fixture's in-memory session: the function
  24. normally opens its own session against the configured database, which in
  25. tests is a different SQLite than ``db_session``'s in-memory one."""
  26. def __init__(self, session):
  27. self._session = session
  28. async def __aenter__(self):
  29. return self._session
  30. async def __aexit__(self, *exc):
  31. return False
  32. @pytest.fixture(autouse=True)
  33. def shared_session(db_session, monkeypatch):
  34. monkeypatch.setattr(creds, "async_session", lambda: _SharedSessionCtx(db_session))
  35. async def _make_user(db, username: str = "cred-user") -> User:
  36. user = User(
  37. username=username,
  38. password_hash=get_password_hash("AdminPass1!"),
  39. role="admin",
  40. is_active=True,
  41. )
  42. db.add(user)
  43. await db.commit()
  44. await db.refresh(user)
  45. return user
  46. async def test_mark_sets_the_per_user_flag(db_session):
  47. """user_id set → the rejection lands on that user's column."""
  48. user = await _make_user(db_session)
  49. await mark_cloud_token_invalid(user.id)
  50. await db_session.refresh(user)
  51. assert user.cloud_token_invalid_at is not None
  52. async def test_mark_none_writes_the_global_settings_flag(db_session):
  53. """user_id=None (auth-disabled install) → the global ``Settings`` row.
  54. A second call updates the existing row rather than adding another."""
  55. await mark_cloud_token_invalid(None)
  56. result = await db_session.execute(select(Settings).where(Settings.key == CLOUD_TOKEN_INVALID_KEY))
  57. rows = result.scalars().all()
  58. assert len(rows) == 1
  59. # Stored value parses as ISO — the status endpoints compare it as a date.
  60. datetime.fromisoformat(rows[0].value)
  61. first_value = rows[0].value
  62. await mark_cloud_token_invalid(None)
  63. result = await db_session.execute(select(Settings).where(Settings.key == CLOUD_TOKEN_INVALID_KEY))
  64. rows = result.scalars().all()
  65. assert len(rows) == 1
  66. assert rows[0].value >= first_value
  67. async def test_mark_is_best_effort(db_session, monkeypatch):
  68. """A bookkeeping failure must never replace the 401 the caller needs to
  69. see — the function swallows everything."""
  70. class _Boom:
  71. async def __aenter__(self):
  72. raise RuntimeError("db gone")
  73. async def __aexit__(self, *exc):
  74. return False
  75. monkeypatch.setattr(creds, "async_session", lambda: _Boom())
  76. # Must not raise.
  77. await mark_cloud_token_invalid(None)