"""Regression: ``_serialize_grants`` must not crash on an offset-AWARE ``expires_at``. Prod incident 2026-06-03 (trace fe188c39…): Alex Quan approved a cloud sub-account access request via ``POST /cmdb/grants/{id}/approve``. The grant was persisted correctly (commit succeeded), but the response 500'd with:: TypeError: can't subtract offset-naive and offset-aware datetimes Root cause: the approve / renew dispatch assigns ``datetime.now(UTC) + timedelta(...)`` (offset-aware) to ``grant.expires_at``. The session is configured ``expire_on_commit= False``, so the router's post-commit ``db.get`` returns the SAME in-memory object — still tz-aware — and ``_serialize_grants`` subtracts a naive ``now`` from it (``(r.expires_at - now).days``). On a later list fetch the row is reloaded from MySQL/TiDB (which strips tz) so it never reproduced from a fresh page load — only on the approve/renew response itself, which made it look intermittent / "time related". This test pins the serializer's tz-agnostic arithmetic directly: a grant whose ``expires_at`` is offset-aware must serialize without raising and yield a correct ``days_until``. """ from __future__ import annotations import uuid from datetime import UTC, datetime, timedelta import pytest from sqlalchemy.ext.asyncio import AsyncSession from app.domains.cmdb.lifecycle_router import _serialize_grants from app.domains.cmdb.models import ( GRANT_STATUS_ACTIVE, SCOPE_STATUS_ACTIVE, CmdbCloudAccessGrant, CmdbCloudScope, ) from app.integrations.models import Integration async def _integration(db: AsyncSession) -> Integration: row = Integration( id=uuid.uuid4(), kind="cloud", provider="aws", name=f"aws-test-{uuid.uuid4().hex[:6]}", slug=f"aws-test-{uuid.uuid4().hex[:6]}", enabled=True, is_primary=False, capabilities=["servers"], ) db.add(row) await db.flush() return row async def _scope(db: AsyncSession, integration: Integration) -> CmdbCloudScope: s = CmdbCloudScope( id=uuid.uuid4(), integration_id=str(integration.id), scope_type="aws_account", external_id=uuid.uuid4().hex[:12], display_name=f"scope-{uuid.uuid4().hex[:6]}", owner_person_id=None, status=SCOPE_STATUS_ACTIVE, first_seen_at=datetime.now(UTC), last_synced_at=datetime.now(UTC), environment="dev", ) db.add(s) await db.flush() return s @pytest.mark.asyncio async def test_serialize_grants_handles_offset_aware_expires_at( db_session: AsyncSession, ): integ = await _integration(db_session) scope = await _scope(db_session, integ) # Mirror exactly what the approve-dispatch path leaves in the # session before ``expire_on_commit=False`` hands it back to the # serializer: an offset-AWARE expiry 7 days out. aware_expires = datetime.now(UTC) + timedelta(days=7) assert aware_expires.tzinfo is not None grant = CmdbCloudAccessGrant( id=uuid.uuid4(), scope_id=str(scope.id), person_id=None, # orphan path — no IdentityPerson lookup needed external_principal_email="someone@test.com", role_key="arn:aws:sso:::permissionSet/test/abc12345", role_display_name="ReadOnlyAccess", status=GRANT_STATUS_ACTIVE, granted_at=datetime.now(UTC), expires_at=aware_expires, detail={"reason": "test"}, ) db_session.add(grant) await db_session.flush() # Must NOT raise "can't subtract offset-naive and offset-aware # datetimes" — the bug this regresses. [data] = await _serialize_grants(db_session, [grant], viewer=None) # 7 days out → days_until is 6 (now is a few ms past the anchor) or # 7; pin the band rather than the exact value to avoid clock flake. assert data["days_until_expiry"] in (6, 7) @pytest.mark.asyncio async def test_serialize_grants_handles_naive_expires_at( db_session: AsyncSession, ): """The reloaded-from-DB representation (naive) must keep working too — the fix must not regress the common path.""" integ = await _integration(db_session) scope = await _scope(db_session, integ) naive_expires = datetime.now(UTC).replace(tzinfo=None) + timedelta(days=7) grant = CmdbCloudAccessGrant( id=uuid.uuid4(), scope_id=str(scope.id), person_id=None, external_principal_email="someone@test.com", role_key="arn:aws:sso:::permissionSet/test/def67890", role_display_name="ReadOnlyAccess", status=GRANT_STATUS_ACTIVE, granted_at=datetime.now(UTC).replace(tzinfo=None), expires_at=naive_expires, detail={"reason": "test"}, ) db_session.add(grant) await db_session.flush() [data] = await _serialize_grants(db_session, [grant], viewer=None) assert data["days_until_expiry"] in (6, 7)