test_auth_middleware_errors.py 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203
  1. """The auth middleware fails closed on its own probe, and only there.
  2. The middleware asks the database whether authentication is on. If that probe
  3. fails it must answer 503 and never fall through to "auth off" (the fail-open
  4. bypass the probe used to have). Pre-fix the request itself also ran inside that
  5. ``try``, so with authentication off any error in any route was answered 503
  6. "Authentication service temporarily unavailable" and logged as an auth-probe
  7. failure, instead of surfacing as the 500 it is.
  8. """
  9. import logging
  10. from collections.abc import AsyncIterator
  11. from unittest.mock import AsyncMock, patch
  12. import pytest
  13. from fastapi.routing import APIRoute
  14. from httpx import ASGITransport, AsyncClient
  15. from backend.app.main import app
  16. PROBE_PATH = "/api/v1/__test_auth_middleware"
  17. @pytest.fixture
  18. async def client(async_client) -> AsyncIterator[AsyncClient]:
  19. """A client that receives the 500 instead of re-raising it, and a test route.
  20. Depends on async_client for the test database wiring it sets up.
  21. """
  22. calls = {"n": 0}
  23. async def route(crash: bool = False):
  24. calls["n"] += 1
  25. if crash:
  26. raise RuntimeError("route failed")
  27. return {"ok": True}
  28. # In front of everything else: the SPA's catch-all route would match first.
  29. app.router.routes.insert(0, APIRoute(PROBE_PATH, route, methods=["GET"]))
  30. try:
  31. async with AsyncClient(
  32. transport=ASGITransport(app=app, raise_app_exceptions=False), base_url="http://test"
  33. ) as c:
  34. c.calls = calls # type: ignore[attr-defined]
  35. yield c
  36. finally:
  37. app.router.routes[:] = [r for r in app.router.routes if getattr(r, "path", None) != PROBE_PATH]
  38. class TestFailsClosedOnTheProbe:
  39. @pytest.mark.asyncio
  40. async def test_a_failing_probe_answers_503_and_never_reaches_the_route(self, client):
  41. with patch("backend.app.core.auth.is_auth_enabled", AsyncMock(side_effect=OSError("too many open files"))):
  42. response = await client.get(PROBE_PATH)
  43. assert response.status_code == 503
  44. assert response.json() == {"detail": "Authentication service temporarily unavailable"}
  45. assert client.calls["n"] == 0
  46. class TestRouteErrorsAreNotAuthErrors:
  47. @pytest.mark.asyncio
  48. async def test_with_auth_off_a_route_error_is_a_500(self, client, caplog):
  49. with (
  50. patch("backend.app.core.auth.is_auth_enabled", AsyncMock(return_value=False)),
  51. caplog.at_level(logging.ERROR),
  52. ):
  53. response = await client.get(PROBE_PATH, params={"crash": "true"})
  54. assert response.status_code == 500
  55. assert "Authentication service" not in response.text
  56. assert "auth-probe" not in caplog.text
  57. assert client.calls["n"] == 1
  58. @pytest.mark.asyncio
  59. async def test_with_auth_off_a_working_route_still_answers(self, client):
  60. with patch("backend.app.core.auth.is_auth_enabled", AsyncMock(return_value=False)):
  61. response = await client.get(PROBE_PATH)
  62. assert response.status_code == 200
  63. assert response.json() == {"ok": True}
  64. @pytest.mark.asyncio
  65. async def test_with_auth_on_a_route_error_is_a_500(self, client):
  66. await client.post(
  67. "/api/v1/auth/setup",
  68. json={"auth_enabled": True, "admin_username": "mwadmin", "admin_password": "AdminPass1!"},
  69. )
  70. login = await client.post("/api/v1/auth/login", json={"username": "mwadmin", "password": "AdminPass1!"})
  71. assert login.status_code == 200, login.text
  72. headers = {"Authorization": f"Bearer {login.json()['access_token']}"}
  73. response = await client.get(PROBE_PATH, params={"crash": "true"}, headers=headers)
  74. assert response.status_code == 500
  75. assert "Authentication service" not in response.text
  76. class TestRouteErrorsReachTheLogFile:
  77. def test_uvicorns_error_log_goes_to_the_log_file_too(self):
  78. """An unhandled route error is logged by uvicorn on ``uvicorn.error``.
  79. Only ``uvicorn.access`` used to get the bambuddy.log handler, so such an
  80. error never reached the file a support package is built from.
  81. """
  82. from backend.app.main import _attach_file_handler_to_uvicorn
  83. handler = logging.NullHandler()
  84. try:
  85. _attach_file_handler_to_uvicorn(handler)
  86. assert handler in logging.getLogger("uvicorn.error").handlers
  87. assert handler in logging.getLogger("uvicorn.access").handlers
  88. finally:
  89. for name in ("uvicorn.error", "uvicorn.access"):
  90. logger = logging.getLogger(name)
  91. if handler in logger.handlers:
  92. logger.removeHandler(handler)
  93. def test_the_app_wires_uvicorns_error_log_to_bambuddy_log(self, tmp_path):
  94. """End to end: with file logging on (the default), importing the app
  95. attaches bambuddy.log to uvicorn.error, and a record there lands in it."""
  96. import os
  97. import subprocess
  98. import sys
  99. from pathlib import Path
  100. repo = Path(__file__).resolve().parents[3]
  101. env = {
  102. **os.environ,
  103. "LOG_TO_FILE": "true",
  104. "LOG_DIR": str(tmp_path),
  105. "DATA_DIR": str(tmp_path),
  106. "DATABASE_URL": f"sqlite+aiosqlite:///{tmp_path / 'app.db'}",
  107. "PYTHONPATH": str(repo),
  108. }
  109. # uvicorn configures its loggers before it imports the app, and its
  110. # config stops uvicorn.error short of the root logger; without that the
  111. # record would reach the root's file handler whatever the app does.
  112. script = (
  113. "import logging, logging.config\n"
  114. "from uvicorn.config import LOGGING_CONFIG\n"
  115. "logging.config.dictConfig(LOGGING_CONFIG)\n"
  116. "import backend.app.main\n"
  117. "logging.getLogger('uvicorn.error').error('Exception in ASGI application: marker-7f3a')\n"
  118. "for h in logging.getLogger().handlers: h.flush()\n"
  119. )
  120. result = subprocess.run(
  121. [sys.executable, "-c", script], cwd=tmp_path, env=env, capture_output=True, text=True, timeout=120
  122. )
  123. assert result.returncode == 0, result.stderr[-2000:]
  124. assert "marker-7f3a" in (tmp_path / "bambuddy.log").read_text(encoding="utf-8")
  125. class TestTokenCheckErrors:
  126. """With login on, a database error while checking a token is not a bad token.
  127. It used to answer 401 "Invalid token", and the frontend treats a 401 from
  128. /auth/me as final: it signs the user out and deletes the token. It now
  129. fails closed the way the probe does, with a logged 503 the frontend retries.
  130. """
  131. @staticmethod
  132. async def _login(client) -> dict[str, str]:
  133. await client.post(
  134. "/api/v1/auth/setup",
  135. json={"auth_enabled": True, "admin_username": "tkadmin", "admin_password": "AdminPass1!"},
  136. )
  137. login = await client.post("/api/v1/auth/login", json={"username": "tkadmin", "password": "AdminPass1!"})
  138. assert login.status_code == 200, login.text
  139. return {"Authorization": f"Bearer {login.json()['access_token']}"}
  140. @pytest.mark.asyncio
  141. async def test_a_database_error_is_a_logged_503_not_a_401(self, client, caplog):
  142. headers = await self._login(client)
  143. with (
  144. patch("backend.app.core.auth.is_jti_revoked", AsyncMock(side_effect=OSError("pool timed out"))),
  145. caplog.at_level(logging.ERROR),
  146. ):
  147. response = await client.get(PROBE_PATH, headers=headers)
  148. assert response.status_code == 503
  149. assert response.json() == {"detail": "Authentication service temporarily unavailable"}
  150. assert client.calls["n"] == 0
  151. assert "pool timed out" in caplog.text
  152. @pytest.mark.asyncio
  153. async def test_a_bad_token_is_still_a_401(self, client):
  154. await self._login(client)
  155. response = await client.get(PROBE_PATH, headers={"Authorization": "Bearer not-a-jwt"})
  156. assert response.status_code == 401
  157. assert response.json() == {"detail": "Invalid token"}
  158. assert client.calls["n"] == 0
  159. @pytest.mark.asyncio
  160. async def test_a_valid_token_still_reaches_the_route(self, client):
  161. headers = await self._login(client)
  162. response = await client.get(PROBE_PATH, headers=headers)
  163. assert response.status_code == 200
  164. assert client.calls["n"] == 1