test_webhook_api_key_check.py 5.5 KB

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