mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-11-12 14:19:36 +00:00
Fixes #2260 (part 1 of 2: liveness; the adaptive offline threshold follows in a separate PR). ## Problem A repeater silent since 2026-10-18 showed **Active — Last relayed 4m ago** and **Last Heard 36m**, and its offline mail never fired. Every hit after that date was the 1-byte hop `67`, shared by nine nodes: - the live resolver's affinity tiebreak (`resolvePathForObs`) put those transmissions in `byNode`, which fed `stats.lastHeard` and `LastHeardMap`; - `last_relayed` folds in the 1-byte prefix bucket on purpose (#662), and drove the status in `roles.js` and the notify offline check. ## Fix The store keeps `certainHeard` per node: the newest transmission that can only be that node. | Evidence | Counts? | |---|---| | Own packet (`pubKey`, `srcPubKey`) | ✅ | | Flood relay hop whose prefix, at the packet's path hash size, matches exactly one path-capable node | ✅ | | 1-byte hop shared with other repeaters | ❌ | | Direct-route path (hops still to come, `Mesh::removeSelfFromPath`) | ❌ | | TRACE (path bytes are SNR) | ❌ | | Destination (`destPubKey`) | ❌ | A multibyte repeater legitimately appears as a 1-byte hop (the originator picks the hash size, `Mesh::routeRecvPacket` appends at that size), so only uniqueness decides. The rule is applied in every load path through `indexResolvedPathHops` and the new `indexColdLoadRelayHops`: Load, loadChunk, scanAndMergeChunk, live ingest, late observations. Liveness reads only that: - `/api/nodes` and the node detail: `last_heard` is the newer of `last_seen` and the certain last heard. Before, it was a plain copy of `last_seen`. - health / bulk-health `stats.lastHeard`. - `LastHeardMap` → notify offline check. The separate relay input (`lastRelayed`, `relayTimes`, `notifyInput.Relayed`) is removed. - `roles.js getEffectiveHeardMs`: newest known timestamp, without `last_relayed`. The side pane no longer says "Last relayed"; the Last Relayed row stays (analytics) with an updated tooltip. - New status **`uncertain`** (repeaters and rooms): nothing certain within the silent window, but the shared prefix (`last_relayed`) still in paths. Grey badge with explanation, an *Uncertain* filter on the nodes list and the map, dimmed like stale. The offline mail for such a node says only its shared prefix was still seen. - A prefix also held by a path-capable node in `inactive_nodes` is not unique; adverts with `signatureValid: false` are no evidence; `/api/nodes/search` reports the same `last_heard`. Attribution for analytics (`byNode`, packet counts, `last_relayed`, relay counters, usefulness) is unchanged. ## Performance Hot path: `noteCertainRelays` runs per observation on ingest under the write lock: two hex bytes of header parsing plus one prefix-map lookup per resolved hop. ``` BenchmarkNoteCertainRelays-16 10307640 116.2 ns/op 0 B/op 0 allocs/op (8-hop 2-byte path, 2,000 repeaters) BenchmarkLastHeardMap-16 1902 0.77 ms/op (5,000 nodes; was 3.7 ms scanning byNode) ``` Memory: one string per node ever certainly heard, dropped together with the node's `byNode` list on eviction. ## Tests - `certain_heard_loadpaths_test.go`: every load path (Load, loadChunk, LoadChunked — each with and without resolved_path — live ingest, late observations) feeds `certainHeard`; each of the 8 call sites verified red with a nil prefix map. - `certain_heard_test.go`: hop width per route/payload type; retired namesake prefix; failed signature; uncertain offline mail; shared vs unique 1-byte vs 2-byte hops; direct/TRACE/nil prefix map; cold-load path; sender vs destination; eviction; `lastHeardValue`; `/api/nodes`, detail and health agree. - Regression for #2260: last certain packet on day X, 20 shared-prefix hops after it → `LastHeardMap` stays at X and `node.offline` goes bad after 72 h. Verified red with the uniqueness check mutated out. - Notify: `TestEvaluateCertainHeard` replaces the relay-input test. - Frontend: `last_relayed` alone no longer keeps a repeater or room active; newest timestamp wins (an old `_liveSeen` can't hide a fresh certain relay). - `cmd/server` `go test ./...` and `test-all.sh` pass locally, also on a trial merge into a downstream fork with 457 extra commits. ## Validation Ran the server on the freshened e2e fixture: list, detail and health report the same last heard; e.g. a repeater whose `last_relayed` is 3 min newer than anything certain keeps its certain time. **No browser screenshot**: Playwright isn't installed on this machine; CI's E2E covers the pages. ## Not changed - The ingestor's relay touch of `nodes.last_seen` (#1854) resolves a shared hop only when exactly one candidate is adjacent in the neighbor graph; it did not fire in this case. Left as is. - The `lastHeard=` list filter still filters `last_seen` in SQL. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
409 lines
14 KiB
Go
409 lines
14 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/meshcore-analyzer/users"
|
|
)
|
|
|
|
// Three repeaters: two share the first pubkey byte 0x67 (as on the
|
|
// instance in #2260, where nine nodes did), one has a unique first byte.
|
|
var (
|
|
chA = "67a7" + strings.Repeat("1", 60) // the silent repeater
|
|
chB = "67f4" + strings.Repeat("2", 60) // its 1-byte namesake, still relaying
|
|
chC = "40aa" + strings.Repeat("3", 60) // unique first byte
|
|
)
|
|
|
|
func chPM() *prefixMap {
|
|
return buildPrefixMap([]nodeInfo{
|
|
{PublicKey: chA, Role: "repeater"},
|
|
{PublicKey: chB, Role: "repeater"},
|
|
{PublicKey: chC, Role: "repeater"},
|
|
})
|
|
}
|
|
|
|
// chTx builds a transmission whose raw header carries the route type and the
|
|
// path hash size (firmware docs/packet_format.md: header, transport codes for
|
|
// transport routes, then the path byte with the hash size in its top bits).
|
|
func chTx(id, routeType, payloadType, hashSize int, firstSeen time.Time) *StoreTx {
|
|
raw := fmt.Sprintf("%02x", payloadType<<2|routeType)
|
|
if routeType == 0 || routeType == 3 {
|
|
raw += "00000000"
|
|
}
|
|
raw += fmt.Sprintf("%02x", (hashSize-1)<<6|1) + strings.Repeat("ab", hashSize) + "00"
|
|
pt := payloadType
|
|
return &StoreTx{ID: id, RawHex: raw, PayloadType: &pt, FirstSeen: firstSeen.UTC().Format(time.RFC3339)}
|
|
}
|
|
|
|
func TestRelayHopHexLen(t *testing.T) {
|
|
at := time.Date(2026, 10, 18, 16, 0, 0, 0, time.UTC)
|
|
cases := []struct {
|
|
name string
|
|
tx *StoreTx
|
|
want int
|
|
}{
|
|
{"flood 1-byte", chTx(1, 1, 5, 1, at), 2},
|
|
{"flood 2-byte", chTx(2, 1, 5, 2, at), 4},
|
|
{"flood 3-byte", chTx(3, 1, 5, 3, at), 6},
|
|
{"transport flood 2-byte", chTx(4, 0, 5, 2, at), 4},
|
|
{"direct: path lists hops still to come", chTx(5, 2, 5, 1, at), 0},
|
|
{"transport direct", chTx(6, 3, 5, 1, at), 0},
|
|
{"TRACE: path bytes are SNR", chTx(7, 1, packetpathTrace, 1, at), 0},
|
|
{"no raw", &StoreTx{ID: 8}, 0},
|
|
}
|
|
for _, c := range cases {
|
|
if got := relayHopHexLen(c.tx); got != c.want {
|
|
t.Errorf("%s: %d; want %d", c.name, got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
const packetpathTrace = 0x09
|
|
|
|
func chStore() *PacketStore {
|
|
s := makeTestStore(0, time.Now(), 0)
|
|
s.byPathHop = make(map[string][]*StoreTx)
|
|
s.initResolvedPathIndex()
|
|
return s
|
|
}
|
|
|
|
func TestNoteCertainRelays(t *testing.T) {
|
|
pm := chPM()
|
|
t0 := time.Date(2026, 10, 18, 16, 49, 25, 0, time.UTC)
|
|
s := chStore()
|
|
hopsSeen := map[string]bool{}
|
|
|
|
// 2-byte flood hop 67a7: only chA has it, certain.
|
|
s.indexResolvedPathHops(chTx(1, 1, 5, 2, t0), []string{chA}, hopsSeen, pm)
|
|
// Later 1-byte flood hops 67 (shared with chB) and 40 (unique): the
|
|
// resolver attributed 67 to chA, but only 40 is certain.
|
|
later := t0.Add(70 * time.Hour)
|
|
s.indexResolvedPathHops(chTx(2, 1, 5, 1, later), []string{chA, chC}, hopsSeen, pm)
|
|
// Direct route and TRACE never count; a nil prefix map records nothing.
|
|
s.indexResolvedPathHops(chTx(3, 2, 5, 2, later), []string{chA}, hopsSeen, pm)
|
|
s.indexResolvedPathHops(chTx(4, 1, packetpathTrace, 2, later), []string{chA}, hopsSeen, pm)
|
|
s.indexResolvedPathHops(chTx(5, 1, 5, 2, later), []string{chA}, hopsSeen, nil)
|
|
|
|
if got := s.certainHeard[chA]; got != t0.Format(time.RFC3339) {
|
|
t.Errorf("chA certain = %q; want the 2-byte hop at %s", got, t0.Format(time.RFC3339))
|
|
}
|
|
if got := s.certainHeard[chC]; got != later.Format(time.RFC3339) {
|
|
t.Errorf("chC certain = %q; want the unique 1-byte hop", got)
|
|
}
|
|
if len(s.byNode[chA]) != 5 {
|
|
t.Errorf("byNode[chA] = %d packets; attribution for analytics must be unchanged", len(s.byNode[chA]))
|
|
}
|
|
|
|
// The cold-load path (resolved_path NULL) feeds liveness the same way.
|
|
s.indexColdLoadRelayHops(chTx(6, 1, 5, 2, later.Add(time.Hour)), []string{chB}, pm)
|
|
if got := s.certainHeard[chB]; got != later.Add(time.Hour).Format(time.RFC3339) {
|
|
t.Errorf("chB certain from cold load = %q", got)
|
|
}
|
|
}
|
|
|
|
func TestIndexByNodeSenderIsCertain(t *testing.T) {
|
|
s := chStore()
|
|
at := time.Date(2026, 10, 18, 16, 45, 51, 0, time.UTC)
|
|
tx := chTx(1, 1, 4, 1, at)
|
|
tx.DecodedJSON = fmt.Sprintf(`{"pubKey":%q,"destPubKey":%q}`, chA, chC)
|
|
s.indexByNode(tx)
|
|
if s.certainHeard[chA] != at.Format(time.RFC3339) {
|
|
t.Errorf("sender not certain: %q", s.certainHeard[chA])
|
|
}
|
|
if _, ok := s.certainHeard[chC]; ok {
|
|
t.Error("a destination is no evidence the node is alive")
|
|
}
|
|
}
|
|
|
|
// #2260 regression: a repeater last certainly heard on 18 Oct, after which
|
|
// only its 1-byte namesake prefix shows up in paths. Liveness must stay at
|
|
// 18 Oct and the offline mail must go out once the 72 h window has passed.
|
|
func TestSilentRepeaterWithNamesakePrefixGoesOffline(t *testing.T) {
|
|
pm := chPM()
|
|
lastCertain := time.Date(2026, 10, 18, 16, 49, 25, 0, time.UTC)
|
|
s := chStore()
|
|
hopsSeen := map[string]bool{}
|
|
s.indexResolvedPathHops(chTx(1, 1, 5, 2, lastCertain), []string{chA}, hopsSeen, pm)
|
|
for i := 0; i < 20; i++ {
|
|
ts := lastCertain.Add(time.Duration(70+i) * time.Hour)
|
|
s.indexResolvedPathHops(chTx(2+i, 1, 5, 1, ts), []string{chA}, hopsSeen, pm)
|
|
}
|
|
|
|
heard, _ := s.LastHeardMap([]string{chA})
|
|
if !heard[chA].Equal(lastCertain) {
|
|
t.Fatalf("LastHeardMap = %v; want %v", heard[chA], lastCertain)
|
|
}
|
|
|
|
in := evInput()
|
|
in.Now = lastCertain.Add(73 * time.Hour)
|
|
in.Watches = []users.NotifyWatch{{UserID: evUser, Pubkey: chA}}
|
|
in.Nodes[chA] = notifyNode{Pubkey: chA, Name: "Silent", Role: "repeater", LastSeen: lastCertain.Add(-4 * time.Minute).Format(time.RFC3339)}
|
|
in.Heard = heard
|
|
if got, _ := evState(evaluateNotifications(in), evUser, users.NotifyNodeOffline, chA); got != users.NotifyBad {
|
|
t.Fatalf("node.offline = %q; want bad", got)
|
|
}
|
|
}
|
|
|
|
func TestCertainHeardEvictedWithNode(t *testing.T) {
|
|
now := time.Now().UTC()
|
|
s := makeTestStore(4, now.Add(-48*time.Hour), 0)
|
|
for i, tx := range s.packets {
|
|
s.noteCertainHeard(fmt.Sprintf("pk%04d", i), tx) // makeTestStore's sender keys
|
|
}
|
|
if len(s.certainHeard) != 4 {
|
|
t.Fatalf("certainHeard = %d entries; want 4", len(s.certainHeard))
|
|
}
|
|
s.retentionHours = 24
|
|
s.EvictStale()
|
|
if len(s.certainHeard) != 0 {
|
|
t.Fatalf("certainHeard = %v after evicting every packet; want empty", s.certainHeard)
|
|
}
|
|
}
|
|
|
|
func TestLastHeardValue(t *testing.T) {
|
|
heard := time.Date(2026, 10, 22, 9, 0, 0, 0, time.UTC)
|
|
cases := []struct {
|
|
name string
|
|
lastSeen interface{}
|
|
heard time.Time
|
|
want interface{}
|
|
}{
|
|
{"certain newer than advert", "2026-10-19T08:00:00Z", heard, "2026-10-22T09:00:00.000Z"},
|
|
{"advert newer", "2026-10-22T10:00:00Z", heard, "2026-10-22T10:00:00Z"},
|
|
{"nothing certain", "2026-10-19T08:00:00Z", time.Time{}, "2026-10-19T08:00:00Z"},
|
|
{"never advertised", nil, heard, "2026-10-22T09:00:00.000Z"},
|
|
{"nothing at all", nil, time.Time{}, nil},
|
|
}
|
|
for _, c := range cases {
|
|
if got := lastHeardValue(c.lastSeen, c.heard); got != c.want {
|
|
t.Errorf("%s: %v; want %v", c.name, got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// /api/nodes and the node detail report the certain last heard, not a copy
|
|
// of last_seen, and the health endpoint's lastHeard is the same value.
|
|
func TestNodeEndpointsLastHeardIsCertain(t *testing.T) {
|
|
srv, router := setupTestServer(t)
|
|
const pk = "aabbccdd11223344"
|
|
heardAt := time.Now().UTC().Add(time.Minute).Truncate(time.Second)
|
|
srv.store.mu.Lock()
|
|
srv.store.certainHeard = map[string]string{pk: heardAt.Format(time.RFC3339)}
|
|
srv.store.mu.Unlock()
|
|
|
|
get := func(url string, out interface{}) {
|
|
t.Helper()
|
|
w := httptest.NewRecorder()
|
|
router.ServeHTTP(w, httptest.NewRequest("GET", url, nil))
|
|
if w.Code != 200 {
|
|
t.Fatalf("%s: %d", url, w.Code)
|
|
}
|
|
if err := json.Unmarshal(w.Body.Bytes(), out); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
type node struct {
|
|
PublicKey string `json:"public_key"`
|
|
LastSeen string `json:"last_seen"`
|
|
LastHeard string `json:"last_heard"`
|
|
}
|
|
want := heardAt.Format("2006-01-02T15:04:05.000Z")
|
|
|
|
var list struct {
|
|
Nodes []node `json:"nodes"`
|
|
}
|
|
get("/api/nodes?limit=50", &list)
|
|
found := false
|
|
for _, n := range list.Nodes {
|
|
if n.PublicKey == pk {
|
|
found = true
|
|
if n.LastHeard != want || n.LastSeen == want {
|
|
t.Errorf("list: last_heard %q last_seen %q; want last_heard %q", n.LastHeard, n.LastSeen, want)
|
|
}
|
|
} else if n.LastHeard != n.LastSeen {
|
|
t.Errorf("list %s: nothing certain, last_heard %q must be last_seen %q", n.PublicKey, n.LastHeard, n.LastSeen)
|
|
}
|
|
}
|
|
if !found {
|
|
t.Fatalf("node %s not listed", pk)
|
|
}
|
|
|
|
var detail struct {
|
|
Node node `json:"node"`
|
|
}
|
|
get("/api/nodes/"+pk, &detail)
|
|
if detail.Node.LastHeard != want {
|
|
t.Errorf("detail last_heard %q; want %q", detail.Node.LastHeard, want)
|
|
}
|
|
|
|
var health struct {
|
|
Stats struct {
|
|
LastHeard string `json:"lastHeard"`
|
|
} `json:"stats"`
|
|
}
|
|
get("/api/nodes/"+pk+"/health", &health)
|
|
if health.Stats.LastHeard != heardAt.Format(time.RFC3339) {
|
|
t.Errorf("health lastHeard %q; want %q", health.Stats.LastHeard, heardAt.Format(time.RFC3339))
|
|
}
|
|
}
|
|
|
|
// noteCertainRelays runs per observation on ingest, under the write lock.
|
|
// An 8-hop 2-byte flood path against a 2,000-repeater prefix map: two hex
|
|
// bytes of header parsing plus one map lookup per hop. Quote in the PR.
|
|
func BenchmarkNoteCertainRelays(b *testing.B) {
|
|
nodes := make([]nodeInfo, 0, 2000)
|
|
for i := 0; i < 2000; i++ {
|
|
nodes = append(nodes, nodeInfo{PublicKey: fmt.Sprintf("%064x", i*7919), Role: "repeater"})
|
|
}
|
|
pm := buildPrefixMap(nodes)
|
|
pks := make([]string, 0, 8)
|
|
for i := 0; i < 8; i++ {
|
|
pks = append(pks, nodes[i*250].PublicKey)
|
|
}
|
|
s := chStore()
|
|
tx := chTx(1, 1, 5, 2, time.Date(2026, 10, 18, 16, 0, 0, 0, time.UTC))
|
|
b.ReportAllocs()
|
|
b.ResetTimer()
|
|
for i := 0; i < b.N; i++ {
|
|
s.noteCertainRelays(tx, pks, pm)
|
|
}
|
|
}
|
|
|
|
func TestIndexByNodeFailedSignatureIsNotCertain(t *testing.T) {
|
|
s := chStore()
|
|
at := time.Date(2026, 10, 18, 16, 45, 51, 0, time.UTC)
|
|
forged := chTx(1, 1, 4, 1, at)
|
|
forged.DecodedJSON = fmt.Sprintf(`{"pubKey":%q,"signatureValid":false}`, chA)
|
|
s.indexByNode(forged)
|
|
if _, ok := s.certainHeard[chA]; ok {
|
|
t.Fatal("an advert whose signature failed is no evidence")
|
|
}
|
|
if len(s.byNode[chA]) != 1 {
|
|
t.Fatal("it is still indexed for analytics")
|
|
}
|
|
valid := chTx(2, 1, 4, 1, at)
|
|
valid.DecodedJSON = fmt.Sprintf(`{"pubKey":%q,"signatureValid":true}`, chA)
|
|
s.indexByNode(valid)
|
|
if s.certainHeard[chA] != at.Format(time.RFC3339) {
|
|
t.Fatalf("a verified advert is evidence: %q", s.certainHeard[chA])
|
|
}
|
|
}
|
|
|
|
func TestNodeSearchLastHeardIsCertain(t *testing.T) {
|
|
srv, router := setupTestServer(t)
|
|
const pk = "aabbccdd11223344"
|
|
heardAt := time.Now().UTC().Add(time.Minute).Truncate(time.Second)
|
|
srv.store.mu.Lock()
|
|
srv.store.certainHeard = map[string]string{pk: heardAt.Format(time.RFC3339)}
|
|
srv.store.mu.Unlock()
|
|
w := httptest.NewRecorder()
|
|
router.ServeHTTP(w, httptest.NewRequest("GET", "/api/nodes/search?q=TestRepeater", nil))
|
|
var body struct {
|
|
Nodes []struct {
|
|
PublicKey string `json:"public_key"`
|
|
LastHeard string `json:"last_heard"`
|
|
} `json:"nodes"`
|
|
}
|
|
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, n := range body.Nodes {
|
|
if n.PublicKey == pk {
|
|
if want := heardAt.Format("2006-01-02T15:04:05.000Z"); n.LastHeard != want {
|
|
t.Fatalf("search last_heard %q; want %q", n.LastHeard, want)
|
|
}
|
|
return
|
|
}
|
|
}
|
|
t.Fatalf("node not found in %s", w.Body.String())
|
|
}
|
|
|
|
// Review (#2261): a repeater moved to inactive_nodes by retention may still
|
|
// relay, so its prefix keeps a hop ambiguous although the nodes table has
|
|
// only one candidate.
|
|
func TestRetiredNamesakeKeepsHopUncertain(t *testing.T) {
|
|
db := setupTestDB(t)
|
|
if _, err := db.conn.Exec(`CREATE TABLE IF NOT EXISTS inactive_nodes (public_key TEXT PRIMARY KEY, name TEXT, role TEXT)`); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, q := range []string{
|
|
`INSERT INTO inactive_nodes (public_key, role) VALUES ('40bb` + strings.Repeat("9", 60) + `', 'repeater')`,
|
|
`INSERT INTO inactive_nodes (public_key, role) VALUES ('6700` + strings.Repeat("8", 60) + `', 'companion')`,
|
|
} {
|
|
if _, err := db.conn.Exec(q); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
pm := chPM()
|
|
if err := pm.markRetired(db); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if uniqueHopPrefix(chC, 2, pm) {
|
|
t.Error("1-byte hop 40 is shared with a retired repeater")
|
|
}
|
|
if !uniqueHopPrefix(chC, 4, pm) {
|
|
t.Error("2-byte hop 40aa is still only chC")
|
|
}
|
|
if _, ok := pm.retired["67"]; ok {
|
|
t.Error("a retired companion cannot relay; its prefix must not count")
|
|
}
|
|
}
|
|
|
|
// Review (#2261): retention copies a row into inactive_nodes and nothing
|
|
// removes it when the node is heard again, so a node can be its own
|
|
// "retired namesake". That row must not take its certain relays away.
|
|
func TestReturnedNodeIsNotItsOwnRetiredNamesake(t *testing.T) {
|
|
db := setupTestDB(t)
|
|
if _, err := db.conn.Exec(`CREATE TABLE IF NOT EXISTS inactive_nodes (public_key TEXT PRIMARY KEY, name TEXT, role TEXT)`); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, q := range []string{
|
|
`INSERT INTO nodes (public_key, role) VALUES ('` + chC + `', 'repeater')`,
|
|
`INSERT INTO inactive_nodes (public_key, role) VALUES ('` + strings.ToUpper(chC) + `', 'repeater')`,
|
|
} {
|
|
if _, err := db.conn.Exec(q); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
pm := chPM()
|
|
if err := pm.markRetired(db); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !uniqueHopPrefix(chC, 4, pm) {
|
|
t.Error("2-byte hop 40aa is only chC; its own stale inactive_nodes row must not count")
|
|
}
|
|
}
|
|
|
|
// The owner of an "uncertain" repeater (#2260) gets the offline mail, worded
|
|
// so it says why: nothing certain, only its shared prefix still in paths.
|
|
func TestOfflineMailExplainsUncertain(t *testing.T) {
|
|
last := evNow.Add(-80 * time.Hour)
|
|
in := evInput()
|
|
in.Nodes[evPkA] = notifyNode{Pubkey: evPkA, Name: "Silent", Role: "repeater", LastSeen: last.Format(time.RFC3339)}
|
|
in.Heard[evPkA] = last
|
|
in.SharedSeen = map[string]time.Time{evPkA: evNow.Add(-4 * time.Minute)}
|
|
in.States = []users.NotifyState{evStateRow(evUser, users.NotifyNodeOffline, evPkA, users.NotifyGood)}
|
|
ch := evaluateNotifications(in).Changes[evUser]
|
|
if len(ch) != 1 || ch[0].To != users.NotifyBad || !ch[0].SharedSeen.Equal(evNow.Add(-4*time.Minute)) {
|
|
t.Fatalf("changes = %+v; want one offline change carrying the shared-prefix time", ch)
|
|
}
|
|
txt := notifyChangeText(ch[0], "")
|
|
if !strings.Contains(txt, "offline (nothing that can only be this node") || !strings.Contains(txt, "possibly another node") {
|
|
t.Fatalf("mail line %q does not explain the uncertain state", txt)
|
|
}
|
|
|
|
// A shared-prefix sighting older than the last certain packet adds nothing.
|
|
in.SharedSeen[evPkA] = last.Add(-time.Hour)
|
|
if ch := evaluateNotifications(in).Changes[evUser]; len(ch) != 1 || !ch[0].SharedSeen.IsZero() {
|
|
t.Fatalf("changes = %+v; want a plain offline change", ch)
|
|
}
|
|
if txt := notifyChangeText(evaluateNotifications(in).Changes[evUser][0], ""); !strings.HasSuffix(strings.SplitN(txt, ", ", 2)[0], "offline") {
|
|
t.Fatalf("plain offline line = %q", txt)
|
|
}
|
|
}
|