"""Service-token auth path — /api/v1/open/directory/me + the ``require_consumer`` dependency's failure modes. Pins: * valid token → 200 with slug + scopes echoed * garbage / PAT-prefixed / revoked / expired token → uniform 401 * disabled or soft-deleted consumer → 401 (kill switch works without touching individual tokens) * missing scope → 403 with the scope named (probing is audited) * success bookkeeping: token.last_used_at + daily usage row * auth failures write directory_api.auth_failure audit rows """ from __future__ import annotations import uuid from datetime import UTC, datetime, timedelta import pytest from fastapi import APIRouter, Depends from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.audit.models import AuditLog from app.identity.consumers.auth import ConsumerContext, require_consumer from app.identity.consumers.models import ( IdentityConsumer, IdentityConsumerUsageDaily, ) from app.identity.consumers.token_service import ConsumerTokenService from app.identity.person_models import IdentityPerson from app.main import app from tests.support.router_probe import mounted_router pytestmark = pytest.mark.asyncio ME = "/api/v1/open/directory/me" async def _consumer_with_token( db: AsyncSession, *, scopes: list[str] | None = None, status: str = "active", expires_in_days: int | None = None, ) -> tuple[uuid.UUID, str, str]: """Returns (consumer_id, slug, token_plaintext) — primitives, not ORM rows, so tests can expire_all freely without tripping MissingGreenlet on expired-instance attribute access.""" owner = IdentityPerson( id=uuid.uuid4(), work_email=f"owner-{uuid.uuid4().hex[:8]}@pingcap.com", display_name="Owner", employment_status="active", source_system="successfactors", hris_person_id=f"sf-{uuid.uuid4().hex[:10]}", ) db.add(owner) await db.flush() consumer = IdentityConsumer( id=uuid.uuid4(), name="Test System", slug=f"test-sys-{uuid.uuid4().hex[:6]}", owner_person_id=owner.id, status=status, scopes=scopes if scopes is not None else ["directory.persons:read"], filter_config={}, ) db.add(consumer) await db.flush() await db.refresh(consumer) consumer_id, slug = consumer.id, consumer.slug row, plaintext = await ConsumerTokenService(db).mint( consumer=consumer, name="t", expires_in_days=expires_in_days, created_by=None, ) await db.commit() return consumer_id, slug, plaintext async def test_me_with_valid_token(client, db_session): _cid, slug, token = await _consumer_with_token(db_session) r = await client.get(ME, headers={"Authorization": f"Bearer {token}"}) assert r.status_code == 200, r.text data = r.json()["data"] assert data["consumer"] == slug assert data["scopes"] == ["directory.persons:read"] async def test_uniform_401s(client, db_session): # no header r = await client.get(ME) assert r.status_code == 401 # PAT-prefixed bearer is rejected on this surface r = await client.get(ME, headers={"Authorization": "Bearer helix_pat_abc"}) assert r.status_code == 401 # garbage service token r = await client.get( ME, headers={"Authorization": "Bearer helix_svc_notarealtoken123"}, ) assert r.status_code == 401 async def test_revoked_token_401(client, db_session, admin_user): admin, _tok = admin_user admin_id = admin.id consumer_id, _slug, token = await _consumer_with_token(db_session) db_session.expire_all() row = await db_session.get(IdentityConsumer, consumer_id) token_id = row.tokens[0].id await ConsumerTokenService(db_session).revoke( consumer_id=consumer_id, token_id=token_id, actor_user_id=admin_id, ) await db_session.commit() r = await client.get(ME, headers={"Authorization": f"Bearer {token}"}) assert r.status_code == 401 async def test_expired_token_401(client, db_session): consumer_id, _slug, token = await _consumer_with_token(db_session, expires_in_days=30) db_session.expire_all() row = await db_session.get(IdentityConsumer, consumer_id) row.tokens[0].expires_at = datetime.now(UTC) - timedelta(days=1) await db_session.commit() r = await client.get(ME, headers={"Authorization": f"Bearer {token}"}) assert r.status_code == 401 async def test_disabled_consumer_kill_switch(client, db_session): _cid, _slug, token = await _consumer_with_token(db_session, status="disabled") r = await client.get(ME, headers={"Authorization": f"Bearer {token}"}) assert r.status_code == 401 async def test_soft_deleted_consumer_401(client, db_session): consumer_id, _slug, token = await _consumer_with_token(db_session) row = await db_session.get(IdentityConsumer, consumer_id) row.deleted_at = datetime.now(UTC) await db_session.commit() r = await client.get(ME, headers={"Authorization": f"Bearer {token}"}) assert r.status_code == 401 async def test_scope_denied_403(client, db_session): """A consumer without the required scope gets 403, not data.""" probe = APIRouter() # No ``request: Request`` parameter, and ``ConsumerContext`` is imported # at module level. This module is ``from __future__ import annotations``, # so every annotation here is a *string* resolved against the module # globals — a name imported inside this function stays an unresolved # forward ref, which FastAPI turns into a request-body field and which # then makes ``app.openapi()`` raise for as long as the probe is mounted. @probe.get("/api/v1/open/directory/_scope_probe") async def _scope_probe( ctx: ConsumerContext = Depends( require_consumer("directory.org:read"), ), ): return {"ok": True} with mounted_router(app, probe): consumer_id, _slug, token = await _consumer_with_token( db_session, scopes=["directory.persons:read"], ) r = await client.get( "/api/v1/open/directory/_scope_probe", headers={"Authorization": f"Bearer {token}"}, ) assert r.status_code == 403 assert "directory.org:read" in r.text # scope probing is audited with the consumer named — written in # an independent committed session, so end this session's # REPEATABLE READ snapshot first. await db_session.commit() rows = (await db_session.execute( select(AuditLog).where( AuditLog.action == "directory_api.scope_denied", AuditLog.resource_id == str(consumer_id), ) )).scalars().all() assert rows, "scope denial must be audited" async def test_success_bookkeeping(client, db_session): consumer_id, _slug, token = await _consumer_with_token(db_session) r = await client.get(ME, headers={"Authorization": f"Bearer {token}"}) assert r.status_code == 200 # bookkeeping commits in its own session — end this session's # REPEATABLE READ snapshot so the fresh rows are visible. await db_session.commit() db_session.expire_all() row = await db_session.get(IdentityConsumer, consumer_id) assert row.tokens[0].last_used_at is not None usage = (await db_session.execute( select(IdentityConsumerUsageDaily).where( IdentityConsumerUsageDaily.consumer_id == consumer_id, ) )).scalars().all() assert len(usage) == 1 assert usage[0].request_count >= 1 async def test_auth_failure_audited(client, db_session): r = await client.get( ME, headers={"Authorization": "Bearer helix_svc_doesnotexist42"}, ) assert r.status_code == 401 await db_session.commit() rows = (await db_session.execute( select(AuditLog).where( AuditLog.action == "directory_api.auth_failure", ) )).scalars().all() assert rows, "failed service-token auth must leave an audit trail"