mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-05-11 20:54:43 +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>
310 lines
9.9 KiB
Go
310 lines
9.9 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http/httptest"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// ─── resolveWithContext unit tests ─────────────────────────────────────────────
|
|
|
|
func TestResolveWithContext_UniquePrefix(t *testing.T) {
|
|
pm := buildPrefixMap([]nodeInfo{
|
|
{Role: "repeater", PublicKey: "a1b2c3d4", Name: "Node-A", HasGPS: true, Lat: 1, Lon: 2},
|
|
})
|
|
ni, confidence, _ := pm.resolveWithContext("a1b2c3d4", nil, nil)
|
|
if ni == nil || ni.Name != "Node-A" {
|
|
t.Fatal("expected Node-A")
|
|
}
|
|
if confidence != "unique_prefix" {
|
|
t.Fatalf("expected unique_prefix, got %s", confidence)
|
|
}
|
|
}
|
|
|
|
func TestResolveWithContext_NoMatch(t *testing.T) {
|
|
pm := buildPrefixMap([]nodeInfo{
|
|
{Role: "repeater", PublicKey: "a1b2c3d4", Name: "Node-A"},
|
|
})
|
|
ni, confidence, _ := pm.resolveWithContext("ff", nil, nil)
|
|
if ni != nil {
|
|
t.Fatal("expected nil")
|
|
}
|
|
if confidence != "no_match" {
|
|
t.Fatalf("expected no_match, got %s", confidence)
|
|
}
|
|
}
|
|
|
|
func TestResolveWithContext_AffinityWins(t *testing.T) {
|
|
pm := buildPrefixMap([]nodeInfo{
|
|
{Role: "repeater", PublicKey: "a1aaaaaa", Name: "Node-A1"},
|
|
{Role: "repeater", PublicKey: "a1bbbbbb", Name: "Node-A2"},
|
|
})
|
|
|
|
graph := NewNeighborGraph()
|
|
for i := 0; i < 100; i++ {
|
|
graph.upsertEdge("c0c0c0c0", "a1aaaaaa", "a1", "obs1", nil, time.Now())
|
|
}
|
|
|
|
ni, confidence, score := pm.resolveWithContext("a1", []string{"c0c0c0c0"}, graph)
|
|
if ni == nil || ni.Name != "Node-A1" {
|
|
t.Fatalf("expected Node-A1, got %v", ni)
|
|
}
|
|
if confidence != "neighbor_affinity" {
|
|
t.Fatalf("expected neighbor_affinity, got %s", confidence)
|
|
}
|
|
if score <= 0 {
|
|
t.Fatalf("expected positive score, got %f", score)
|
|
}
|
|
}
|
|
|
|
func TestResolveWithContext_AffinityTooClose_FallsToGeo(t *testing.T) {
|
|
pm := buildPrefixMap([]nodeInfo{
|
|
{Role: "repeater", PublicKey: "a1aaaaaa", Name: "Node-A1", HasGPS: true, Lat: 10, Lon: 20},
|
|
{Role: "repeater", PublicKey: "a1bbbbbb", Name: "Node-A2", HasGPS: true, Lat: 11, Lon: 21},
|
|
{Role: "repeater", PublicKey: "c0c0c0c0", Name: "Ctx", HasGPS: true, Lat: 10.1, Lon: 20.1},
|
|
})
|
|
|
|
graph := NewNeighborGraph()
|
|
for i := 0; i < 50; i++ {
|
|
graph.upsertEdge("c0c0c0c0", "a1aaaaaa", "a1", "obs1", nil, time.Now())
|
|
graph.upsertEdge("c0c0c0c0", "a1bbbbbb", "a1", "obs1", nil, time.Now())
|
|
}
|
|
|
|
ni, confidence, _ := pm.resolveWithContext("a1", []string{"c0c0c0c0"}, graph)
|
|
if ni == nil {
|
|
t.Fatal("expected a result")
|
|
}
|
|
if confidence != "geo_proximity" {
|
|
t.Fatalf("expected geo_proximity, got %s", confidence)
|
|
}
|
|
if ni.Name != "Node-A1" {
|
|
t.Fatalf("expected Node-A1 (closer to context), got %s", ni.Name)
|
|
}
|
|
}
|
|
|
|
func TestResolveWithContext_GPSPreference(t *testing.T) {
|
|
pm := buildPrefixMap([]nodeInfo{
|
|
{Role: "repeater", PublicKey: "a1aaaaaa", Name: "NoGPS"},
|
|
{Role: "repeater", PublicKey: "a1bbbbbb", Name: "HasGPS", HasGPS: true, Lat: 1, Lon: 2},
|
|
})
|
|
|
|
ni, confidence, _ := pm.resolveWithContext("a1", nil, nil)
|
|
if ni == nil || ni.Name != "HasGPS" {
|
|
t.Fatalf("expected HasGPS, got %v", ni)
|
|
}
|
|
if confidence != "gps_preference" {
|
|
t.Fatalf("expected gps_preference, got %s", confidence)
|
|
}
|
|
}
|
|
|
|
func TestResolveWithContext_FirstMatchFallback(t *testing.T) {
|
|
pm := buildPrefixMap([]nodeInfo{
|
|
{Role: "repeater", PublicKey: "a1aaaaaa", Name: "First"},
|
|
{Role: "repeater", PublicKey: "a1bbbbbb", Name: "Second"},
|
|
})
|
|
|
|
ni, confidence, _ := pm.resolveWithContext("a1", nil, nil)
|
|
if ni == nil || ni.Name != "First" {
|
|
t.Fatalf("expected First, got %v", ni)
|
|
}
|
|
if confidence != "first_match" {
|
|
t.Fatalf("expected first_match, got %s", confidence)
|
|
}
|
|
}
|
|
|
|
func TestResolveWithContext_NilGraphFallsToGPS(t *testing.T) {
|
|
pm := buildPrefixMap([]nodeInfo{
|
|
{Role: "repeater", PublicKey: "a1aaaaaa", Name: "NoGPS"},
|
|
{Role: "repeater", PublicKey: "a1bbbbbb", Name: "HasGPS", HasGPS: true, Lat: 1, Lon: 2},
|
|
})
|
|
|
|
ni, confidence, _ := pm.resolveWithContext("a1", []string{"someone"}, nil)
|
|
if ni == nil || ni.Name != "HasGPS" {
|
|
t.Fatalf("expected HasGPS, got %v", ni)
|
|
}
|
|
if confidence != "gps_preference" {
|
|
t.Fatalf("expected gps_preference, got %s", confidence)
|
|
}
|
|
}
|
|
|
|
func TestResolveWithContext_BackwardCompatResolve(t *testing.T) {
|
|
// Verify original resolve() still works unchanged
|
|
pm := buildPrefixMap([]nodeInfo{
|
|
{Role: "repeater", PublicKey: "a1aaaaaa", Name: "NoGPS"},
|
|
{Role: "repeater", PublicKey: "a1bbbbbb", Name: "HasGPS", HasGPS: true, Lat: 1, Lon: 2},
|
|
})
|
|
ni := pm.resolve("a1")
|
|
if ni == nil || ni.Name != "HasGPS" {
|
|
t.Fatalf("expected HasGPS from resolve(), got %v", ni)
|
|
}
|
|
}
|
|
|
|
// ─── geoDistApprox ─────────────────────────────────────────────────────────────
|
|
|
|
func TestGeoDistApprox_SamePoint(t *testing.T) {
|
|
d := geoDistApprox(37.0, -122.0, 37.0, -122.0)
|
|
if d != 0 {
|
|
t.Fatalf("expected 0, got %f", d)
|
|
}
|
|
}
|
|
|
|
func TestGeoDistApprox_Ordering(t *testing.T) {
|
|
d1 := geoDistApprox(37.0, -122.0, 37.01, -122.01)
|
|
d2 := geoDistApprox(37.0, -122.0, 38.0, -121.0)
|
|
if d1 >= d2 {
|
|
t.Fatal("closer point should have smaller distance")
|
|
}
|
|
}
|
|
|
|
// ─── handleResolveHops enhanced response (API tests) ───────────────────────────
|
|
|
|
func TestResolveHopsAPI_UniquePrefix(t *testing.T) {
|
|
srv, router := setupTestServer(t)
|
|
_ = srv
|
|
|
|
// Insert a unique node
|
|
srv.db.conn.Exec("INSERT OR IGNORE INTO nodes (public_key, name, lat, lon, role) VALUES (?, ?, ?, ?, ?)",
|
|
"ff11223344", "UniqueNode", 37.0, -122.0, "repeater")
|
|
srv.store.InvalidateNodeCache()
|
|
|
|
req := httptest.NewRequest("GET", "/api/resolve-hops?hops=ff11223344", nil)
|
|
rr := httptest.NewRecorder()
|
|
router.ServeHTTP(rr, req)
|
|
|
|
var result ResolveHopsResponse
|
|
if err := json.Unmarshal(rr.Body.Bytes(), &result); err != nil {
|
|
t.Fatalf("bad JSON: %v", err)
|
|
}
|
|
|
|
hr, ok := result.Resolved["ff11223344"]
|
|
if !ok {
|
|
t.Fatal("expected hop in resolved map")
|
|
}
|
|
if hr.Confidence != "unique_prefix" {
|
|
t.Fatalf("expected unique_prefix, got %s", hr.Confidence)
|
|
}
|
|
}
|
|
|
|
func TestResolveHopsAPI_AmbiguousNoContext(t *testing.T) {
|
|
srv, router := setupTestServer(t)
|
|
|
|
srv.db.conn.Exec("INSERT OR IGNORE INTO nodes (public_key, name, lat, lon, role) VALUES (?, ?, ?, ?, ?)",
|
|
"ee1aaaaaaa", "Node-E1", 37.0, -122.0, "repeater")
|
|
srv.db.conn.Exec("INSERT OR IGNORE INTO nodes (public_key, name, lat, lon, role) VALUES (?, ?, ?, ?, ?)",
|
|
"ee1bbbbbbb", "Node-E2", 38.0, -121.0, "repeater")
|
|
srv.store.InvalidateNodeCache()
|
|
|
|
req := httptest.NewRequest("GET", "/api/resolve-hops?hops=ee1", nil)
|
|
rr := httptest.NewRecorder()
|
|
router.ServeHTTP(rr, req)
|
|
|
|
var result ResolveHopsResponse
|
|
json.Unmarshal(rr.Body.Bytes(), &result)
|
|
|
|
hr := result.Resolved["ee1"]
|
|
if hr == nil {
|
|
t.Fatal("expected hop in resolved map")
|
|
}
|
|
// With both candidates having GPS and no affinity context, the resolver
|
|
// picks the GPS-preferred candidate → confidence is "gps_preference".
|
|
if hr.Confidence != "gps_preference" {
|
|
t.Fatalf("expected gps_preference, got %s", hr.Confidence)
|
|
}
|
|
if len(hr.Candidates) != 2 {
|
|
t.Fatalf("expected 2 candidates, got %d", len(hr.Candidates))
|
|
}
|
|
for _, c := range hr.Candidates {
|
|
if c.AffinityScore != nil {
|
|
t.Fatal("expected nil affinity score without context")
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestResolveHopsAPI_WithAffinityContext(t *testing.T) {
|
|
srv, router := setupTestServer(t)
|
|
|
|
srv.db.conn.Exec("INSERT OR IGNORE INTO nodes (public_key, name, lat, lon, role) VALUES (?, ?, ?, ?, ?)",
|
|
"dd1aaaaaaa", "Node-D1", 37.0, -122.0, "repeater")
|
|
srv.db.conn.Exec("INSERT OR IGNORE INTO nodes (public_key, name, lat, lon, role) VALUES (?, ?, ?, ?, ?)",
|
|
"dd1bbbbbbb", "Node-D2", 38.0, -121.0, "repeater")
|
|
srv.db.conn.Exec("INSERT OR IGNORE INTO nodes (public_key, name, lat, lon, role) VALUES (?, ?, ?, ?, ?)",
|
|
"c0c0c0c0c0", "Context", 37.1, -122.1, "repeater")
|
|
|
|
// Invalidate node cache so the PM includes newly inserted nodes.
|
|
srv.store.cacheMu.Lock()
|
|
srv.store.nodeCacheTime = time.Time{}
|
|
srv.store.cacheMu.Unlock()
|
|
|
|
// Build graph with strong affinity
|
|
graph := NewNeighborGraph()
|
|
for i := 0; i < 100; i++ {
|
|
graph.upsertEdge("c0c0c0c0c0", "dd1aaaaaaa", "dd1", "obs1", nil, time.Now())
|
|
}
|
|
graph.builtAt = time.Now()
|
|
srv.neighborMu.Lock()
|
|
srv.neighborGraph = graph
|
|
srv.neighborMu.Unlock()
|
|
|
|
req := httptest.NewRequest("GET", "/api/resolve-hops?hops=dd1&from_node=c0c0c0c0c0", nil)
|
|
rr := httptest.NewRecorder()
|
|
router.ServeHTTP(rr, req)
|
|
|
|
var result ResolveHopsResponse
|
|
json.Unmarshal(rr.Body.Bytes(), &result)
|
|
|
|
hr := result.Resolved["dd1"]
|
|
if hr == nil {
|
|
t.Fatal("expected hop in resolved map")
|
|
}
|
|
if hr.Confidence != "neighbor_affinity" {
|
|
t.Fatalf("expected neighbor_affinity, got %s", hr.Confidence)
|
|
}
|
|
if hr.BestCandidate == nil || *hr.BestCandidate != "dd1aaaaaaa" {
|
|
t.Fatalf("expected bestCandidate dd1aaaaaaa, got %v", hr.BestCandidate)
|
|
}
|
|
|
|
// Verify affinity scores present
|
|
hasScore := false
|
|
for _, c := range hr.Candidates {
|
|
if c.AffinityScore != nil && *c.AffinityScore > 0 {
|
|
hasScore = true
|
|
}
|
|
}
|
|
if !hasScore {
|
|
t.Fatal("expected at least one candidate with affinity score")
|
|
}
|
|
}
|
|
|
|
func TestResolveHopsAPI_ResponseShape(t *testing.T) {
|
|
srv, router := setupTestServer(t)
|
|
|
|
srv.db.conn.Exec("INSERT OR IGNORE INTO nodes (public_key, name, lat, lon, role) VALUES (?, ?, ?, ?, ?)",
|
|
"bb1aaaaaaa", "Node-B1", 37.0, -122.0, "repeater")
|
|
|
|
req := httptest.NewRequest("GET", "/api/resolve-hops?hops=bb1a", nil)
|
|
rr := httptest.NewRecorder()
|
|
router.ServeHTTP(rr, req)
|
|
|
|
var raw map[string]json.RawMessage
|
|
json.Unmarshal(rr.Body.Bytes(), &raw)
|
|
|
|
if _, ok := raw["resolved"]; !ok {
|
|
t.Fatal("missing 'resolved' key")
|
|
}
|
|
|
|
var resolved map[string]map[string]interface{}
|
|
json.Unmarshal(raw["resolved"], &resolved)
|
|
|
|
for _, hr := range resolved {
|
|
if _, ok := hr["confidence"]; !ok {
|
|
t.Error("missing 'confidence' field in HopResolution")
|
|
}
|
|
if _, ok := hr["candidates"]; !ok {
|
|
t.Error("missing 'candidates' field")
|
|
}
|
|
}
|
|
}
|
|
|
|
// ─── Helpers used only in this test file ───────────────────────────────────────
|