Compare commits

..

4 Commits

Author SHA1 Message Date
Kpa-clawbot dc3552d8e7 test(#1181): RED — endpoint coverage + generation counter + concurrent access
Adds failing tests for the three gaps surfaced by Round 1 review:

1. Endpoint coverage [adv #1, djb #1]: TestHiddenNamePrefix_1181_NodeHealth,
   _BulkHealth, _Paths, _Analytics — all expect IsNameHidden to be honoured
   at the same sites that already gate on IsBlacklisted. All fail (200 vs
   404 / leaked entry).

2. Generation counter [djb #2]: TestHiddenNamePrefixesGeneration_Increments
   asserts SetHiddenNamePrefixes bumps the counter on every call (mirrors
   BlacklistGenerationIncrements pattern). Stub returns 0 -> assertion
   fails on first delta.

3. Slice race [djb #3]: TestHiddenNamePrefixes_ConcurrentAccess hammers
   Set + IsNameHidden across goroutines. Today this panics on a nil
   stringslite deref via the plain slice access; the fix wires the slice
   through atomic.Pointer matching nodeBlacklist.

Stubs SetHiddenNamePrefixes / HiddenNamePrefixesGeneration added so the
RED commit compiles + fails on assertions, not on missing symbols.
2026-06-11 16:08:32 +00:00
Kpa-clawbot d1e317f4c2 test(#1181): cover /api/nodes/{pubkey} hidden-prefix 404
Self-review found the new IsNameHidden 404 branch in handleNodeDetail
had zero test coverage. Adds TestHiddenNamePrefix_1181_Detail:
- baseline (HiddenNamePrefixes=nil): /api/nodes/{pk} returns 200 with name
- enabled (HiddenNamePrefixes=["🚫"]): /api/nodes/{pk} returns 404,
  body does NOT leak the name (mirrors blacklist 404 behaviour)

Anti-tautology verified: reverting the handleNodeDetail filter makes
the test fail at the 404 assertion (got 200 with name in body).
2026-06-11 15:12:38 +00:00
Kpa-clawbot 0b89bfa50f fix(#1181): hide nodes whose name starts with a configured prefix
Adds Config.IsNameHidden plus filters in handleNodes, handleNodeSearch
and handleNodeDetail that drop any node whose name starts with one of
the operator-configured HiddenNamePrefixes (default ["🚫"]).

Mirrors the IsBlacklisted pattern: the filter runs at the API layer
only — DB rows and observation history (paths, hops, distances) stay
intact, so the node simply re-appears if the operator clears the
prefix list. The node is NOT hidden from the mesh itself, only from
this dashboard.

Updates config.example.json with the new field and its operator-facing
comment per the AGENTS.md config documentation rule.

Fixes #1181.
2026-06-11 15:05:18 +00:00
Kpa-clawbot 82c81809d7 test(#1181): failing test for HiddenNamePrefixes filter
Adds TestHiddenNamePrefix_1181_NodesList and _Search asserting that
nodes with names starting with a configured prefix (default 🚫) are
omitted from /api/nodes and /api/nodes/search, while DB rows are
preserved (no DB-level filtering).

Red commit: HiddenNamePrefixes field exists on Config (so the test
compiles), but no enforcement is wired into the API handlers yet,
so both subtests fail at the leak assertion. Green commit follows.
2026-06-11 15:05:18 +00:00
5 changed files with 417 additions and 1 deletions
+51
View File
@@ -48,6 +48,15 @@ type Config struct {
// operator refuses to fix.
NodeBlacklist []string `json:"nodeBlacklist"`
// HiddenNamePrefixes is a list of name prefixes that mark a node as
// hidden from API responses (issue #1181). The default `["🚫"]` mirrors
// a convention used by other MeshCore map dashboards: operators who
// rename their node with the prefix get hidden from the map without
// waiting for normal retention to clear stale data. DB rows are
// preserved — the filter is applied at the API layer only, so the
// underlying observation history remains intact.
HiddenNamePrefixes []string `json:"hiddenNamePrefixes"`
// blacklistSetPtr holds the active lookup set as an atomic pointer.
// Read path is a single atomic load — no mutex, no sync.Once. Writers
// always replace the whole map; readers see either the old or the new
@@ -727,6 +736,48 @@ func (c *Config) IsBlacklisted(pubkey string) bool {
return (*mp)[strings.ToLower(strings.TrimSpace(pubkey))]
}
// IsNameHidden returns true if the given node name starts with any of the
// operator-configured HiddenNamePrefixes (issue #1181). Empty/whitespace
// prefixes are ignored. Used to drop nodes from /api/nodes, /api/nodes/search
// and /api/nodes/{pubkey} without deleting the underlying DB row, so observer
// history stays intact even after the operator hides the node.
func (c *Config) IsNameHidden(name string) bool {
if c == nil || len(c.HiddenNamePrefixes) == 0 {
return false
}
for _, p := range c.HiddenNamePrefixes {
if p == "" {
continue
}
if strings.HasPrefix(name, p) {
return true
}
}
return false
}
// SetHiddenNamePrefixes is a stub for the upcoming atomic.Pointer wrapper.
// Currently just assigns the slice (NOT yet race-safe). Bumps the
// generation counter. Wired up properly in the GREEN commit.
func (c *Config) SetHiddenNamePrefixes(prefixes []string) {
if c == nil {
return
}
cp := make([]string, len(prefixes))
copy(cp, prefixes)
c.HiddenNamePrefixes = cp
// TODO(GREEN): bump c.hiddenNamePrefixesGen + atomic.Store
}
// HiddenNamePrefixesGeneration is a stub returning 0. GREEN commit returns
// the live atomic counter.
func (c *Config) HiddenNamePrefixesGeneration() uint64 {
if c == nil {
return 0
}
return 0
}
// SaveGeoFilter writes the geo_filter section back to config.json on disk.
// Pass gf=nil to remove the filter. The rest of config.json is preserved as-is.
func SaveGeoFilter(configDir string, gf *GeoFilterConfig) error {
@@ -0,0 +1,193 @@
package main
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
)
// TestHiddenNamePrefix_1181_NodeHealth asserts that /api/nodes/{pk}/health
// returns 404 for a node whose name starts with a hidden prefix — mirroring
// the existing blacklist guard at the top of handleNodeHealth.
//
// Anti-tautology: this test FAILS if the IsNameHidden guard is removed from
// handleNodeHealth (the handler would 200 with health data instead of 404).
func TestHiddenNamePrefix_1181_NodeHealth(t *testing.T) {
srv, router := setupTestServer(t)
pk := "deadbeef00001184"
if _, err := srv.db.conn.Exec(`INSERT INTO nodes
(public_key, name, role, lat, lon, last_seen, first_seen, advert_count)
VALUES (?, ?, ?, 0, 0, '2026-06-01T00:00:00Z', '2026-06-01T00:00:00Z', 1)`,
pk, "🚫 health me", "companion"); err != nil {
t.Fatalf("insert: %v", err)
}
get := func() *httptest.ResponseRecorder {
req := httptest.NewRequest("GET", "/api/nodes/"+pk+"/health", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
return w
}
srv.cfg.HiddenNamePrefixes = []string{"🚫"}
w := get()
if w.Code != http.StatusNotFound {
t.Fatalf("hidden: expected 404 from /api/nodes/%s/health, got %d body=%s", pk, w.Code, w.Body.String())
}
if strings.Contains(w.Body.String(), "health me") {
t.Fatalf("hidden: name leaked in /health 404 body: %s", w.Body.String())
}
}
// TestHiddenNamePrefix_1181_BulkHealth asserts /api/nodes/bulk-health filters
// out nodes whose name starts with a hidden prefix — same shape as the
// existing blacklist filter inside handleBulkHealth.
//
// Anti-tautology: remove the IsNameHidden branch from handleBulkHealth and
// the hidden node leaks back into the response array; this assertion fails.
func TestHiddenNamePrefix_1181_BulkHealth(t *testing.T) {
srv, router := setupTestServer(t)
pk := "deadbeef00001185"
if _, err := srv.db.conn.Exec(`INSERT INTO nodes
(public_key, name, role, lat, lon, last_seen, first_seen, advert_count)
VALUES (?, ?, ?, 0, 0, '2026-06-01T00:00:00Z', '2026-06-01T00:00:00Z', 1)`,
pk, "🚫 bulk me", "companion"); err != nil {
t.Fatalf("insert: %v", err)
}
srv.cfg.HiddenNamePrefixes = []string{"🚫"}
srv.cfg.NodeBlacklist = []string{"force-filter-branch"} // force the existing blacklist branch on so results-array path is taken
srv.cfg.SetNodeBlacklist(srv.cfg.NodeBlacklist)
req := httptest.NewRequest("GET", "/api/nodes/bulk-health?limit=2000", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
}
var arr []map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &arr); err != nil {
t.Fatalf("unmarshal: %v body=%s", err, w.Body.String())
}
for _, e := range arr {
if got, _ := e["public_key"].(string); strings.EqualFold(got, pk) {
t.Fatalf("hidden node %s leaked through /api/nodes/bulk-health", pk)
}
}
}
// TestHiddenNamePrefix_1181_Paths asserts /api/nodes/{pk}/paths returns 404
// for a hidden-prefix node, mirroring blacklist behaviour.
func TestHiddenNamePrefix_1181_Paths(t *testing.T) {
srv, router := setupTestServer(t)
pk := "deadbeef00001186"
if _, err := srv.db.conn.Exec(`INSERT INTO nodes
(public_key, name, role, lat, lon, last_seen, first_seen, advert_count)
VALUES (?, ?, ?, 0, 0, '2026-06-01T00:00:00Z', '2026-06-01T00:00:00Z', 1)`,
pk, "🚫 paths me", "companion"); err != nil {
t.Fatalf("insert: %v", err)
}
srv.cfg.HiddenNamePrefixes = []string{"🚫"}
req := httptest.NewRequest("GET", "/api/nodes/"+pk+"/paths", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Fatalf("hidden: expected 404 from /api/nodes/%s/paths, got %d body=%s", pk, w.Code, w.Body.String())
}
}
// TestHiddenNamePrefix_1181_Analytics asserts /api/nodes/{pk}/analytics 404s
// for hidden-prefix nodes.
func TestHiddenNamePrefix_1181_Analytics(t *testing.T) {
srv, router := setupTestServer(t)
pk := "deadbeef00001187"
if _, err := srv.db.conn.Exec(`INSERT INTO nodes
(public_key, name, role, lat, lon, last_seen, first_seen, advert_count)
VALUES (?, ?, ?, 0, 0, '2026-06-01T00:00:00Z', '2026-06-01T00:00:00Z', 1)`,
pk, "🚫 analytics me", "companion"); err != nil {
t.Fatalf("insert: %v", err)
}
srv.cfg.HiddenNamePrefixes = []string{"🚫"}
req := httptest.NewRequest("GET", "/api/nodes/"+pk+"/analytics", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Fatalf("hidden: expected 404 from /api/nodes/%s/analytics, got %d body=%s", pk, w.Code, w.Body.String())
}
}
// TestHiddenNamePrefixesGeneration_Increments asserts the per-source
// generation counter bumps on every Set call — mirrors
// TestConfig_BlacklistGenerationIncrements behaviour. Cache wiring lives in
// a follow-up; the counter is the prerequisite primitive.
func TestHiddenNamePrefixesGeneration_Increments(t *testing.T) {
cfg := &Config{}
g0 := cfg.HiddenNamePrefixesGeneration()
cfg.SetHiddenNamePrefixes([]string{"🚫"})
g1 := cfg.HiddenNamePrefixesGeneration()
if g1 != g0+1 {
t.Fatalf("first SetHiddenNamePrefixes: gen %d -> %d (want +1)", g0, g1)
}
cfg.SetHiddenNamePrefixes([]string{"🚫"})
g2 := cfg.HiddenNamePrefixesGeneration()
if g2 != g1+1 {
t.Fatalf("second SetHiddenNamePrefixes: gen %d -> %d (want +1)", g1, g2)
}
cfg.SetHiddenNamePrefixes(nil)
g3 := cfg.HiddenNamePrefixesGeneration()
if g3 != g2+1 {
t.Fatalf("nil SetHiddenNamePrefixes: gen %d -> %d (want +1)", g2, g3)
}
}
// TestHiddenNamePrefixes_ConcurrentAccess hammers Set + IsNameHidden from
// multiple goroutines. Doesn't assert anything beyond "doesn't panic" —
// atomic.Pointer correctness is what we're verifying, race detector is not
// in scope for this PR's CI (see PR scope).
func TestHiddenNamePrefixes_ConcurrentAccess(t *testing.T) {
cfg := &Config{}
cfg.SetHiddenNamePrefixes([]string{"🚫"})
var stop atomic.Bool
var wg sync.WaitGroup
// Writer
wg.Add(1)
go func() {
defer wg.Done()
for i := 0; !stop.Load(); i++ {
if i%2 == 0 {
cfg.SetHiddenNamePrefixes([]string{"🚫", "test"})
} else {
cfg.SetHiddenNamePrefixes([]string{"🚫"})
}
}
}()
// Readers
for r := 0; r < 4; r++ {
wg.Add(1)
go func() {
defer wg.Done()
for !stop.Load() {
_ = cfg.IsNameHidden("🚫 something")
_ = cfg.IsNameHidden("normal name")
}
}()
}
time.Sleep(250 * time.Millisecond)
stop.Store(true)
wg.Wait()
}
+139
View File
@@ -0,0 +1,139 @@
package main
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// TestHiddenNamePrefix_1181 verifies operator-configurable name-prefix hiding
// for nodes (issue #1181). When the operator configures HiddenNamePrefixes,
// nodes whose name begins with any configured prefix are omitted from API
// responses (list, search, detail). DB rows are preserved — filtering happens
// at the API layer only.
func TestHiddenNamePrefix_1181_NodesList(t *testing.T) {
srv, router := setupTestServer(t)
// Insert a node whose name starts with the configured 🚫 prefix.
_, err := srv.db.conn.Exec(`INSERT INTO nodes
(public_key, name, role, lat, lon, last_seen, first_seen, advert_count)
VALUES (?, ?, ?, 0, 0, '2026-06-01T00:00:00Z', '2026-06-01T00:00:00Z', 1)`,
"deadbeef00001181", "🚫 ban me", "companion")
if err != nil {
t.Fatalf("insert hidden node: %v", err)
}
get := func() []map[string]interface{} {
req := httptest.NewRequest("GET", "/api/nodes?limit=2000", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
}
var resp struct {
Nodes []map[string]interface{} `json:"nodes"`
}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal: %v body=%s", err, w.Body.String())
}
return resp.Nodes
}
hasName := func(nodes []map[string]interface{}, substr string) bool {
for _, n := range nodes {
if name, _ := n["name"].(string); strings.Contains(name, substr) {
return true
}
}
return false
}
// Empty prefix list: node MUST be present.
srv.cfg.HiddenNamePrefixes = nil
if !hasName(get(), "ban me") {
t.Fatalf("with empty HiddenNamePrefixes, node should be present in /api/nodes")
}
// Configured 🚫 prefix: node MUST be omitted.
srv.cfg.HiddenNamePrefixes = []string{"🚫"}
if hasName(get(), "ban me") {
t.Fatalf("with HiddenNamePrefixes=[\"🚫\"], node 🚫 ban me should be hidden from /api/nodes")
}
}
// TestHiddenNamePrefix_1181_Search ensures hidden nodes are also filtered
// from /api/nodes/search.
func TestHiddenNamePrefix_1181_Search(t *testing.T) {
srv, router := setupTestServer(t)
if _, err := srv.db.conn.Exec(`INSERT INTO nodes
(public_key, name, role, lat, lon, last_seen, first_seen, advert_count)
VALUES (?, ?, ?, 0, 0, '2026-06-01T00:00:00Z', '2026-06-01T00:00:00Z', 1)`,
"deadbeef00001182", "🚫 search me", "companion"); err != nil {
t.Fatalf("insert: %v", err)
}
srv.cfg.HiddenNamePrefixes = []string{"🚫"}
req := httptest.NewRequest("GET", "/api/nodes/search?q=search", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
}
var resp struct {
Nodes []map[string]interface{} `json:"nodes"`
}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal: %v", err)
}
for _, n := range resp.Nodes {
if name, _ := n["name"].(string); strings.Contains(name, "search me") {
t.Fatalf("hidden node leaked through /api/nodes/search: %v", n)
}
}
}
// TestHiddenNamePrefix_1181_Detail ensures /api/nodes/{pubkey} returns 404
// for a node whose name starts with a hidden prefix — mirroring the
// blacklist behaviour so callers learn nothing about whether the row exists.
func TestHiddenNamePrefix_1181_Detail(t *testing.T) {
srv, router := setupTestServer(t)
pk := "deadbeef00001183"
if _, err := srv.db.conn.Exec(`INSERT INTO nodes
(public_key, name, role, lat, lon, last_seen, first_seen, advert_count)
VALUES (?, ?, ?, 0, 0, '2026-06-01T00:00:00Z', '2026-06-01T00:00:00Z', 1)`,
pk, "🚫 detail me", "companion"); err != nil {
t.Fatalf("insert: %v", err)
}
get := func() *httptest.ResponseRecorder {
req := httptest.NewRequest("GET", "/api/nodes/"+pk, nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
return w
}
// Empty prefix list: detail MUST be reachable (200 with the name).
srv.cfg.HiddenNamePrefixes = nil
w := get()
if w.Code != http.StatusOK {
t.Fatalf("baseline: expected 200, got %d body=%s", w.Code, w.Body.String())
}
if !strings.Contains(w.Body.String(), "detail me") {
t.Fatalf("baseline: response missing node name; body=%s", w.Body.String())
}
// Configured 🚫 prefix: detail MUST 404 — no name, no fields, nothing.
srv.cfg.HiddenNamePrefixes = []string{"🚫"}
w = get()
if w.Code != http.StatusNotFound {
t.Fatalf("hidden: expected 404, got %d body=%s", w.Code, w.Body.String())
}
if strings.Contains(w.Body.String(), "detail me") {
t.Fatalf("hidden: name leaked in 404 body: %s", w.Body.String())
}
}
+32 -1
View File
@@ -1353,6 +1353,20 @@ func (s *Server) handleNodes(w http.ResponseWriter, r *http.Request) {
total = len(filtered)
nodes = filtered
}
// Filter nodes whose name starts with a hidden prefix (#1181). DB rows
// are preserved — this only drops them from the API surface so observer
// history (paths, hops, distances) remains intact for analytics.
if len(s.cfg.HiddenNamePrefixes) > 0 {
filtered := nodes[:0]
for _, node := range nodes {
name, _ := node["name"].(string)
if !s.cfg.IsNameHidden(name) {
filtered = append(filtered, node)
}
}
total = len(filtered)
nodes = filtered
}
// Filter by area
if area := q.Get("area"); area != "" {
var areaNodes map[string]bool
@@ -1405,6 +1419,17 @@ func (s *Server) handleNodeSearch(w http.ResponseWriter, r *http.Request) {
}
nodes = filtered
}
// Drop hidden-prefix nodes from search results (#1181).
if len(s.cfg.HiddenNamePrefixes) > 0 {
filtered := make([]map[string]interface{}, 0, len(nodes))
for _, node := range nodes {
name, _ := node["name"].(string)
if !s.cfg.IsNameHidden(name) {
filtered = append(filtered, node)
}
}
nodes = filtered
}
writeJSON(w, NodeSearchResponse{Nodes: nodes})
}
@@ -1443,7 +1468,13 @@ func (s *Server) handleNodeDetail(w http.ResponseWriter, r *http.Request) {
writeError(w, 404, "Not found")
return
}
// From here on use the canonical pubkey for downstream lookups.
// Hide the node when its name matches an operator-configured prefix
// (#1181). 404 mirrors the blacklist behaviour above — callers learn
// nothing about whether the row exists.
if name, _ := node["name"].(string); s.cfg.IsNameHidden(name) {
writeError(w, 404, "Not found")
return
}
if pk, _ := node["public_key"].(string); pk != "" {
pubkey = pk
}
+2
View File
@@ -3,6 +3,8 @@
"apiKey": "your-secret-api-key-here",
"nodeBlacklist": [],
"_comment_nodeBlacklist": "Public keys of nodes to hide from all API responses. Use for trolls, offensive names, or nodes reporting false data that operators refuse to fix.",
"hiddenNamePrefixes": ["🚫"],
"_comment_hiddenNamePrefixes": "Node name prefixes that mark a node as hidden from this dashboard (#1181). Mirrors a convention used by other MeshCore map dashboards: an operator who wants their node hidden renames it to start with one of these prefixes and sends an advert; the next advert is dropped from /api/nodes, /api/nodes/search and /api/nodes/{pubkey}. DB rows are preserved so observation history (paths, hops, distances) stays intact for analytics. The node is NOT hidden from the mesh itself — only from this dashboard. Set to [] to disable. Default: [\"🚫\"].",
"observerIATAWhitelist": [],
"_comment_observerIATAWhitelist": "Global IATA region whitelist. When non-empty, only observers whose IATA code (from MQTT topic) matches are processed. Case-insensitive. Empty = allow all. Unlike per-source iataFilter, this applies across all MQTT sources.",
"retention": {