| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150 |
- """A database error while checking an API key is not an invalid key.
- ``_validate_api_key`` caught every exception and returned None, which every
- caller answers with 401 "Invalid API key": a valid key was refused as wrong
- during a pool timeout, or when writing its last-used time hit "database is
- locked", and the web interface clears its sign-in on that message. Such an
- error now refuses the request with 503, as the auth checks in the middleware
- do, and is logged.
- """
- import logging
- from contextlib import contextmanager
- from datetime import datetime, timedelta, timezone
- import pytest
- from sqlalchemy import event, update
- from sqlalchemy.exc import OperationalError
- from backend.app.models.api_key import APIKey
- async def _admin_headers(async_client) -> dict[str, str]:
- await async_client.post(
- "/api/v1/auth/setup",
- json={"auth_enabled": True, "admin_username": "keyadmin", "admin_password": "AdminPass1!"},
- )
- login = await async_client.post("/api/v1/auth/login", json={"username": "keyadmin", "password": "AdminPass1!"})
- assert login.status_code == 200, login.text
- return {"Authorization": f"Bearer {login.json()['access_token']}"}
- async def _key(async_client) -> str:
- admin = await _admin_headers(async_client)
- created = await async_client.post(
- "/api/v1/api-keys/", headers=admin, json={"name": "integration", "can_read_status": True}
- )
- assert created.status_code in (200, 201), created.text
- return created.json()["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)
- PROTECTED = "/api/v1/printers/"
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_a_valid_key_works(async_client):
- key = await _key(async_client)
- response = await async_client.get(PROTECTED, headers={"X-API-Key": key})
- assert response.status_code == 200, response.text
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_a_failed_key_lookup_is_a_logged_503(async_client, test_engine, caplog):
- key = await _key(async_client)
- with _failing(test_engine, "SELECT API_KEYS") as state, caplog.at_level(logging.ERROR):
- response = await async_client.get(PROTECTED, headers={"X-API-Key": key})
- assert state["failed"]
- assert response.status_code == 503, response.text
- assert response.json()["detail"] == "Authentication service temporarily unavailable"
- assert "database is locked" in caplog.text
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_a_failed_last_used_write_is_a_503_not_an_invalid_key(async_client, test_engine):
- key = await _key(async_client)
- with _failing(test_engine, "UPDATE API_KEYS") as state:
- response = await async_client.get(PROTECTED, 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_wrong_key_is_still_a_401(async_client):
- key = await _key(async_client)
- response = await async_client.get(PROTECTED, headers={"X-API-Key": key[:-4] + "XXXX"})
- # The message depends on the route's dependency ("Invalid API key" or
- # "Authentication required"); the refusal is what matters.
- assert response.status_code == 401
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_an_expired_key_is_still_a_401(async_client, db_session):
- key = await _key(async_client)
- await db_session.execute(
- update(APIKey)
- .values(expires_at=datetime.now(timezone.utc) - timedelta(days=1))
- .where(APIKey.name == "integration")
- )
- await db_session.commit()
- response = await async_client.get(PROTECTED, headers={"X-API-Key": key})
- assert response.status_code == 401
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_a_key_row_with_an_unreadable_hash_is_no_match_and_is_reported(async_client, db_session, caplog):
- key = await _key(async_client)
- await db_session.execute(update(APIKey).values(key_hash="not-a-hash").where(APIKey.name == "integration"))
- await db_session.commit()
- with caplog.at_level(logging.WARNING):
- response = await async_client.get(PROTECTED, headers={"X-API-Key": key})
- assert response.status_code == 401
- assert "unreadable hash" in caplog.text
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_an_oversized_key_is_refused_without_blaming_the_stored_hash(async_client, caplog):
- """passlib refuses secrets over 4096 characters; that is the client's input,
- not a broken row, and must not be logged as one."""
- key = await _key(async_client)
- with caplog.at_level(logging.WARNING):
- response = await async_client.get(PROTECTED, headers={"X-API-Key": key[:8] + "a" * 5000})
- assert response.status_code == 401
- assert "unreadable hash" not in caplog.text
|