fix: normalise naive UTC expiry from google-auth to tz-aware in GmailService
Agent-Logs-Url: https://github.com/christianlouis/InboxConverge/sessions/a94e2685-c88f-4109-a6eb-566c7343ab1f Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
parent
89ebdd2b79
commit
01a85ebede
@@ -434,6 +434,20 @@ class GmailService:
|
|||||||
|
|
||||||
return label_ids
|
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:
|
def is_token_expiring_soon(self, within_minutes: int = 30) -> bool:
|
||||||
"""
|
"""
|
||||||
Return True if the access token has already expired or will expire
|
Return True if the access token has already expired or will expire
|
||||||
@@ -495,7 +509,7 @@ class GmailService:
|
|||||||
)
|
)
|
||||||
return {
|
return {
|
||||||
"access_token": self.credentials.token,
|
"access_token": self.credentials.token,
|
||||||
"expiry": self.credentials.expiry,
|
"expiry": self._tz_aware_expiry(self.credentials.expiry),
|
||||||
}
|
}
|
||||||
except google.auth.exceptions.RefreshError as e:
|
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}"
|
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 {
|
return {
|
||||||
"access_token": current_token,
|
"access_token": current_token,
|
||||||
"expiry": self.credentials.expiry,
|
"expiry": self._tz_aware_expiry(self.credentials.expiry),
|
||||||
}
|
}
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -419,3 +419,72 @@ class TestGmailService:
|
|||||||
# Second access should NOT call build again
|
# Second access should NOT call build again
|
||||||
_ = service.service
|
_ = service.service
|
||||||
mock_build.assert_called_once()
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user