Merge pull request #225 from christianlouis/copilot/fix-imap-connection-timeout

fix: normalise naive UTC expiry from google-auth before DB storage
This commit is contained in:
Christian Krakau-Louis
2026-05-03 23:47:43 +02:00
committed by GitHub
2 changed files with 85 additions and 2 deletions
+16 -2
View File
@@ -434,6 +434,20 @@ class GmailService:
return label_ids
@staticmethod
def _tz_aware_expiry(expiry: Optional[datetime]) -> Optional[datetime]:
"""Return *expiry* with UTC tzinfo attached if it is naive.
google-auth sets ``credentials.expiry`` as a naive UTC datetime
(``datetime.utcnow() + timedelta(...)``). Storing a naive datetime
into a ``DateTime(timezone=True)`` column causes silent tz-mismatch
bugs in comparisons and storage, so we always normalise before
returning expiry values to callers.
"""
if expiry is not None and expiry.tzinfo is None:
return expiry.replace(tzinfo=timezone.utc)
return expiry
def is_token_expiring_soon(self, within_minutes: int = 30) -> bool:
"""
Return True if the access token has already expired or will expire
@@ -495,7 +509,7 @@ class GmailService:
)
return {
"access_token": self.credentials.token,
"expiry": self.credentials.expiry,
"expiry": self._tz_aware_expiry(self.credentials.expiry),
}
except google.auth.exceptions.RefreshError as e:
error_msg = f"Gmail refresh token has been revoked or is invalid — the user must re-authorise. Detail: {e}"
@@ -529,6 +543,6 @@ class GmailService:
)
return {
"access_token": current_token,
"expiry": self.credentials.expiry,
"expiry": self._tz_aware_expiry(self.credentials.expiry),
}
return None
+69
View File
@@ -419,3 +419,72 @@ class TestGmailService:
# Second access should NOT call build again
_ = service.service
mock_build.assert_called_once()
# ------------------------------------------------------------------
# _tz_aware_expiry timezone normalisation helper
# ------------------------------------------------------------------
def test_tz_aware_expiry_naive_becomes_utc(self):
"""A naive datetime is made tz-aware (UTC)."""
naive = datetime(2026, 6, 1, 12, 0, 0) # no tzinfo
result = GmailService._tz_aware_expiry(naive)
assert result is not None
assert result.tzinfo is not None
assert result.utcoffset().total_seconds() == 0
assert result.replace(tzinfo=None) == naive
def test_tz_aware_expiry_aware_unchanged(self):
"""An already tz-aware datetime is returned as-is."""
aware = datetime(2026, 6, 1, 12, 0, 0, tzinfo=timezone.utc)
result = GmailService._tz_aware_expiry(aware)
assert result is aware
def test_tz_aware_expiry_none_unchanged(self):
"""None is returned unchanged."""
assert GmailService._tz_aware_expiry(None) is None
# ------------------------------------------------------------------
# get_refreshed_token naive expiry is normalised to UTC
# ------------------------------------------------------------------
def test_get_refreshed_token_naive_expiry_becomes_utc(self):
"""get_refreshed_token converts a naive expiry from google-auth to UTC-aware."""
service = GmailService(access_token="original-token")
# google-auth sets credentials.expiry as naive UTC
naive_expiry = datetime(2099, 1, 1, 0, 0, 0) # no tzinfo
service.credentials.token = "new-refreshed-token"
service.credentials.expiry = naive_expiry
result = service.get_refreshed_token()
assert result is not None
expiry = result["expiry"]
assert expiry is not None
assert expiry.tzinfo is not None, "expiry must be tz-aware for DB storage"
assert expiry.utcoffset().total_seconds() == 0
assert expiry.replace(tzinfo=None) == naive_expiry
# ------------------------------------------------------------------
# proactive_refresh naive expiry is normalised to UTC
# ------------------------------------------------------------------
@pytest.mark.asyncio
async def test_proactive_refresh_naive_expiry_becomes_utc(self):
"""proactive_refresh converts a naive expiry from google-auth to UTC-aware."""
service = GmailService(
access_token="old-token",
refresh_token="refresh-token",
)
naive_expiry = datetime(2099, 6, 1, 0, 0, 0) # no tzinfo
def _fake_refresh(_request):
service.credentials.token = "new-token"
service.credentials.expiry = naive_expiry
with patch.object(service.credentials, "refresh", side_effect=_fake_refresh):
result = await service.proactive_refresh()
assert result["access_token"] == "new-token"
expiry = result["expiry"]
assert expiry is not None
assert expiry.tzinfo is not None, "expiry must be tz-aware for DB storage"
assert expiry.utcoffset().total_seconds() == 0
assert expiry.replace(tzinfo=None) == naive_expiry