Files
meshcore-analyzer/cmd/server/clock_skew_issue1094_test.go
T
Kpa-clawbotandopenclaw-bot 1a2b8c48be feat(node-detail): link RTC-reset warning to offending packet hashes (#1094) (#1590)
## Problem
Node detail's bimodal-clock warning showed only `⚠️ N of last M adverts
had nonsense timestamps (likely RTC reset)` — no way to tell which
packets, no way to verify the heuristic, no way to drill in.

## Fix
Additive, two-sides:

**Backend** (`cmd/server/clock_skew.go`)
- New type `BadSample { Hash, AdvertTS, SkewSec }`.
- New field `NodeClockSkew.RecentBadSamples []BadSample` (`omitempty`).
- Populated from the **same** bimodal-bad classification pass that
produces `RecentBadSampleCount` — no heuristic change. `tsSkewPair`
carries `hash` + `advertTS` so the classifier can record per-sample
evidence without a second walk; drift code is unaffected (reads only
`ts`/`skew`).

**Frontend** (`public/nodes.js`)
- `bimodalWarning` preserves the existing count summary line, then
renders a `<ul>` of bad samples: each `<li>` is `<a
href="#/packets/HASH">hash[:8]</a> → formatTimestamp(advertTS)` with ISO
tooltip. Defensive `Array.isArray` so older API responses still render
the summary alone.

## TDD
- **Red:**
`cmd/server/clock_skew_issue1094_test.go::TestIssue1094_RecentBadSamples_ExposesHashAndTimestamp`
— seeds 3 healthy + 2 bimodal-bad adverts, asserts `RecentBadSamples`
has length 2 with the expected hashes and advert timestamps. Fails on
the assertion (`len = 0, want 2`) with the stub-only commit.
- **Green:** classifier populates the slice; existing #1285 and bimodal
tests stay green.
- Red commit: `ed501f4b`
- Green commit: `54305b06`

## Cross-stack
Backend + frontend ship together (`cross-stack: justified` commit). API
stays backward compatible (`omitempty` server, `Array.isArray` client)
but the feature only lights up with both halves present.

## Preflight
Clean — PII, branch scope, red-commit, CSS vars, XSS sinks, migrations,
fixture coverage all pass.

## Acceptance
- [x] Warning lists specific packet hashes
- [x] Each hash links to `#/packets/<hash>`
- [x] Bad advert timestamp shown next to the hash
- [x] Pattern is reusable — `BadSample` is a clean shape any future
heuristic that flags specific packets can adopt

Fixes #1094

---------

Co-authored-by: openclaw-bot <bot@openclaw.local>
2026-06-04 18:48:27 -07:00

110 lines
3.3 KiB
Go

package main
// Regression test for #1094: the bimodal-clock warning currently exposes only
// RecentBadSampleCount, leaving the UI to render "⚠️ N of M adverts had
// nonsense timestamps" without telling the operator WHICH packets were bad.
//
// This test pins the additive API contract: alongside the count, the response
// must expose RecentBadSamples — a slice of (hash, advertTS, skewSec) — so the
// frontend can render each offending hash as a clickable link with its bad
// timestamp.
import (
"testing"
"time"
)
// Seeds 5 recent adverts: 3 healthy (~-20s skew) and 2 with a "nonsense"
// bimodal-bad timestamp (|skew| in (1h, 24h]). The recent window is exactly
// 5 samples, so all five are inside it.
func seedIssue1094Repro(t *testing.T) (*PacketStore, []string, []int64) {
t.Helper()
ps := NewPacketStore(nil, nil)
pt := 4 // ADVERT
const pubkey = "BADTS1094"
baseObs := int64(1779000000)
var txs []*StoreTx
var badHashes []string
var badAdvertTSs []int64
// 3 healthy adverts (skew = -20s).
for i := 0; i < 3; i++ {
obsTS := baseObs + int64(i)*60
advTS := obsTS - 20
txs = append(txs, &StoreTx{
Hash: "healthy-1094-" + formatInt64(int64(i)),
PayloadType: &pt,
DecodedJSON: `{"payload":{"timestamp":` + formatInt64(advTS) + `}}`,
Observations: []*StoreObs{
{ObserverID: "obs1", Timestamp: time.Unix(obsTS, 0).UTC().Format(time.RFC3339)},
},
})
}
// 2 nonsense-timestamp adverts (skew = -7200s = -2h — bimodal-bad,
// below the 24h RTC-reset exclusion so they DO count in recentBadCount).
for i := 0; i < 2; i++ {
obsTS := baseObs + int64(3+i)*60
advTS := obsTS - 7200
hash := "bad-1094-" + formatInt64(int64(i))
txs = append(txs, &StoreTx{
Hash: hash,
PayloadType: &pt,
DecodedJSON: `{"payload":{"timestamp":` + formatInt64(advTS) + `}}`,
Observations: []*StoreObs{
{ObserverID: "obs1", Timestamp: time.Unix(obsTS, 0).UTC().Format(time.RFC3339)},
},
})
badHashes = append(badHashes, hash)
badAdvertTSs = append(badAdvertTSs, advTS)
}
ps.mu.Lock()
ps.byNode[pubkey] = txs
for _, tx := range txs {
ps.byPayloadType[4] = append(ps.byPayloadType[4], tx)
}
ps.clockSkew.computeInterval = 0
ps.mu.Unlock()
return ps, badHashes, badAdvertTSs
}
func TestIssue1094_RecentBadSamples_ExposesHashAndTimestamp(t *testing.T) {
ps, wantHashes, wantAdvertTSs := seedIssue1094Repro(t)
r := ps.GetNodeClockSkew("BADTS1094")
if r == nil {
t.Fatal("expected clock skew result")
}
// Pre-condition: count must already be 2 (gates the test against the
// existing field — if this drops we'd be measuring the wrong thing).
if r.RecentBadSampleCount != 2 {
t.Fatalf("RecentBadSampleCount = %d, want 2 (seed bug, not the field-under-test)",
r.RecentBadSampleCount)
}
if len(r.RecentBadSamples) != 2 {
t.Fatalf("RecentBadSamples len = %d, want 2 — operators need to see which "+
"adverts had nonsense timestamps, not just the count",
len(r.RecentBadSamples))
}
gotByHash := map[string]int64{}
for _, bs := range r.RecentBadSamples {
gotByHash[bs.Hash] = bs.AdvertTS
}
for i, h := range wantHashes {
ts, ok := gotByHash[h]
if !ok {
t.Errorf("RecentBadSamples missing hash %q", h)
continue
}
if ts != wantAdvertTSs[i] {
t.Errorf("RecentBadSamples[%q].AdvertTS = %d, want %d (the bad advertTS)",
h, ts, wantAdvertTSs[i])
}
}
}