test(#1290): red — assert listener-only observers excluded from path-hop candidates

Adds TestResolveWithContext_ExcludesNonRelayObservers_Issue1290 covering:
- repeat:off pubkey → not a candidate
- repeat:on pubkey → still a candidate (regression guard)
- legacy obs / no field → still a candidate (back-compat)

Stub prefixMap.markNonRelay added so the test compiles and runs to its
assertion. Filtering inside resolveWithContext is not yet wired, so
case-1 fails on assertion (case repeat:off — expected nil, got
"ListenerOnly"). Green commit will plumb the filter through pm.m
construction.
This commit is contained in:
Kpa-clawbot
2026-06-08 06:39:15 +00:00
parent fa02f23a40
commit 5f7fdb9620
2 changed files with 65 additions and 0 deletions
+43
View File
@@ -0,0 +1,43 @@
package main
import (
"testing"
)
// Issue #1290 — exclude observers that advertised `repeat:off` (listener-only)
// from the path-hop disambiguator candidate set. Three cases:
// 1. repeat:off pubkey → NOT a candidate
// 2. repeat:on pubkey → IS a candidate (regression guard)
// 3. legacy / no field → IS a candidate (back-compat preserve current behavior)
func TestResolveWithContext_ExcludesNonRelayObservers_Issue1290(t *testing.T) {
nodes := []nodeInfo{
{Role: "repeater", PublicKey: "a1aaaaaa", Name: "RealRepeater"},
{Role: "repeater", PublicKey: "a1bbbbbb", Name: "ListenerOnly"},
}
// Case 1: marked non-relay → excluded from candidate set.
pm := buildPrefixMap(nodes)
pm.markNonRelay([]string{"a1bbbbbb"})
ni, conf, _ := pm.resolveWithContext("a1bbbbbb", nil, nil)
if ni != nil {
t.Fatalf("case repeat:off — expected nil (listener-only excluded), got name=%q confidence=%q", ni.Name, conf)
}
if conf != "no_match" {
t.Fatalf("case repeat:off — expected no_match confidence after exclusion, got %q", conf)
}
// Case 2: repeat:on (i.e. not in nonRelay set) → still resolves.
pm2 := buildPrefixMap(nodes)
pm2.markNonRelay([]string{"a1bbbbbb"})
ni2, _, _ := pm2.resolveWithContext("a1aaaaaa", nil, nil)
if ni2 == nil || ni2.Name != "RealRepeater" {
t.Fatalf("case repeat:on — expected RealRepeater, got %+v", ni2)
}
// Case 3: legacy back-compat — no markNonRelay call → behavior unchanged.
pm3 := buildPrefixMap(nodes)
ni3, _, _ := pm3.resolveWithContext("a1bbbbbb", nil, nil)
if ni3 == nil || ni3.Name != "ListenerOnly" {
t.Fatalf("case legacy — expected ListenerOnly (back-compat), got %+v", ni3)
}
}
+22
View File
@@ -6121,6 +6121,28 @@ func (s *PacketStore) getAllNodes() []nodeInfo {
type prefixMap struct {
m map[string][]nodeInfo
// nonRelay holds lowercase pubkeys of observer-known nodes that have
// advertised `repeat:off` in their MQTT /status message (issue #1290).
// Such nodes are pure listeners and must never be selected as a
// path-hop candidate by resolveWithContext, since by firmware
// contract they do not forward packets. nil/empty preserves the
// pre-#1290 behavior (every prefix-matching node is a candidate).
nonRelay map[string]struct{}
}
// markNonRelay registers a set of lowercase pubkeys as listener-only.
// Called by the server when wiring the prefix map after reading the
// observers table's can_relay column. Issue #1290.
func (pm *prefixMap) markNonRelay(pubkeys []string) {
if pm == nil {
return
}
if pm.nonRelay == nil {
pm.nonRelay = make(map[string]struct{}, len(pubkeys))
}
for _, pk := range pubkeys {
pm.nonRelay[strings.ToLower(pk)] = struct{}{}
}
}
// maxPrefixLen caps prefix map entries. MeshCore path hops use 26 char