diff --git a/cmd/ingestor/rxtime_test.go b/cmd/ingestor/rxtime_test.go index 58bf6860..2a29baa7 100644 --- a/cmd/ingestor/rxtime_test.go +++ b/cmd/ingestor/rxtime_test.go @@ -78,3 +78,75 @@ func TestResolveRxTime(t *testing.T) { t.Errorf("recent timestamp <30d: got %q want %q", got, recent) } } + +// Regression: issue #1463 — naive (zone-less) ISO timestamps from observers +// in negative-UTC-offset zones (e.g. California PDT, UTC−7) were interpreted +// as UTC, producing rxTime values 7h in the past that poisoned `last_seen` +// and rendered the observer perpetually "Stale" in the UI. The symmetric +// clamp now collapses any naive timestamp more than 15 min off server-now to +// `now()`, while zone-aware timestamps (RFC3339 with Z or offset) are still +// honored verbatim regardless of skew (those are well-behaved observers). +func TestResolveRxTimeNaiveTimestampClamp(t *testing.T) { + now := time.Now().UTC() + + mustParse := func(s string) time.Time { + t.Helper() + parsed, err := time.Parse(time.RFC3339, s) + if err != nil { + t.Fatalf("result %q is not RFC3339: %v", s, err) + } + return parsed + } + nearNow := func(s string) bool { + d := mustParse(s).Sub(now) + if d < 0 { + d = -d + } + return d <= time.Minute + } + + // California observer (UTC-7) emitting a naive local-clock timestamp: + // must NOT be stored verbatim 7h in the past — clamp to ~now. + naivePast := now.Add(-7 * time.Hour).Format("2006-01-02T15:04:05") + if got := resolveRxTime(map[string]interface{}{"timestamp": naivePast}, "test"); !nearNow(got) { + t.Errorf("naive past timestamp (UTC-7 observer): got %q, expected ~now (clamped)", got) + } + + // Naive future just minutes ahead (UTC+N observer, existing soft-clamp + // behavior): still clamped to now. + naiveFuture := now.Add(5 * time.Minute).Format("2006-01-02T15:04:05") + if got := resolveRxTime(map[string]interface{}{"timestamp": naiveFuture}, "test"); !nearNow(got) { + t.Errorf("naive future timestamp: got %q, expected ~now (clamped)", got) + } + + // Naive microsecond layout (python isoformat without tz) — same clamp. + naivePastMicros := now.Add(-7 * time.Hour).Format("2006-01-02T15:04:05.000000") + if got := resolveRxTime(map[string]interface{}{"timestamp": naivePastMicros}, "test"); !nearNow(got) { + t.Errorf("naive past timestamp w/ micros: got %q, expected ~now (clamped)", got) + } + + // Well-behaved observer: Z-suffixed past timestamp passes through verbatim + // even if it's hours old (legitimate buffered uploads must be preserved). + zPast := now.Add(-7 * time.Hour).Format(time.RFC3339) + if got := resolveRxTime(map[string]interface{}{"timestamp": zPast}, "test"); got != zPast { + t.Errorf("Z-suffixed past timestamp must pass through: got %q want %q", got, zPast) + } + + // Well-behaved observer with explicit offset (UTC-7) — canonicalize to UTC + // but preserve the moment in time. Must equal the same moment in UTC. + offsetLoc := time.FixedZone("PDT", -7*3600) + offsetMoment := now.Add(-7 * time.Hour).In(offsetLoc) + offsetStr := offsetMoment.Format(time.RFC3339) + wantUTC := offsetMoment.UTC().Format(time.RFC3339) + if got := resolveRxTime(map[string]interface{}{"timestamp": offsetStr}, "test"); got != wantUTC { + t.Errorf("offset-suffixed timestamp: got %q want %q", got, wantUTC) + } + + // Naive timestamp within tolerance window (2 min in past, observer that + // happens to be in UTC) — within tolerance, passes through verbatim. + naiveCloseStr := now.Add(-2 * time.Minute).Format("2006-01-02T15:04:05") + naiveCloseWant := now.Add(-2 * time.Minute).Format(time.RFC3339) + if got := resolveRxTime(map[string]interface{}{"timestamp": naiveCloseStr}, "test"); got != naiveCloseWant { + t.Errorf("naive timestamp within tolerance: got %q, expected %q (verbatim)", got, naiveCloseWant) + } +}