test_cli.py 5.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171
  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"