test_api_key_infra_errors.py 5.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150
  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. """
  9. import logging
  10. from contextlib import contextmanager
  11. from datetime import datetime, timedelta, timezone
  12. import pytest
  13. from sqlalchemy import event, update
  14. from sqlalchemy.exc import OperationalError
  15. from backend.app.models.api_key import APIKey
  16. async def _admin_headers(async_client) -> dict[str, str]:
  17. await async_client.post(
  18. "/api/v1/auth/setup",
  19. json={"auth_enabled": True, "admin_username": "keyadmin", "admin_password": "AdminPass1!"},
  20. )
  21. login = await async_client.post("/api/v1/auth/login", json={"username": "keyadmin", "password": "AdminPass1!"})
  22. assert login.status_code == 200, login.text
  23. return {"Authorization": f"Bearer {login.json()['access_token']}"}
  24. async def _key(async_client) -> str:
  25. admin = await _admin_headers(async_client)
  26. created = await async_client.post(
  27. "/api/v1/api-keys/", headers=admin, json={"name": "integration", "can_read_status": True}
  28. )
  29. assert created.status_code in (200, 201), created.text
  30. return created.json()["key"]
  31. @contextmanager
  32. def _failing(test_engine, statement_start: str):
  33. """Make the next statement starting with *statement_start* fail as SQLite does when locked."""
  34. state = {"failed": False}
  35. def hook(conn, cursor, statement, parameters, context, executemany):
  36. if not state["failed"] and statement.lstrip().upper().startswith(statement_start):
  37. state["failed"] = True
  38. raise OperationalError(statement, parameters, Exception("database is locked"))
  39. event.listen(test_engine.sync_engine, "before_cursor_execute", hook)
  40. try:
  41. yield state
  42. finally:
  43. event.remove(test_engine.sync_engine, "before_cursor_execute", hook)
  44. PROTECTED = "/api/v1/printers/"
  45. @pytest.mark.asyncio
  46. @pytest.mark.integration
  47. async def test_a_valid_key_works(async_client):
  48. key = await _key(async_client)
  49. response = await async_client.get(PROTECTED, headers={"X-API-Key": key})
  50. assert response.status_code == 200, response.text
  51. @pytest.mark.asyncio
  52. @pytest.mark.integration
  53. async def test_a_failed_key_lookup_is_a_logged_503(async_client, test_engine, caplog):
  54. key = await _key(async_client)
  55. with _failing(test_engine, "SELECT API_KEYS") as state, caplog.at_level(logging.ERROR):
  56. response = await async_client.get(PROTECTED, headers={"X-API-Key": key})
  57. assert state["failed"]
  58. assert response.status_code == 503, response.text
  59. assert response.json()["detail"] == "Authentication service temporarily unavailable"
  60. assert "database is locked" in caplog.text
  61. @pytest.mark.asyncio
  62. @pytest.mark.integration
  63. async def test_a_failed_last_used_write_is_a_503_not_an_invalid_key(async_client, test_engine):
  64. key = await _key(async_client)
  65. with _failing(test_engine, "UPDATE API_KEYS") as state:
  66. response = await async_client.get(PROTECTED, headers={"X-API-Key": key})
  67. assert state["failed"]
  68. assert response.status_code == 503, response.text
  69. @pytest.mark.asyncio
  70. @pytest.mark.integration
  71. async def test_a_wrong_key_is_still_a_401(async_client):
  72. key = await _key(async_client)
  73. response = await async_client.get(PROTECTED, headers={"X-API-Key": key[:-4] + "XXXX"})
  74. # The message depends on the route's dependency ("Invalid API key" or
  75. # "Authentication required"); the refusal is what matters.
  76. assert response.status_code == 401
  77. @pytest.mark.asyncio
  78. @pytest.mark.integration
  79. async def test_an_expired_key_is_still_a_401(async_client, db_session):
  80. key = await _key(async_client)
  81. await db_session.execute(
  82. update(APIKey)
  83. .values(expires_at=datetime.now(timezone.utc) - timedelta(days=1))
  84. .where(APIKey.name == "integration")
  85. )
  86. await db_session.commit()
  87. response = await async_client.get(PROTECTED, headers={"X-API-Key": key})
  88. assert response.status_code == 401
  89. @pytest.mark.asyncio
  90. @pytest.mark.integration
  91. async def test_a_key_row_with_an_unreadable_hash_is_no_match_and_is_reported(async_client, db_session, caplog):
  92. key = await _key(async_client)
  93. await db_session.execute(update(APIKey).values(key_hash="not-a-hash").where(APIKey.name == "integration"))
  94. await db_session.commit()
  95. with caplog.at_level(logging.WARNING):
  96. response = await async_client.get(PROTECTED, headers={"X-API-Key": key})
  97. assert response.status_code == 401
  98. assert "unreadable hash" in caplog.text
  99. @pytest.mark.asyncio
  100. @pytest.mark.integration
  101. async def test_an_oversized_key_is_refused_without_blaming_the_stored_hash(async_client, caplog):
  102. """passlib refuses secrets over 4096 characters; that is the client's input,
  103. not a broken row, and must not be logged as one."""
  104. key = await _key(async_client)
  105. with caplog.at_level(logging.WARNING):
  106. response = await async_client.get(PROTECTED, headers={"X-API-Key": key[:8] + "a" * 5000})
  107. assert response.status_code == 401
  108. assert "unreadable hash" not in caplog.text