diff --git a/keep/functions/__init__.py b/keep/functions/__init__.py index 8b3aa1e617..67b63b1aa1 100644 --- a/keep/functions/__init__.py +++ b/keep/functions/__init__.py @@ -212,6 +212,21 @@ def timestamp_delta( return dt + delta +def _assume_utc(dt: datetime.datetime) -> datetime.datetime: + """Attach UTC to a datetime that carries no offset. + + A timestamp written without an offset, such as ``2024-01-01T00:00:00``, + is the documented input to to_utc and to_timestamp, and it is what most + alert payloads carry. astimezone reads such a value in the host's local + timezone, so the same alert converted on a UTC server and on a server in + Asia/Kolkata comes out 5.5 hours apart. UTC is the only reading that does + not depend on where Keep happens to run. + """ + if dt.tzinfo is None or dt.tzinfo.utcoffset(dt) is None: + return dt.replace(tzinfo=datetime.timezone.utc) + return dt + + def to_utc(dt: datetime.datetime | str = "") -> datetime.datetime: if isinstance(dt, str): try: @@ -219,7 +234,7 @@ def to_utc(dt: datetime.datetime | str = "") -> datetime.datetime: except ParserError: # Failed to parse the date return "" - utc_dt = dt.astimezone(pytz.utc) + utc_dt = _assume_utc(dt).astimezone(pytz.utc) return utc_dt @@ -241,7 +256,7 @@ def to_timestamp(dt: datetime.datetime | str = "") -> int: except ParserError: # Failed to parse the date return 0 - return int(dt.timestamp()) + return int(_assume_utc(dt).timestamp()) def datetime_compare(t1: datetime = None, t2: datetime = None) -> float: @@ -558,6 +573,8 @@ def is_business_hours( if not dt: # Handle case where parsing failed return False + dt = _assume_utc(dt) + # Convert to specified timezone dt = dt.astimezone(tz) diff --git a/tests/test_functions.py b/tests/test_functions.py index c5866fcfba..9592b468d2 100644 --- a/tests/test_functions.py +++ b/tests/test_functions.py @@ -1,5 +1,6 @@ import datetime import json +import time from datetime import timedelta import pytest @@ -1262,3 +1263,61 @@ def test_dict_merge(): result = functions.dict_merge(d1, "invalid", {"e": 7}) assert result == {"a": 1, "b": 2, "e": 7} + + +@pytest.fixture +def host_timezone(request, monkeypatch): + """Run the test as if Keep were deployed in the given timezone. + + time.tzset reads TZ into process-global state, so it has to be called + again on the way out or the setting leaks into every test that follows. + """ + monkeypatch.setenv("TZ", request.param) + time.tzset() + yield request.param + monkeypatch.undo() + time.tzset() + + +@pytest.mark.parametrize( + "host_timezone", ["UTC", "America/New_York", "Asia/Kolkata"], indirect=True +) +def test_to_utc_reads_an_offsetless_string_as_utc(host_timezone): + """An offsetless timestamp must not be read in the host's timezone. + + This is the example the workflow function docs give for to_utc. Before the + fix astimezone applied the host offset, so the same string converted on a + UTC server and on a server in Asia/Kolkata came out 5.5 hours apart. + """ + assert functions.to_utc("2024-01-01T00:00:00") == datetime.datetime( + 2024, 1, 1, 0, 0, tzinfo=datetime.timezone.utc + ) + + +@pytest.mark.parametrize( + "host_timezone", ["UTC", "America/New_York", "Asia/Kolkata"], indirect=True +) +def test_to_timestamp_reads_an_offsetless_string_as_utc(host_timezone): + assert functions.to_timestamp("2024-01-01T00:00:00") == 1704067200 + + +@pytest.mark.parametrize( + "host_timezone", ["UTC", "America/New_York", "Asia/Kolkata"], indirect=True +) +def test_to_utc_keeps_an_explicit_offset(host_timezone): + """A string that states its offset is unaffected by either the host or the fix.""" + assert functions.to_utc("2024-01-01T02:00:00+02:00") == datetime.datetime( + 2024, 1, 1, 0, 0, tzinfo=datetime.timezone.utc + ) + + +@pytest.mark.parametrize( + "host_timezone", ["UTC", "America/New_York", "Asia/Kolkata"], indirect=True +) +def test_is_business_hours_reads_an_offsetless_string_as_utc(host_timezone): + """The routing decision must not depend on where Keep runs. + + 18:00 UTC is inside the default 08:00-20:00 window, and it is a Monday. + Read as New York local time the same string is 23:00 UTC, which is outside. + """ + assert functions.is_business_hours("2024-01-01T18:00:00") is True