diff --git a/changedetectionio/blueprint/rss/_util.py b/changedetectionio/blueprint/rss/_util.py index c96f8b41..26f72716 100644 --- a/changedetectionio/blueprint/rss/_util.py +++ b/changedetectionio/blueprint/rss/_util.py @@ -150,7 +150,8 @@ def populate_feed_entry(fe, watch, content, guid, timestamp, link=None, title_su fe.guid(guid, permalink=False) # Set pubDate using the timestamp of this specific change - dt = datetime.datetime.fromtimestamp(int(timestamp)) - dt = dt.replace(tzinfo=pytz.UTC) + # Note: tz= must be passed to fromtimestamp(), otherwise we get a naive datetime in the + # container's local timezone and relabelling it as UTC shifts every pubDate by the local offset + dt = datetime.datetime.fromtimestamp(int(timestamp), tz=pytz.UTC) fe.pubDate(dt) diff --git a/changedetectionio/run_basic_tests.sh b/changedetectionio/run_basic_tests.sh index 784b2a5b..f69ef75d 100755 --- a/changedetectionio/run_basic_tests.sh +++ b/changedetectionio/run_basic_tests.sh @@ -91,6 +91,10 @@ export HIDE_REFERER=True REMOVE_REQUESTS_OLD_SCREENSHOTS=false pytest -vv -s --maxfail=1 tests/test_notification.py tests/test_access_control.py +# Re #4309 - RSS pubDate/timestamps must be correct on containers that don't run UTC +# (Europe/Athens is UTC+2/+3, so any naive local->UTC relabelling shows up as a shifted date) +TZ=Europe/Athens pytest -vv -s --maxfail=1 tests/test_rss.py + # Re-run a few tests that will trigger brotli based storage # And again with brotli+screenshot attachment SNAPSHOT_BROTLI_COMPRESSION_THRESHOLD=5 REMOVE_REQUESTS_OLD_SCREENSHOTS=false pytest -vv -s --maxfail=1 --dist=load tests/test_backend.py tests/test_rss.py tests/test_unique_lines.py tests/test_notification.py tests/test_access_control.py diff --git a/changedetectionio/tests/test_rss.py b/changedetectionio/tests/test_rss.py index 5fb74b0b..25a397f4 100644 --- a/changedetectionio/tests/test_rss.py +++ b/changedetectionio/tests/test_rss.py @@ -1,6 +1,8 @@ #!/usr/bin/env python3 +import email.utils import os import time +import pytest from flask import url_for from .util import set_original_response, set_modified_response, live_server_setup, wait_for_all_checks, extract_rss_token_from_UI, \ extract_UUID_from_client, delete_all_watches @@ -354,3 +356,54 @@ def test_rss_single_watch_feed(client, live_server, measure_memory_usage, datast assert (">3<" in descriptions[2] or "Version 3" in descriptions[2]) and "content" in descriptions[2], \ f"Third item should show Version 3, but got: {descriptions[2][:500]}" + # #4309 - pubDate must be the real UTC time of the change, whatever the server's local timezone is. + # The GUID carries the snapshot timestamp, so the two must agree to the second. + for item in items: + guid_timestamp = int(item.findtext('guid').rsplit('/', 1)[1]) + pub_date = email.utils.parsedate_to_datetime(item.findtext('pubDate')) + assert pub_date.timestamp() == guid_timestamp, \ + f"pubDate {item.findtext('pubDate')} does not match snapshot timestamp {guid_timestamp}" + + +@pytest.mark.skipif(not hasattr(time, 'tzset'), reason="Changing TZ at runtime needs a POSIX platform") +@pytest.mark.parametrize("tz_name", ['UTC', 'Europe/Athens', 'America/New_York', 'Australia/Sydney']) +def test_rss_pubdate_is_utc_regardless_of_local_timezone(tz_name): + """#4309 - datetime.fromtimestamp() without tz= returns local wall-clock time, and relabelling + that as UTC shifts every pubDate by the local offset (items appear in the future on TZ=Europe/Athens). + + No live server needed - this drives populate_feed_entry() directly under several timezones.""" + import xml.etree.ElementTree as ET + from feedgen.feed import FeedGenerator + from ..blueprint.rss._util import populate_feed_entry + + timestamp = 1700000000 # Tue, 14 Nov 2023 22:13:20 UTC + + original_tz = os.environ.get('TZ') + try: + os.environ['TZ'] = tz_name + time.tzset() + + fg = FeedGenerator() + fg.title('test') + fg.link(href='https://example.com', rel='self') + fg.description('test') + fe = fg.add_entry() + + populate_feed_entry(fe=fe, + watch={'uuid': 'fake-uuid', 'url': 'https://example.com'}, + content='some content', + guid=f'fake-uuid/{timestamp}', + timestamp=timestamp) + + pub_date = ET.fromstring(fg.rss_str()).findtext('.//item/pubDate') + finally: + if original_tz is None: + os.environ.pop('TZ', None) + else: + os.environ['TZ'] = original_tz + time.tzset() + + # parsedate_to_datetime() honours the offset in the header, so this compares real instants + assert email.utils.parsedate_to_datetime(pub_date).timestamp() == timestamp, \ + f"With TZ={tz_name} the feed said {pub_date}, expected the instant {timestamp}" +