diff --git a/backend/app/services/gmail_service.py b/backend/app/services/gmail_service.py index 3fde4c7..e0726d5 100644 --- a/backend/app/services/gmail_service.py +++ b/backend/app/services/gmail_service.py @@ -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 diff --git a/backend/tests/unit/test_gmail_service.py b/backend/tests/unit/test_gmail_service.py index 31a582b..8a1160d 100644 --- a/backend/tests/unit/test_gmail_service.py +++ b/backend/tests/unit/test_gmail_service.py @@ -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