test_webhook_api_key_check.py 5.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149
  1. """The webhook routes check an API key the way every other route does.
  2. They used ``get_api_key``, a copy of ``_validate_api_key`` that missed its
  3. fixes: a database error, a key too long to hash and an unreadable stored hash
  4. all came back as a 500, and a failed last-used write left the session dirty.
  5. ``get_api_key`` now reads the headers and leaves the check to
  6. ``_validate_api_key``, so these answer as they do everywhere: 503 when the key
  7. can't be checked, 401 when it is not a valid key.
  8. """
  9. import logging
  10. from contextlib import contextmanager
  11. from datetime import datetime, timedelta, timezone
  12. import pytest
  13. from httpx import AsyncClient
  14. from sqlalchemy import event, update
  15. from sqlalchemy.exc import OperationalError
  16. from backend.app.models.api_key import APIKey
  17. # A webhook route that answers 404 for a printer that doesn't exist, so a
  18. # request that gets past the key check is easy to tell from one that doesn't.
  19. ROUTE = "/api/v1/webhook/printer/99999/status"
  20. @pytest.fixture
  21. async def key(db_session) -> str:
  22. from backend.app.core.auth import generate_api_key
  23. full_key, key_hash, key_prefix = generate_api_key()
  24. db_session.add(
  25. APIKey(name="webhook-key", key_hash=key_hash, key_prefix=key_prefix, can_read_status=True, enabled=True)
  26. )
  27. await db_session.commit()
  28. return full_key
  29. @contextmanager
  30. def _failing(test_engine, statement_start: str):
  31. """Make the next statement starting with *statement_start* fail as SQLite does when locked."""
  32. state = {"failed": False}
  33. def hook(conn, cursor, statement, parameters, context, executemany):
  34. if not state["failed"] and statement.lstrip().upper().startswith(statement_start):
  35. state["failed"] = True
  36. raise OperationalError(statement, parameters, Exception("database is locked"))
  37. event.listen(test_engine.sync_engine, "before_cursor_execute", hook)
  38. try:
  39. yield state
  40. finally:
  41. event.remove(test_engine.sync_engine, "before_cursor_execute", hook)
  42. @pytest.mark.asyncio
  43. @pytest.mark.integration
  44. async def test_a_valid_key_gets_through(async_client: AsyncClient, key):
  45. response = await async_client.get(ROUTE, headers={"X-API-Key": key})
  46. assert response.status_code == 404, response.text
  47. @pytest.mark.asyncio
  48. @pytest.mark.integration
  49. async def test_a_failed_key_lookup_is_a_503(async_client: AsyncClient, key, test_engine):
  50. with _failing(test_engine, "SELECT API_KEYS") as state:
  51. response = await async_client.get(ROUTE, headers={"X-API-Key": key})
  52. assert state["failed"]
  53. assert response.status_code == 503, response.text
  54. @pytest.mark.asyncio
  55. @pytest.mark.integration
  56. async def test_a_failed_last_used_write_is_a_503(async_client: AsyncClient, key, test_engine):
  57. with _failing(test_engine, "UPDATE API_KEYS") as state:
  58. response = await async_client.get(ROUTE, headers={"X-API-Key": key})
  59. assert state["failed"]
  60. assert response.status_code == 503, response.text
  61. @pytest.mark.asyncio
  62. @pytest.mark.integration
  63. async def test_an_oversized_key_is_a_401(async_client: AsyncClient, key):
  64. """passlib refuses secrets over 4096 characters; that is a wrong key, not a server error."""
  65. response = await async_client.get(ROUTE, headers={"X-API-Key": key[:8] + "a" * 5000})
  66. assert response.status_code == 401, response.text
  67. @pytest.mark.asyncio
  68. @pytest.mark.integration
  69. async def test_a_key_row_with_an_unreadable_hash_is_a_401_and_reported(
  70. async_client: AsyncClient, key, db_session, caplog
  71. ):
  72. await db_session.execute(update(APIKey).values(key_hash="not-a-hash").where(APIKey.name == "webhook-key"))
  73. await db_session.commit()
  74. with caplog.at_level(logging.WARNING):
  75. response = await async_client.get(ROUTE, headers={"X-API-Key": key})
  76. assert response.status_code == 401, response.text
  77. assert "unreadable hash" in caplog.text
  78. @pytest.mark.asyncio
  79. @pytest.mark.integration
  80. async def test_an_expired_key_is_a_401(async_client: AsyncClient, key, db_session):
  81. await db_session.execute(
  82. update(APIKey)
  83. .values(expires_at=datetime.now(timezone.utc) - timedelta(days=1))
  84. .where(APIKey.name == "webhook-key")
  85. )
  86. await db_session.commit()
  87. response = await async_client.get(ROUTE, headers={"X-API-Key": key})
  88. assert response.status_code == 401, response.text
  89. assert response.json()["detail"] == "Invalid API key"
  90. @pytest.mark.asyncio
  91. @pytest.mark.integration
  92. async def test_a_wrong_key_and_no_key_are_401s(async_client: AsyncClient, key):
  93. wrong = await async_client.get(ROUTE, headers={"X-API-Key": key[:-4] + "XXXX"})
  94. missing = await async_client.get(ROUTE)
  95. assert wrong.status_code == 401
  96. assert wrong.json()["detail"] == "Invalid API key"
  97. assert missing.status_code == 401
  98. @pytest.mark.asyncio
  99. @pytest.mark.integration
  100. @pytest.mark.parametrize("route", [ROUTE, "/api/v1/printers/"])
  101. @pytest.mark.parametrize("header", ["X-API-Key", "Authorization"])
  102. async def test_a_disabled_key_is_a_401(async_client: AsyncClient, key, db_session, route, header):
  103. from backend.app.models.settings import Settings
  104. db_session.add(Settings(key="auth_enabled", value="true"))
  105. await db_session.execute(update(APIKey).values(enabled=False).where(APIKey.name == "webhook-key"))
  106. await db_session.commit()
  107. value = key if header == "X-API-Key" else f"Bearer {key}"
  108. response = await async_client.get(route, headers={header: value})
  109. assert response.status_code == 401, response.text