From 5fb48e1e90afdff509a83cc900889df5e5031d93 Mon Sep 17 00:00:00 2001 From: Klaas van Schelven Date: Fri, 1 Aug 2025 10:01:41 +0200 Subject: [PATCH] sent_at validation: support 00+00 Fix #179 --- ingest/header_validators.py | 20 +++++++++++++++----- ingest/tests.py | 19 +++++++++++++++++++ 2 files changed, 34 insertions(+), 5 deletions(-) diff --git a/ingest/header_validators.py b/ingest/header_validators.py index a8f95b8..7add3ed 100644 --- a/ingest/header_validators.py +++ b/ingest/header_validators.py @@ -16,7 +16,7 @@ from .exceptions import ParseError # * item headers -> only those that are relevant for "event" items -_RFC3339_Z = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$") +_RFC3339_Z = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?(Z|\+00:00)$") _UUID32 = re.compile(r"^[0-9a-fA-F]{32}$") _UUID36 = re.compile(r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$") @@ -35,12 +35,22 @@ def validate_sdk(v): def validate_sent_at(v): if not isinstance(v, str) or not _RFC3339_Z.match(v): - raise ParseError(f'Envelope header "sent_at" must be an RFC3339 UTC timestamp ending in Z: {v}') + raise ParseError(f'Envelope header "sent_at" must be an RFC3339 UTC timestamp: {v}') try: - datetime.strptime(v, "%Y-%m-%dT%H:%M:%SZ") - except ValueError: - datetime.fromisoformat(v.replace("Z", "+00:00")) + # Convert Z to +00:00 for isoformat compatibility + if v.endswith("Z"): + v = v[:-1] + "+00:00" + + # Truncate fractional seconds to 6 digits (Python's datetime.fromisoformat supports up to 6 digits) + v = re.sub( + r"(\.\d{1,6})\d*(\+00:00)$", # keep only first 6 digits before +00:00 + r"\1\2", + v + ) + return datetime.fromisoformat(v) + except ValueError as e: + raise ParseError(f'Envelope header "sent_at" is not a valid RFC3339 timestamp: {e}') from e def validate_event_id(v): diff --git a/ingest/tests.py b/ingest/tests.py index eeb3f56..6f66613 100644 --- a/ingest/tests.py +++ b/ingest/tests.py @@ -32,6 +32,7 @@ from bsmain.management.commands.send_json import Command as SendJsonCommand from .views import BaseIngestAPIView from .parsers import readuntil, NewlineFinder, ParseError, LengthFinder, StreamingEnvelopeParser from .event_counter import check_for_thresholds +from .header_validators import validate_envelope_headers from bugsink.exceptions import ViolatedExpectation @@ -899,3 +900,21 @@ class TestParser(RegularTestCase): envelope_headers) # the rest of the test is not repeated here + + +class HeaderValidationTest(RegularTestCase): + # incomplete: regression-based-first (we'll add more later) + + def test_sent_at_trailing_zeros(self): + # regression test for #179 + validate_envelope_headers({"sent_at": "2025-07-31T23:05:37.0926585+00:00"}) + validate_envelope_headers({"sent_at": "2025-07-31T23:05:37.0926585Z"}) + + with self.assertRaises(ParseError): + validate_envelope_headers({"sent_at": "garbage"}) + + with self.assertRaises(ParseError): + validate_envelope_headers({"sent_at": "2025-07-31T23:05:37.0926585032123+00:00"}) + + with self.assertRaises(ParseError): + validate_envelope_headers({"sent_at": "2025-07-31T23:05:37.0926+12:00"})