From a5e00314ef80ed0f1c4dacbd8c3ea0a02cfd1d00 Mon Sep 17 00:00:00 2001 From: Hardik Kaurani Date: Fri, 25 Sep 2026 10:59:33 +0530 Subject: [PATCH 1/5] fix(alerts): handle dictionary payload for crashlytics digestDate --- src/firebase_functions/private/util.py | 9 +++++++-- tests/test_util.py | 8 ++++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/src/firebase_functions/private/util.py b/src/firebase_functions/private/util.py index d91d13e..409a70e 100644 --- a/src/firebase_functions/private/util.py +++ b/src/firebase_functions/private/util.py @@ -369,8 +369,13 @@ def normalize_timestamp_string(time: str) -> str: return f"{prefix}.{digits[:6]}{timezone}" -def timestamp_conversion(time: str) -> _dt.datetime: - """Converts an ISO 8601 timestamp and returns a timezone-aware datetime object.""" +def timestamp_conversion(time: str | dict) -> _dt.datetime: + """Converts an ISO 8601 timestamp or dictionary and returns a timezone-aware datetime object.""" + if isinstance(time, dict): + seconds = int(time.get("seconds", 0)) + nanos = int(time.get("nanos", time.get("nanoseconds", 0))) + return _dt.datetime.fromtimestamp(seconds + (nanos / 1e9), tz=_dt.timezone.utc) + normalized_time = normalize_timestamp_string(time) if "." not in normalized_time: return _dt.datetime.strptime(normalized_time, "%Y-%m-%dT%H:%M:%S%z") diff --git a/tests/test_util.py b/tests/test_util.py index 71ce614..d895a44 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -57,6 +57,14 @@ def test_timestamp_conversion_supported_formats(): Testing shared timestamp conversion handles supported RTDB and CloudEvent formats. """ timestamps = [ + ( + {"seconds": 1712750400, "nanos": 0}, + _dt.datetime(2024, 4, 10, 12, 0, tzinfo=_dt.timezone.utc), + ), + ( + {"seconds": 1712750400, "nanos": 123456000}, + _dt.datetime(2024, 4, 10, 12, 0, 0, 123456, tzinfo=_dt.timezone.utc), + ), ( "2024-04-10T12:00:00.000Z", _dt.datetime(2024, 4, 10, 12, 0, tzinfo=_dt.timezone.utc), From a858599e46916990fd6ea6b02a5600dce64f63ed Mon Sep 17 00:00:00 2001 From: Hardik Kaurani Date: Fri, 25 Sep 2026 11:02:20 +0530 Subject: [PATCH 2/5] address code review: use timedelta to prevent precision loss and safely handle None values --- src/firebase_functions/private/util.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/firebase_functions/private/util.py b/src/firebase_functions/private/util.py index 409a70e..d668df4 100644 --- a/src/firebase_functions/private/util.py +++ b/src/firebase_functions/private/util.py @@ -372,9 +372,16 @@ def normalize_timestamp_string(time: str) -> str: def timestamp_conversion(time: str | dict) -> _dt.datetime: """Converts an ISO 8601 timestamp or dictionary and returns a timezone-aware datetime object.""" if isinstance(time, dict): - seconds = int(time.get("seconds", 0)) - nanos = int(time.get("nanos", time.get("nanoseconds", 0))) - return _dt.datetime.fromtimestamp(seconds + (nanos / 1e9), tz=_dt.timezone.utc) + seconds = time.get("seconds") + seconds = int(seconds) if seconds is not None else 0 + + nanos = time.get("nanos") + if nanos is None: + nanos = time.get("nanoseconds") + nanos = int(nanos) if nanos is not None else 0 + + base_dt = _dt.datetime.fromtimestamp(seconds, tz=_dt.timezone.utc) + return base_dt + _dt.timedelta(microseconds=nanos // 1000) normalized_time = normalize_timestamp_string(time) if "." not in normalized_time: From 87082453d87701e253c41a3e1e180858b56e26ba Mon Sep 17 00:00:00 2001 From: Hardik Kaurani Date: Fri, 25 Sep 2026 12:15:13 +0530 Subject: [PATCH 3/5] Trigger CLA check From 59d2271ad59cdf6fb5955ff8a610290d236c07a0 Mon Sep 17 00:00:00 2001 From: Hardik K Date: Fri, 16 Oct 2026 21:43:54 +0530 Subject: [PATCH 4/5] fix(alerts): parse calendar-date digestDate and enforce explicit timestamp dict format - Parse Crashlytics Stability Digest calendar-date dict shape {year, month, day} in _alerts_fn._parse_digest_date into timezone-aware UTC datetime at midnight - Prevent timestamp_conversion in util.py from defaulting missing seconds to 0 / Unix epoch; raise explicit ValueError on unrecognized or incomplete timestamp dicts - Add comprehensive regression tests in tests/test_alerts_fn.py and tests/test_util.py --- src/firebase_functions/private/_alerts_fn.py | 34 +++- src/firebase_functions/private/util.py | 22 ++- tests/test_alerts_fn.py | 156 +++++++++++++++++++ tests/test_util.py | 22 +++ 4 files changed, 225 insertions(+), 9 deletions(-) create mode 100644 tests/test_alerts_fn.py diff --git a/src/firebase_functions/private/_alerts_fn.py b/src/firebase_functions/private/_alerts_fn.py index b7796ee..9aae047 100644 --- a/src/firebase_functions/private/_alerts_fn.py +++ b/src/firebase_functions/private/_alerts_fn.py @@ -14,13 +14,13 @@ """Internal utilities for Firebase Alert function types.""" # pylint: disable=protected-access,cyclic-import +import datetime as _dt import typing as _typing import cloudevents.http as _ce -from functions_framework import logging as _logging - import firebase_functions.private.util as _util from firebase_functions.alerts import FirebaseAlertData +from functions_framework import logging as _logging def plan_update_payload_from_ce_payload(payload: dict): @@ -129,11 +129,39 @@ def trending_issue_details_from_ce_payload(payload: dict): ) +def _parse_digest_date(digest_date: _typing.Any) -> _dt.datetime: + """Parses a Crashlytics digestDate into a timezone-aware UTC datetime.""" + if isinstance(digest_date, dict): + if "year" in digest_date and "month" in digest_date and "day" in digest_date: + year, month, day = ( + digest_date["year"], + digest_date["month"], + digest_date["day"], + ) + if year is None or month is None or day is None: + raise ValueError(f"Invalid calendar date fields cannot be None: {digest_date}") + try: + return _dt.datetime( + int(year), + int(month), + int(day), + tzinfo=_dt.timezone.utc, + ) + except (ValueError, TypeError) as err: + raise ValueError(f"Invalid calendar date values in {digest_date}: {err}") from err + if "seconds" in digest_date and digest_date["seconds"] is not None: + return _util.timestamp_conversion(digest_date) + raise ValueError(f"Unrecognized dictionary format for digestDate: {digest_date}") + if isinstance(digest_date, str): + return _util.timestamp_conversion(digest_date) + raise ValueError(f"Invalid digestDate type: {type(digest_date)}") + + def stability_digest_payload_from_ce_payload(payload: dict): from firebase_functions.alerts.crashlytics_fn import StabilityDigestPayload return StabilityDigestPayload( - digest_date=_util.timestamp_conversion(payload["digestDate"]), + digest_date=_parse_digest_date(payload["digestDate"]), trending_issues=[ trending_issue_details_from_ce_payload(issue) for issue in payload["trendingIssues"] ], diff --git a/src/firebase_functions/private/util.py b/src/firebase_functions/private/util.py index d668df4..fbdef13 100644 --- a/src/firebase_functions/private/util.py +++ b/src/firebase_functions/private/util.py @@ -373,14 +373,24 @@ def timestamp_conversion(time: str | dict) -> _dt.datetime: """Converts an ISO 8601 timestamp or dictionary and returns a timezone-aware datetime object.""" if isinstance(time, dict): seconds = time.get("seconds") - seconds = int(seconds) if seconds is not None else 0 + if seconds is None: + raise ValueError( + f"Unrecognized timestamp dictionary format (missing 'seconds'): {time}" + ) + try: + seconds_int = int(seconds) + except (ValueError, TypeError) as err: + raise ValueError(f"Invalid 'seconds' value in timestamp: {seconds}") from err - nanos = time.get("nanos") - if nanos is None: - nanos = time.get("nanoseconds") - nanos = int(nanos) if nanos is not None else 0 + nanos_val = time.get("nanos") + if nanos_val is None: + nanos_val = time.get("nanoseconds") + try: + nanos = int(nanos_val) if nanos_val is not None else 0 + except (ValueError, TypeError) as err: + raise ValueError(f"Invalid 'nanos' value in timestamp: {nanos_val}") from err - base_dt = _dt.datetime.fromtimestamp(seconds, tz=_dt.timezone.utc) + base_dt = _dt.datetime.fromtimestamp(seconds_int, tz=_dt.timezone.utc) return base_dt + _dt.timedelta(microseconds=nanos // 1000) normalized_time = normalize_timestamp_string(time) diff --git a/tests/test_alerts_fn.py b/tests/test_alerts_fn.py new file mode 100644 index 0000000..b3888d0 --- /dev/null +++ b/tests/test_alerts_fn.py @@ -0,0 +1,156 @@ +# Copyright 2024 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +Tests for internal alerts utility functions. +""" + +import datetime as _dt + +import pytest +from firebase_functions.private._alerts_fn import ( + _parse_digest_date, + stability_digest_payload_from_ce_payload, +) + + +def test_parse_digest_date_valid_calendar_date(): + """ + Testing that valid {year, month, day} calendar dates are parsed + into timezone-aware UTC datetime at midnight. + """ + date_dict = {"year": 2026, "month": 5, "day": 15} + expected = _dt.datetime(2026, 5, 15, 0, 0, tzinfo=_dt.timezone.utc) + assert _parse_digest_date(date_dict) == expected + + +def test_parse_digest_date_valid_timestamp_dict(): + """ + Testing that valid timestamp dictionaries are accepted. + """ + ts_dict = {"seconds": 1712750400, "nanos": 0} + expected = _dt.datetime(2024, 4, 10, 12, 0, tzinfo=_dt.timezone.utc) + assert _parse_digest_date(ts_dict) == expected + + ts_dict_nanos = {"seconds": 1712750400, "nanos": 123456000} + expected_nanos = _dt.datetime(2024, 4, 10, 12, 0, 0, 123456, tzinfo=_dt.timezone.utc) + assert _parse_digest_date(ts_dict_nanos) == expected_nanos + + +def test_parse_digest_date_valid_iso_string(): + """ + Testing that valid ISO 8601 string representations are supported. + """ + iso_str = "2026-05-15T00:00:00Z" + expected = _dt.datetime(2026, 5, 15, 0, 0, tzinfo=_dt.timezone.utc) + assert _parse_digest_date(iso_str) == expected + + +def test_parse_digest_date_missing_fields_raises(): + """ + Testing that missing calendar-date fields raise ValueError. + """ + incomplete_dates = [ + {"year": 2026, "month": 5}, + {"year": 2026, "day": 15}, + {"month": 5, "day": 15}, + ] + for d in incomplete_dates: + with pytest.raises(ValueError): + _parse_digest_date(d) + + +def test_parse_digest_date_explicit_none_raises(): + """ + Testing that explicit None calendar-date fields raise ValueError. + """ + none_dates = [ + {"year": None, "month": 5, "day": 15}, + {"year": 2026, "month": None, "day": 15}, + {"year": 2026, "month": 5, "day": None}, + ] + for d in none_dates: + with pytest.raises(ValueError): + _parse_digest_date(d) + + +def test_parse_digest_date_invalid_values_raises(): + """ + Testing that invalid month, day, or non-integer values raise ValueError. + """ + invalid_dates = [ + {"year": 2026, "month": 13, "day": 15}, + {"year": 2026, "month": 2, "day": 30}, + {"year": 2026, "month": 0, "day": 15}, + {"year": 2026, "month": 5, "day": 32}, + {"year": "invalid", "month": 5, "day": 15}, + ] + for d in invalid_dates: + with pytest.raises(ValueError): + _parse_digest_date(d) + + +def test_parse_digest_date_unrecognized_dict_raises(): + """ + Testing that empty or unknown dictionaries raise ValueError. + """ + bad_dicts = [ + {}, + {"foo": "bar"}, + {"digestDate": "2026-05-15"}, + ] + for d in bad_dicts: + with pytest.raises(ValueError): + _parse_digest_date(d) + + +def test_parse_digest_date_invalid_types_raises(): + """ + Testing that non-string and non-dict types raise ValueError. + """ + bad_types = [ + 12345, + 123.45, + ["2026", "05", "15"], + None, + ] + for val in bad_types: + with pytest.raises(ValueError): + _parse_digest_date(val) + + +def test_stability_digest_payload_from_ce_payload(): + """ + Testing stability_digest_payload_from_ce_payload parses full payload with calendar date. + """ + ce_payload = { + "digestDate": {"year": 2026, "month": 5, "day": 15}, + "trendingIssues": [ + { + "type": "crash", + "issue": { + "id": "issue-123", + "title": "NullPointerException", + "subtitle": "MainActivity.kt:42", + "appVersion": "1.0.0", + }, + "eventCount": 42, + "userCount": 10, + } + ], + } + payload = stability_digest_payload_from_ce_payload(ce_payload) + assert payload.digest_date == _dt.datetime(2026, 5, 15, 0, 0, tzinfo=_dt.timezone.utc) + assert len(payload.trending_issues) == 1 + assert payload.trending_issues[0].event_count == 42 + assert payload.trending_issues[0].issue.id == "issue-123" diff --git a/tests/test_util.py b/tests/test_util.py index d895a44..3a09359 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -18,6 +18,7 @@ import datetime as _dt from os import environ, path +import pytest from firebase_functions.private.util import ( _unsafe_decode_id_token, deep_merge, @@ -65,6 +66,10 @@ def test_timestamp_conversion_supported_formats(): {"seconds": 1712750400, "nanos": 123456000}, _dt.datetime(2024, 4, 10, 12, 0, 0, 123456, tzinfo=_dt.timezone.utc), ), + ( + {"seconds": 1712750400, "nanoseconds": 123456000}, + _dt.datetime(2024, 4, 10, 12, 0, 0, 123456, tzinfo=_dt.timezone.utc), + ), ( "2024-04-10T12:00:00.000Z", _dt.datetime(2024, 4, 10, 12, 0, tzinfo=_dt.timezone.utc), @@ -126,6 +131,23 @@ def test_timestamp_conversion_supported_formats(): assert timestamp_conversion(input_timestamp) == expected_datetime +def test_timestamp_conversion_unrecognized_dict_raises(): + """ + Testing that unrecognized or incomplete timestamp dictionaries raise ValueError. + """ + unrecognized_dicts = [ + {}, + {"foo": "bar"}, + {"nanos": 123456000}, + {"seconds": None}, + {"seconds": "invalid"}, + {"seconds": 1712750400, "nanos": "invalid"}, + ] + for d in unrecognized_dicts: + with pytest.raises(ValueError): + timestamp_conversion(d) + + def test_normalize_document_path(): """ Testing "document" path passed to Firestore event listener From 2f95add58cab0b81f4202a857a091d0e8fef823a Mon Sep 17 00:00:00 2001 From: Hardik Kaurani Date: Sun, 18 Oct 2026 09:03:34 +0530 Subject: [PATCH 5/5] style: sort and format imports with ruff (fix I001) --- src/firebase_functions/private/_alerts_fn.py | 3 ++- tests/test_alerts_fn.py | 1 + tests/test_util.py | 1 + 3 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/firebase_functions/private/_alerts_fn.py b/src/firebase_functions/private/_alerts_fn.py index 9aae047..2a9408a 100644 --- a/src/firebase_functions/private/_alerts_fn.py +++ b/src/firebase_functions/private/_alerts_fn.py @@ -18,9 +18,10 @@ import typing as _typing import cloudevents.http as _ce +from functions_framework import logging as _logging + import firebase_functions.private.util as _util from firebase_functions.alerts import FirebaseAlertData -from functions_framework import logging as _logging def plan_update_payload_from_ce_payload(payload: dict): diff --git a/tests/test_alerts_fn.py b/tests/test_alerts_fn.py index b3888d0..ac23c57 100644 --- a/tests/test_alerts_fn.py +++ b/tests/test_alerts_fn.py @@ -18,6 +18,7 @@ import datetime as _dt import pytest + from firebase_functions.private._alerts_fn import ( _parse_digest_date, stability_digest_payload_from_ce_payload, diff --git a/tests/test_util.py b/tests/test_util.py index 3a09359..2d25331 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -19,6 +19,7 @@ from os import environ, path import pytest + from firebase_functions.private.util import ( _unsafe_decode_id_token, deep_merge,