| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132 |
- """The webhook routes check an API key the way every other route does.
- They used ``get_api_key``, a copy of ``_validate_api_key`` that missed its
- fixes: a database error, a key too long to hash and an unreadable stored hash
- all came back as a 500, and a failed last-used write left the session dirty.
- ``get_api_key`` now reads the headers and leaves the check to
- ``_validate_api_key``, so these answer as they do everywhere: 503 when the key
- can't be checked, 401 when it is not a valid key.
- """
- import logging
- from contextlib import contextmanager
- from datetime import datetime, timedelta, timezone
- import pytest
- from httpx import AsyncClient
- from sqlalchemy import event, update
- from sqlalchemy.exc import OperationalError
- from backend.app.models.api_key import APIKey
- # A webhook route that answers 404 for a printer that doesn't exist, so a
- # request that gets past the key check is easy to tell from one that doesn't.
- ROUTE = "/api/v1/webhook/printer/99999/status"
- @pytest.fixture
- async def key(db_session) -> str:
- from backend.app.core.auth import generate_api_key
- full_key, key_hash, key_prefix = generate_api_key()
- db_session.add(
- APIKey(name="webhook-key", key_hash=key_hash, key_prefix=key_prefix, can_read_status=True, enabled=True)
- )
- await db_session.commit()
- return full_key
- @contextmanager
- def _failing(test_engine, statement_start: str):
- """Make the next statement starting with *statement_start* fail as SQLite does when locked."""
- state = {"failed": False}
- def hook(conn, cursor, statement, parameters, context, executemany):
- if not state["failed"] and statement.lstrip().upper().startswith(statement_start):
- state["failed"] = True
- raise OperationalError(statement, parameters, Exception("database is locked"))
- event.listen(test_engine.sync_engine, "before_cursor_execute", hook)
- try:
- yield state
- finally:
- event.remove(test_engine.sync_engine, "before_cursor_execute", hook)
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_a_valid_key_gets_through(async_client: AsyncClient, key):
- response = await async_client.get(ROUTE, headers={"X-API-Key": key})
- assert response.status_code == 404, response.text
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_a_failed_key_lookup_is_a_503(async_client: AsyncClient, key, test_engine):
- with _failing(test_engine, "SELECT API_KEYS") as state:
- response = await async_client.get(ROUTE, headers={"X-API-Key": key})
- assert state["failed"]
- assert response.status_code == 503, response.text
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_a_failed_last_used_write_is_a_503(async_client: AsyncClient, key, test_engine):
- with _failing(test_engine, "UPDATE API_KEYS") as state:
- response = await async_client.get(ROUTE, headers={"X-API-Key": key})
- assert state["failed"]
- assert response.status_code == 503, response.text
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_an_oversized_key_is_a_401(async_client: AsyncClient, key):
- """passlib refuses secrets over 4096 characters; that is a wrong key, not a server error."""
- response = await async_client.get(ROUTE, headers={"X-API-Key": key[:8] + "a" * 5000})
- assert response.status_code == 401, response.text
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_a_key_row_with_an_unreadable_hash_is_a_401_and_reported(
- async_client: AsyncClient, key, db_session, caplog
- ):
- await db_session.execute(update(APIKey).values(key_hash="not-a-hash").where(APIKey.name == "webhook-key"))
- await db_session.commit()
- with caplog.at_level(logging.WARNING):
- response = await async_client.get(ROUTE, headers={"X-API-Key": key})
- assert response.status_code == 401, response.text
- assert "unreadable hash" in caplog.text
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_an_expired_key_is_a_401(async_client: AsyncClient, key, db_session):
- await db_session.execute(
- update(APIKey)
- .values(expires_at=datetime.now(timezone.utc) - timedelta(days=1))
- .where(APIKey.name == "webhook-key")
- )
- await db_session.commit()
- response = await async_client.get(ROUTE, headers={"X-API-Key": key})
- assert response.status_code == 401, response.text
- assert response.json()["detail"] == "Invalid API key"
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_a_wrong_key_and_no_key_are_401s(async_client: AsyncClient, key):
- wrong = await async_client.get(ROUTE, headers={"X-API-Key": key[:-4] + "XXXX"})
- missing = await async_client.get(ROUTE)
- assert wrong.status_code == 401
- assert wrong.json()["detail"] == "Invalid API key"
- assert missing.status_code == 401
|