test_api_key_infra_errors.py 9.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268
  1. """A database error while checking an API key is not an invalid key.
  2. ``_validate_api_key`` caught every exception and returned None, which every
  3. caller answers with 401 "Invalid API key": a valid key was refused as wrong
  4. during a pool timeout, or when writing its last-used time hit "database is
  5. locked", and the web interface clears its sign-in on that message. Such an
  6. error now refuses the request with 503, as the auth checks in the middleware
  7. do, and is logged.
  8. Writing the key's last-used time is the exception: the key has been checked by
  9. then, so a failed write (a read-only replica after a failover) is logged and
  10. the key accepted. The time is written at most once a minute (#3360).
  11. """
  12. import logging
  13. from contextlib import contextmanager
  14. from datetime import datetime, timedelta, timezone
  15. from unittest.mock import patch
  16. import pytest
  17. from sqlalchemy import event, update
  18. from sqlalchemy.exc import OperationalError
  19. from sqlalchemy.ext.asyncio import AsyncSession
  20. from backend.app.core import auth
  21. from backend.app.models.api_key import APIKey
  22. async def _admin_headers(async_client) -> dict[str, str]:
  23. await async_client.post(
  24. "/api/v1/auth/setup",
  25. json={"auth_enabled": True, "admin_username": "keyadmin", "admin_password": "AdminPass1!"},
  26. )
  27. login = await async_client.post("/api/v1/auth/login", json={"username": "keyadmin", "password": "AdminPass1!"})
  28. assert login.status_code == 200, login.text
  29. return {"Authorization": f"Bearer {login.json()['access_token']}"}
  30. async def _key(async_client) -> str:
  31. admin = await _admin_headers(async_client)
  32. created = await async_client.post(
  33. "/api/v1/api-keys/", headers=admin, json={"name": "integration", "can_read_status": True}
  34. )
  35. assert created.status_code in (200, 201), created.text
  36. return created.json()["key"]
  37. @contextmanager
  38. def _failing(test_engine, *statement_starts: str):
  39. """Make the next statements starting with *statement_starts*, in turn, fail
  40. as SQLite does when locked."""
  41. state = {"failed": False, "left": list(statement_starts)}
  42. def hook(conn, cursor, statement, parameters, context, executemany):
  43. if state["left"] and statement.lstrip().upper().startswith(state["left"][0]):
  44. state["left"].pop(0)
  45. state["failed"] = not state["left"]
  46. raise OperationalError(statement, parameters, Exception("database is locked"))
  47. event.listen(test_engine.sync_engine, "before_cursor_execute", hook)
  48. try:
  49. yield state
  50. finally:
  51. event.remove(test_engine.sync_engine, "before_cursor_execute", hook)
  52. @contextmanager
  53. def _counting(test_engine, statement_start: str):
  54. seen: list[str] = []
  55. def hook(conn, cursor, statement, parameters, context, executemany):
  56. if statement.lstrip().upper().startswith(statement_start):
  57. seen.append(statement)
  58. event.listen(test_engine.sync_engine, "before_cursor_execute", hook)
  59. try:
  60. yield seen
  61. finally:
  62. event.remove(test_engine.sync_engine, "before_cursor_execute", hook)
  63. PROTECTED = "/api/v1/printers/"
  64. @pytest.mark.asyncio
  65. @pytest.mark.integration
  66. async def test_a_valid_key_works(async_client):
  67. key = await _key(async_client)
  68. response = await async_client.get(PROTECTED, headers={"X-API-Key": key})
  69. assert response.status_code == 200, response.text
  70. @pytest.mark.asyncio
  71. @pytest.mark.integration
  72. async def test_a_failed_key_lookup_is_a_logged_503(async_client, test_engine, caplog):
  73. key = await _key(async_client)
  74. with _failing(test_engine, "SELECT API_KEYS") as state, caplog.at_level(logging.ERROR):
  75. response = await async_client.get(PROTECTED, headers={"X-API-Key": key})
  76. assert state["failed"]
  77. assert response.status_code == 503, response.text
  78. assert response.json()["detail"] == "Authentication service temporarily unavailable"
  79. assert "database is locked" in caplog.text
  80. @pytest.mark.asyncio
  81. @pytest.mark.integration
  82. async def test_a_failed_last_used_write_still_lets_the_key_in(async_client, test_engine, caplog):
  83. """The reporter's read-only replica: the key matched, only the write failed."""
  84. key = await _key(async_client)
  85. with _failing(test_engine, "UPDATE API_KEYS") as state, caplog.at_level(logging.WARNING):
  86. response = await async_client.get(PROTECTED, headers={"X-API-Key": key})
  87. assert state["failed"]
  88. assert response.status_code == 200, response.text
  89. assert "Could not record API key" in caplog.text
  90. @pytest.mark.asyncio
  91. @pytest.mark.integration
  92. async def test_a_failed_write_and_a_failed_reread_is_a_503(async_client, test_engine):
  93. key = await _key(async_client)
  94. with _failing(test_engine, "UPDATE API_KEYS", "SELECT API_KEYS") as state:
  95. response = await async_client.get(PROTECTED, headers={"X-API-Key": key})
  96. assert state["failed"]
  97. assert response.status_code == 503, response.text
  98. @pytest.mark.asyncio
  99. @pytest.mark.integration
  100. async def test_a_key_turned_off_before_the_reread_is_refused(async_client, test_engine):
  101. key = await _key(async_client)
  102. original = AsyncSession.refresh
  103. async def refresh_after_disabling(self, instance, *args, **kwargs):
  104. await original(self, instance, *args, **kwargs)
  105. if isinstance(instance, APIKey):
  106. instance.enabled = False
  107. with (
  108. _failing(test_engine, "UPDATE API_KEYS"),
  109. patch.object(AsyncSession, "refresh", refresh_after_disabling),
  110. ):
  111. response = await async_client.get(PROTECTED, headers={"X-API-Key": key})
  112. assert response.status_code == 401, response.text
  113. @pytest.mark.asyncio
  114. @pytest.mark.integration
  115. async def test_a_key_set_to_expire_before_the_reread_is_refused(async_client, test_engine):
  116. """Setting a past expiry revokes a key just as turning it off does."""
  117. key = await _key(async_client)
  118. original = AsyncSession.refresh
  119. async def refresh_after_expiring(self, instance, *args, **kwargs):
  120. await original(self, instance, *args, **kwargs)
  121. if isinstance(instance, APIKey):
  122. instance.expires_at = datetime.now(timezone.utc) - timedelta(seconds=1)
  123. with (
  124. _failing(test_engine, "UPDATE API_KEYS"),
  125. patch.object(AsyncSession, "refresh", refresh_after_expiring),
  126. ):
  127. response = await async_client.get(PROTECTED, headers={"X-API-Key": key})
  128. assert response.status_code == 401, response.text
  129. @pytest.mark.asyncio
  130. @pytest.mark.integration
  131. async def test_a_failing_write_is_not_retried_on_every_request(async_client, test_engine):
  132. """On SQLite each try waits out the busy timeout first."""
  133. key = await _key(async_client)
  134. with _counting(test_engine, "UPDATE API_KEYS") as writes, _failing(test_engine, "UPDATE API_KEYS"):
  135. for _ in range(3):
  136. response = await async_client.get(PROTECTED, headers={"X-API-Key": key})
  137. assert response.status_code == 200, response.text
  138. assert len(writes) == 1
  139. @pytest.mark.asyncio
  140. @pytest.mark.integration
  141. async def test_last_used_is_written_at_most_once_a_minute(async_client, test_engine, db_session):
  142. key = await _key(async_client)
  143. with _counting(test_engine, "UPDATE API_KEYS") as writes:
  144. for _ in range(3):
  145. response = await async_client.get(PROTECTED, headers={"X-API-Key": key})
  146. assert response.status_code == 200, response.text
  147. assert len(writes) == 1
  148. await db_session.execute(
  149. update(APIKey)
  150. .values(last_used=datetime.now(timezone.utc) - timedelta(minutes=2))
  151. .where(APIKey.name == "integration")
  152. )
  153. await db_session.commit()
  154. # The attempt this process remembers is a minute old too.
  155. auth._api_key_last_used._attempts.clear()
  156. with _counting(test_engine, "UPDATE API_KEYS") as writes:
  157. response = await async_client.get(PROTECTED, headers={"X-API-Key": key})
  158. assert response.status_code == 200, response.text
  159. assert len(writes) == 1
  160. @pytest.mark.asyncio
  161. @pytest.mark.integration
  162. async def test_a_wrong_key_is_still_a_401(async_client):
  163. key = await _key(async_client)
  164. response = await async_client.get(PROTECTED, headers={"X-API-Key": key[:-4] + "XXXX"})
  165. # The message depends on the route's dependency ("Invalid API key" or
  166. # "Authentication required"); the refusal is what matters.
  167. assert response.status_code == 401
  168. @pytest.mark.asyncio
  169. @pytest.mark.integration
  170. async def test_an_expired_key_is_still_a_401(async_client, db_session):
  171. key = await _key(async_client)
  172. await db_session.execute(
  173. update(APIKey)
  174. .values(expires_at=datetime.now(timezone.utc) - timedelta(days=1))
  175. .where(APIKey.name == "integration")
  176. )
  177. await db_session.commit()
  178. response = await async_client.get(PROTECTED, headers={"X-API-Key": key})
  179. assert response.status_code == 401
  180. @pytest.mark.asyncio
  181. @pytest.mark.integration
  182. async def test_a_key_row_with_an_unreadable_hash_is_no_match_and_is_reported(async_client, db_session, caplog):
  183. key = await _key(async_client)
  184. await db_session.execute(update(APIKey).values(key_hash="not-a-hash").where(APIKey.name == "integration"))
  185. await db_session.commit()
  186. with caplog.at_level(logging.WARNING):
  187. response = await async_client.get(PROTECTED, headers={"X-API-Key": key})
  188. assert response.status_code == 401
  189. assert "unreadable hash" in caplog.text
  190. @pytest.mark.asyncio
  191. @pytest.mark.integration
  192. async def test_an_oversized_key_is_refused_without_blaming_the_stored_hash(async_client, caplog):
  193. """passlib refuses secrets over 4096 characters; that is the client's input,
  194. not a broken row, and must not be logged as one."""
  195. key = await _key(async_client)
  196. with caplog.at_level(logging.WARNING):
  197. response = await async_client.get(PROTECTED, headers={"X-API-Key": key[:8] + "a" * 5000})
  198. assert response.status_code == 401
  199. assert "unreadable hash" not in caplog.text