"""`resolve_auth_context` emits `auth.succeeded`/`auth.failed` (ADR-0011). This runs on every authenticated request, so every rejection reason needs a distinguishable log event -- previously none of them logged anything. """ from collections.abc import MutableMapping from typing import Any import pytest import structlog from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from src.application.auth.errors import InvalidApiKeyError, TenantInactiveError from src.application.auth.service import resolve_auth_context from tests.support.factories import create_api_key, create_tenant pytestmark = [ pytest.mark.integration, pytest.mark.postgres, pytest.mark.asyncio(loop_scope="session"), ] def _events_by_name( logs: list[MutableMapping[str, Any]], name: str ) -> list[MutableMapping[str, Any]]: return [entry for entry in logs if entry.get("event") == name] async def test_valid_key_emits_auth_succeeded( db_session: AsyncSession, db_sessionmaker: async_sessionmaker[AsyncSession] ) -> None: tenant = await create_tenant(db_session) _, full_key = await create_api_key(db_session, tenant=tenant, scopes=["files:write"]) await db_session.commit() with structlog.testing.capture_logs() as logs: auth = await resolve_auth_context(db_sessionmaker, full_key) succeeded = _events_by_name(logs, "auth.succeeded") assert len(succeeded) == 1 assert succeeded[0]["tenant_id"] == str(auth.tenant_id) assert succeeded[0]["api_key_id"] == str(auth.api_key_id) assert _events_by_name(logs, "auth.failed") == [] async def test_malformed_token_emits_auth_failed( db_sessionmaker: async_sessionmaker[AsyncSession], ) -> None: with structlog.testing.capture_logs() as logs, pytest.raises(InvalidApiKeyError): await resolve_auth_context(db_sessionmaker, "not-a-bearer-token-at-all") failed = _events_by_name(logs, "auth.failed") assert len(failed) == 1 assert failed[0]["reason"] == "malformed_key" async def test_wrong_secret_emits_auth_failed( db_session: AsyncSession, db_sessionmaker: async_sessionmaker[AsyncSession] ) -> None: tenant = await create_tenant(db_session) api_key, _ = await create_api_key(db_session, tenant=tenant) await db_session.commit() with structlog.testing.capture_logs() as logs, pytest.raises(InvalidApiKeyError): await resolve_auth_context(db_sessionmaker, f"sk_{api_key.key_prefix}_wrong-secret") failed = _events_by_name(logs, "auth.failed") assert len(failed) == 1 assert failed[0]["reason"] == "unknown_key" async def test_unknown_prefix_emits_auth_failed( db_sessionmaker: async_sessionmaker[AsyncSession], ) -> None: with structlog.testing.capture_logs() as logs, pytest.raises(InvalidApiKeyError): await resolve_auth_context(db_sessionmaker, "sk_doesnotexist_secret") failed = _events_by_name(logs, "auth.failed") assert len(failed) == 1 assert failed[0]["reason"] == "unknown_key" async def test_revoked_key_emits_auth_failed_with_key_status( db_session: AsyncSession, db_sessionmaker: async_sessionmaker[AsyncSession] ) -> None: tenant = await create_tenant(db_session) _, full_key = await create_api_key(db_session, tenant=tenant, status="revoked") await db_session.commit() with structlog.testing.capture_logs() as logs, pytest.raises(InvalidApiKeyError): await resolve_auth_context(db_sessionmaker, full_key) failed = _events_by_name(logs, "auth.failed") assert len(failed) == 1 assert failed[0]["reason"] == "key_inactive" assert failed[0]["key_status"] == "revoked" async def test_suspended_tenant_emits_auth_failed( db_session: AsyncSession, db_sessionmaker: async_sessionmaker[AsyncSession] ) -> None: tenant = await create_tenant(db_session, status="suspended") _, full_key = await create_api_key(db_session, tenant=tenant) await db_session.commit() with structlog.testing.capture_logs() as logs, pytest.raises(TenantInactiveError): await resolve_auth_context(db_sessionmaker, full_key) failed = _events_by_name(logs, "auth.failed") assert len(failed) == 1 assert failed[0]["reason"] == "tenant_inactive" assert failed[0]["tenant_id"] == str(tenant.id) async def test_auth_failed_never_logs_the_secret( db_session: AsyncSession, db_sessionmaker: async_sessionmaker[AsyncSession] ) -> None: """ADR-0011's redaction rule: never log plaintext API keys. `key_prefix` is the non-secret lookup portion (same distinction `ApiKey.key_prefix` makes); the secret itself must not appear in any field's value. """ tenant = await create_tenant(db_session) api_key, _ = await create_api_key(db_session, tenant=tenant) await db_session.commit() wrong_secret = "definitely-not-the-real-secret" with structlog.testing.capture_logs() as logs, pytest.raises(InvalidApiKeyError): await resolve_auth_context(db_sessionmaker, f"sk_{api_key.key_prefix}_{wrong_secret}") failed = _events_by_name(logs, "auth.failed") assert len(failed) == 1 assert wrong_secret not in str(failed[0])