"""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