mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-05-11 16:54:58 +00:00
5678874128
Fixes #935 ## Problem `buildPrefixMap()` indexed ALL nodes regardless of role, causing companions/sensors to appear as repeater hops when their pubkey prefix collided with a path-hop hash byte. ## Fix ### Server (`cmd/server/store.go`) - Added `canAppearInPath(role string) bool` — allowlist of roles that can forward packets (repeater, room_server, room) - `buildPrefixMap` now skips nodes that fail this check ### Client (`public/hop-resolver.js`) - Added matching `canAppearInPath(role)` helper - `init()` now only populates `prefixIdx` for path-eligible nodes - `pubkeyIdx` remains complete — `resolveFromServer()` still resolves any node type by full pubkey (for server-confirmed `resolved_path` arrays) ## Tests - `cmd/server/prefix_map_role_test.go`: 7 new tests covering role filtering in prefix map and resolveWithContext - `test-hop-resolver-affinity.js`: 4 new tests verifying client-side role filter + pubkeyIdx completeness - All existing tests updated to include `Role: "repeater"` where needed - `go test ./cmd/server/...` — PASS - `node test-hop-resolver-affinity.js` — 16/17 pass (1 pre-existing centroid failure unrelated to this change) ## Commits 1. `fix: filter prefix map to only repeater/room roles (#935)` — server implementation 2. `test: prefix map role filter coverage (#935)` — server tests 3. `ui: filter HopResolver prefix index to repeater/room roles (#935)` — client implementation 4. `test: hop-resolver role filter coverage (#935)` — client tests --------- Co-authored-by: you <you@example.com>
213 lines
6.3 KiB
Go
213 lines
6.3 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"testing"
|
|
)
|
|
|
|
func TestCanAppearInPath(t *testing.T) {
|
|
cases := []struct {
|
|
role string
|
|
want bool
|
|
}{
|
|
{"repeater", true},
|
|
{"Repeater", true},
|
|
{"REPEATER", true},
|
|
{"room_server", true},
|
|
{"Room_Server", true},
|
|
{"room", true},
|
|
{"companion", false},
|
|
{"sensor", false},
|
|
{"", false},
|
|
{"unknown", false},
|
|
}
|
|
for _, tc := range cases {
|
|
if got := canAppearInPath(tc.role); got != tc.want {
|
|
t.Errorf("canAppearInPath(%q) = %v, want %v", tc.role, got, tc.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBuildPrefixMap_ExcludesCompanions(t *testing.T) {
|
|
nodes := []nodeInfo{
|
|
{PublicKey: "7a1234abcdef", Role: "companion", Name: "MyCompanion"},
|
|
}
|
|
pm := buildPrefixMap(nodes)
|
|
if len(pm.m) != 0 {
|
|
t.Fatalf("expected empty prefix map, got %d entries", len(pm.m))
|
|
}
|
|
}
|
|
|
|
func TestBuildPrefixMap_ExcludesSensors(t *testing.T) {
|
|
nodes := []nodeInfo{
|
|
{PublicKey: "7a1234abcdef", Role: "sensor", Name: "MySensor"},
|
|
}
|
|
pm := buildPrefixMap(nodes)
|
|
if len(pm.m) != 0 {
|
|
t.Fatalf("expected empty prefix map, got %d entries", len(pm.m))
|
|
}
|
|
}
|
|
|
|
func TestResolveWithContext_NilWhenOnlyCompanionMatchesPrefix(t *testing.T) {
|
|
nodes := []nodeInfo{
|
|
{PublicKey: "7a1234abcdef", Role: "companion", Name: "MyCompanion"},
|
|
}
|
|
pm := buildPrefixMap(nodes)
|
|
r, _, _ := pm.resolveWithContext("7a", nil, nil)
|
|
if r != nil {
|
|
t.Fatalf("expected nil, got %+v", r)
|
|
}
|
|
}
|
|
|
|
func TestResolveWithContext_NilWhenOnlySensorMatchesPrefix(t *testing.T) {
|
|
nodes := []nodeInfo{
|
|
{PublicKey: "7a1234abcdef", Role: "sensor", Name: "MySensor"},
|
|
}
|
|
pm := buildPrefixMap(nodes)
|
|
r, _, _ := pm.resolveWithContext("7a", nil, nil)
|
|
if r != nil {
|
|
t.Fatalf("expected nil for sensor-only prefix, got %+v", r)
|
|
}
|
|
}
|
|
|
|
func TestResolveWithContext_PrefersRepeaterOverCompanionAtSamePrefix(t *testing.T) {
|
|
nodes := []nodeInfo{
|
|
{PublicKey: "7a1234abcdef", Role: "companion", Name: "MyCompanion"},
|
|
{PublicKey: "7a5678901234", Role: "repeater", Name: "MyRepeater"},
|
|
}
|
|
pm := buildPrefixMap(nodes)
|
|
r, _, _ := pm.resolveWithContext("7a", nil, nil)
|
|
if r == nil {
|
|
t.Fatal("expected non-nil result")
|
|
}
|
|
if r.Name != "MyRepeater" {
|
|
t.Fatalf("expected MyRepeater, got %s", r.Name)
|
|
}
|
|
}
|
|
|
|
func TestResolveWithContext_PrefersRoomServerOverCompanionAtSamePrefix(t *testing.T) {
|
|
nodes := []nodeInfo{
|
|
{PublicKey: "ab1234abcdef", Role: "companion", Name: "MyCompanion"},
|
|
{PublicKey: "ab5678901234", Role: "room_server", Name: "MyRoom"},
|
|
}
|
|
pm := buildPrefixMap(nodes)
|
|
r, _, _ := pm.resolveWithContext("ab", nil, nil)
|
|
if r == nil {
|
|
t.Fatal("expected non-nil result")
|
|
}
|
|
if r.Name != "MyRoom" {
|
|
t.Fatalf("expected MyRoom, got %s", r.Name)
|
|
}
|
|
}
|
|
|
|
func TestResolve_NilWhenOnlyCompanionMatchesPrefix(t *testing.T) {
|
|
nodes := []nodeInfo{
|
|
{PublicKey: "7a1234abcdef", Role: "companion", Name: "MyCompanion"},
|
|
}
|
|
pm := buildPrefixMap(nodes)
|
|
r := pm.resolve("7a")
|
|
if r != nil {
|
|
t.Fatalf("expected nil from resolve() for companion-only prefix, got %+v", r)
|
|
}
|
|
}
|
|
|
|
func TestResolve_NilWhenOnlySensorMatchesPrefix(t *testing.T) {
|
|
nodes := []nodeInfo{
|
|
{PublicKey: "7a1234abcdef", Role: "sensor", Name: "MySensor"},
|
|
}
|
|
pm := buildPrefixMap(nodes)
|
|
r := pm.resolve("7a")
|
|
if r != nil {
|
|
t.Fatalf("expected nil from resolve() for sensor-only prefix, got %+v", r)
|
|
}
|
|
}
|
|
|
|
func TestResolveWithContext_PicksRepeaterEvenWhenCompanionHasGPS(t *testing.T) {
|
|
// Adversarial: companion has GPS, repeater doesn't. Role filter should
|
|
// exclude companion entirely, so repeater wins despite lacking GPS.
|
|
nodes := []nodeInfo{
|
|
{PublicKey: "7a1234abcdef", Role: "companion", Name: "GPSCompanion", Lat: 37.0, Lon: -122.0, HasGPS: true},
|
|
{PublicKey: "7a5678901234", Role: "repeater", Name: "NoGPSRepeater", Lat: 0, Lon: 0, HasGPS: false},
|
|
}
|
|
pm := buildPrefixMap(nodes)
|
|
r, _, _ := pm.resolveWithContext("7a", nil, nil)
|
|
if r == nil {
|
|
t.Fatal("expected non-nil result")
|
|
}
|
|
if r.Name != "NoGPSRepeater" {
|
|
t.Fatalf("expected NoGPSRepeater (role filter excludes companion), got %s", r.Name)
|
|
}
|
|
}
|
|
|
|
func TestComputeDistancesForTx_CompanionNeverInResolvedChain(t *testing.T) {
|
|
// Integration test: a path with a prefix matching both a companion and a
|
|
// repeater. The resolveHop function (using buildPrefixMap) should only
|
|
// return the repeater.
|
|
nodes := []nodeInfo{
|
|
{PublicKey: "7a1234abcdef", Role: "companion", Name: "BadCompanion", Lat: 37.0, Lon: -122.0, HasGPS: true},
|
|
{PublicKey: "7a5678901234", Role: "repeater", Name: "GoodRepeater", Lat: 38.0, Lon: -123.0, HasGPS: true},
|
|
{PublicKey: "bb1111111111", Role: "repeater", Name: "OtherRepeater", Lat: 39.0, Lon: -124.0, HasGPS: true},
|
|
}
|
|
pm := buildPrefixMap(nodes)
|
|
|
|
nodeByPk := make(map[string]*nodeInfo)
|
|
for i := range nodes {
|
|
nodeByPk[nodes[i].PublicKey] = &nodes[i]
|
|
}
|
|
repeaterSet := map[string]bool{
|
|
"7a5678901234": true,
|
|
"bb1111111111": true,
|
|
}
|
|
|
|
// Build a synthetic StoreTx with a path ["7a", "bb"] and a sender with GPS
|
|
senderPK := "cc0000000000"
|
|
sender := nodeInfo{PublicKey: senderPK, Role: "repeater", Name: "Sender", Lat: 36.0, Lon: -121.0, HasGPS: true}
|
|
nodeByPk[senderPK] = &sender
|
|
|
|
pathJSON, _ := json.Marshal([]string{"7a", "bb"})
|
|
decoded, _ := json.Marshal(map[string]interface{}{"pubKey": senderPK})
|
|
|
|
tx := &StoreTx{
|
|
PathJSON: string(pathJSON),
|
|
DecodedJSON: string(decoded),
|
|
FirstSeen: "2026-04-30T12:00",
|
|
}
|
|
|
|
resolveHop := func(hop string) *nodeInfo {
|
|
return pm.resolve(hop)
|
|
}
|
|
|
|
hops, pathRec := computeDistancesForTx(tx, nodeByPk, repeaterSet, resolveHop)
|
|
|
|
// Verify BadCompanion's pubkey never appears in hops
|
|
badPK := "7a1234abcdef"
|
|
for i, h := range hops {
|
|
if h.FromPk == badPK || h.ToPk == badPK {
|
|
t.Fatalf("hop[%d] contains BadCompanion pubkey: from=%s to=%s", i, h.FromPk, h.ToPk)
|
|
}
|
|
}
|
|
|
|
// Verify BadCompanion's pubkey never appears in pathRec
|
|
if pathRec == nil {
|
|
t.Fatal("expected non-nil path record (3 GPS nodes in chain)")
|
|
}
|
|
for i, hop := range pathRec.Hops {
|
|
if hop.FromPk == badPK || hop.ToPk == badPK {
|
|
t.Fatalf("pathRec.Hops[%d] contains BadCompanion pubkey: from=%s to=%s", i, hop.FromPk, hop.ToPk)
|
|
}
|
|
}
|
|
|
|
// Verify GoodRepeater IS in the chain (proves the prefix was resolved to the right node)
|
|
goodPK := "7a5678901234"
|
|
foundGood := false
|
|
for _, hop := range pathRec.Hops {
|
|
if hop.FromPk == goodPK || hop.ToPk == goodPK {
|
|
foundGood = true
|
|
break
|
|
}
|
|
}
|
|
if !foundGood {
|
|
t.Fatal("expected GoodRepeater (7a5678901234) in pathRec.Hops but not found")
|
|
}
|
|
}
|