test_cli.py 6.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205
  1. """Unit tests for the ``backend.app.cli`` kiosk-bootstrap subcommand."""
  2. from __future__ import annotations
  3. from collections.abc import AsyncGenerator
  4. import pytest
  5. import pytest_asyncio
  6. from sqlalchemy import select
  7. from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
  8. from backend.app.cli import DEFAULT_KIOSK_KEY_NAME, KioskBootstrapError, kiosk_bootstrap
  9. from backend.app.core.auth import _validate_api_key
  10. from backend.app.core.database import Base
  11. from backend.app.models.api_key import APIKey
  12. from backend.app.models.settings import Settings
  13. @pytest_asyncio.fixture
  14. async def session_maker() -> AsyncGenerator[async_sessionmaker, None]:
  15. engine = create_async_engine("sqlite+aiosqlite:///:memory:")
  16. async with engine.begin() as conn:
  17. await conn.run_sync(Base.metadata.create_all)
  18. maker = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
  19. try:
  20. yield maker
  21. finally:
  22. await engine.dispose()
  23. @pytest.mark.asyncio
  24. @pytest.mark.unit
  25. async def test_bootstrap_creates_key_when_none_exists(session_maker):
  26. key = await kiosk_bootstrap(
  27. DEFAULT_KIOSK_KEY_NAME,
  28. force=False,
  29. session_maker=session_maker,
  30. ensure_schema=False,
  31. )
  32. assert key.startswith("bb_")
  33. assert len(key) > 20
  34. async with session_maker() as db:
  35. rows = (await db.execute(select(APIKey))).scalars().all()
  36. assert len(rows) == 1
  37. row = rows[0]
  38. assert row.name == DEFAULT_KIOSK_KEY_NAME
  39. assert row.enabled is True
  40. assert row.can_queue is False
  41. assert row.can_control_printer is False
  42. assert row.can_read_status is True
  43. assert row.printer_ids is None
  44. assert row.expires_at is None
  45. assert row.key_prefix.startswith("bb_")
  46. assert row.key_hash != key # stored value is a hash, not the plaintext
  47. @pytest.mark.asyncio
  48. @pytest.mark.unit
  49. async def test_bootstrap_refuses_to_overwrite_without_force(session_maker):
  50. first = await kiosk_bootstrap(
  51. DEFAULT_KIOSK_KEY_NAME,
  52. force=False,
  53. session_maker=session_maker,
  54. ensure_schema=False,
  55. )
  56. with pytest.raises(KioskBootstrapError) as exc_info:
  57. await kiosk_bootstrap(
  58. DEFAULT_KIOSK_KEY_NAME,
  59. force=False,
  60. session_maker=session_maker,
  61. ensure_schema=False,
  62. )
  63. assert "already exists" in str(exc_info.value)
  64. assert "--force" in str(exc_info.value)
  65. # First key survives unchanged and still validates
  66. async with session_maker() as db:
  67. row = (await db.execute(select(APIKey))).scalar_one()
  68. validated = await _validate_api_key(db, first)
  69. assert validated is not None
  70. assert validated.id == row.id
  71. @pytest.mark.asyncio
  72. @pytest.mark.unit
  73. async def test_bootstrap_force_rotates_existing_key(session_maker):
  74. first = await kiosk_bootstrap(
  75. DEFAULT_KIOSK_KEY_NAME,
  76. force=False,
  77. session_maker=session_maker,
  78. ensure_schema=False,
  79. )
  80. second = await kiosk_bootstrap(
  81. DEFAULT_KIOSK_KEY_NAME,
  82. force=True,
  83. session_maker=session_maker,
  84. ensure_schema=False,
  85. )
  86. assert first != second
  87. async with session_maker() as db:
  88. rows = (await db.execute(select(APIKey))).scalars().all()
  89. assert len(rows) == 1 # old row was deleted, not duplicated
  90. # Old key no longer validates, new key does
  91. assert await _validate_api_key(db, first) is None
  92. validated = await _validate_api_key(db, second)
  93. assert validated is not None
  94. assert validated.name == DEFAULT_KIOSK_KEY_NAME
  95. @pytest.mark.asyncio
  96. @pytest.mark.unit
  97. async def test_bootstrap_marks_setup_completed(session_maker):
  98. """Bootstrap must set setup_completed=true so AuthContext doesn't redirect the kiosk to /setup."""
  99. await kiosk_bootstrap(
  100. DEFAULT_KIOSK_KEY_NAME,
  101. force=False,
  102. session_maker=session_maker,
  103. ensure_schema=False,
  104. )
  105. async with session_maker() as db:
  106. setting = (await db.execute(select(Settings).where(Settings.key == "setup_completed"))).scalar_one()
  107. assert setting.value == "true"
  108. @pytest.mark.asyncio
  109. @pytest.mark.unit
  110. async def test_bootstrap_setup_idempotent_on_rotate(session_maker):
  111. """Re-running with --force must not duplicate the setup_completed row."""
  112. await kiosk_bootstrap(
  113. DEFAULT_KIOSK_KEY_NAME,
  114. force=False,
  115. session_maker=session_maker,
  116. ensure_schema=False,
  117. )
  118. await kiosk_bootstrap(
  119. DEFAULT_KIOSK_KEY_NAME,
  120. force=True,
  121. session_maker=session_maker,
  122. ensure_schema=False,
  123. )
  124. async with session_maker() as db:
  125. rows = (await db.execute(select(Settings).where(Settings.key == "setup_completed"))).scalars().all()
  126. assert len(rows) == 1
  127. assert rows[0].value == "true"
  128. @pytest.mark.asyncio
  129. @pytest.mark.unit
  130. async def test_bootstrap_custom_name(session_maker):
  131. key = await kiosk_bootstrap(
  132. "custom-kiosk-name",
  133. force=False,
  134. session_maker=session_maker,
  135. ensure_schema=False,
  136. )
  137. async with session_maker() as db:
  138. row = (await db.execute(select(APIKey))).scalar_one()
  139. assert row.name == "custom-kiosk-name"
  140. validated = await _validate_api_key(db, key)
  141. assert validated is not None
  142. assert validated.name == "custom-kiosk-name"
  143. @pytest.mark.asyncio
  144. @pytest.mark.unit
  145. async def test_rotating_drops_the_old_keys_links(session_maker):
  146. """The new key gets a new id, and the old key's camera and WebSocket links go.
  147. Deleted first, SQLite gave the new key the old one's id: the old (perhaps
  148. leaked) key's links and open dashboards took the new key's printers, all of
  149. them even when an admin had limited or disabled the old key.
  150. """
  151. from datetime import datetime, timedelta, timezone
  152. from backend.app.models.auth_ephemeral import AuthEphemeralToken
  153. await kiosk_bootstrap(DEFAULT_KIOSK_KEY_NAME, force=False, session_maker=session_maker, ensure_schema=False)
  154. async with session_maker() as db:
  155. old_id = (await db.execute(select(APIKey.id))).scalar_one()
  156. expires = datetime.now(timezone.utc) + timedelta(hours=1)
  157. db.add_all(
  158. [
  159. AuthEphemeralToken(
  160. token=f"{kind}-old", token_type=kind, username="", api_key_id=old_id, expires_at=expires
  161. )
  162. for kind in ("camera_stream", "websocket")
  163. ]
  164. )
  165. await db.commit()
  166. await kiosk_bootstrap(DEFAULT_KIOSK_KEY_NAME, force=True, session_maker=session_maker, ensure_schema=False)
  167. async with session_maker() as db:
  168. assert (await db.execute(select(APIKey.id))).scalar_one() != old_id
  169. assert (await db.execute(select(AuthEphemeralToken.token))).scalars().all() == []