mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-07-02 09:51:40 +00:00
8295c2115c
Red commit: 178617ca7b (CI run:
https://github.com/Kpa-clawbot/CoreScope/actions/runs/27191921487 —
red-state was verified locally; CI on this branch runs against green
HEAD per pull_request triggers)
Fixes #1629
## Summary
`/api/nodes/{pubkey}/reach` cached responses survived blacklist
mutations for up to the 5-minute TTL. A node added to `NodeBlacklist`
after a recent reach request was still served the cached non-blacklisted
payload until the entry expired.
## Fix (per triage)
Per @Kpa-clawbot's locked fix path on the issue:
1. Add a monotonic `BlacklistGeneration()` counter on `*Config`.
2. `SetNodeBlacklist` (new setter) atomically replaces the slice,
rebuilds the lookup set under an `RWMutex`, and bumps the generation via
`atomic.AddUint64`.
3. `cmd/server/node_reach.go` folds the generation into the cache key
(`"<pubkey>|<days>|g<gen>"`) so any mutation invalidates prior entries
on the next request — no callbacks bolted onto the setter, no
cache-layer surgery, no TTL change.
While here, the latent bug in `blacklistSet()` is also fixed:
`sync.Once` locked in the initial set, so a later `SetNodeBlacklist` was
invisible to `IsBlacklisted`. The `Once` still gates the lock-free
initial build; mutations rebuild under `RWMutex` and reads take an
`RLock` around the map handoff.
## Files
- `cmd/server/config.go` — `SetNodeBlacklist`, `BlacklistGeneration`,
`rebuildBlacklistSetLocked`, `RWMutex`. `IsBlacklisted` reads the
rebuilt set (no stale-slice short-circuit).
- `cmd/server/node_reach.go` — `cacheKey` includes `|g<gen>`.
- `cmd/server/node_reach_blacklist_cache_test.go` — new regression test
(the red commit).
- `cmd/server/node_reach_endpoint_test.go` — existing cache-hit
assertion updated to the generation-suffixed key.
## TDD evidence
- Red commit `178617ca` adds the test + a deliberate `SetNodeBlacklist`
stub that only reassigns the slice. The test fails on the post-blacklist
assertion: `status=200 want 404 (cached payload was served — #1629)`.
- Green commit `257c104f` replaces the stub with the real
implementation; full `go test ./...` and `go test -race -run
"TestNodeReach|TestNodeBlacklist|TestConfig"` pass locally.
## Scope
- One narrow PR. Backend only — no frontend or API response-shape
change.
- No public type signatures touched beyond the new exported
`SetNodeBlacklist` / `BlacklistGeneration` on `*Config`.
- Preflight: all hard gates pass (PII, branch scope, red commit, CSS,
LIKE/JSON, sync/async migration, XSS).
---------
Co-authored-by: corescope-bot <bot@corescope.local>
Co-authored-by: openclaw-bot <bot@openclaw.local>
313 lines
12 KiB
Go
313 lines
12 KiB
Go
package main
|
|
|
|
import (
|
|
"database/sql"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/gorilla/mux"
|
|
_ "modernc.org/sqlite"
|
|
)
|
|
|
|
func serveReach(srv *Server, path string) *httptest.ResponseRecorder {
|
|
router := mux.NewRouter()
|
|
router.HandleFunc("/api/nodes/{pubkey}/reach", srv.handleNodeReach).Methods("GET")
|
|
req := httptest.NewRequest("GET", path, nil)
|
|
rr := httptest.NewRecorder()
|
|
router.ServeHTTP(rr, req)
|
|
return rr
|
|
}
|
|
|
|
// pk64 pads a short hex stem to a full 64-char lowercase pubkey.
|
|
func pk64(stem string) string { return stem + strings.Repeat("0", 64-len(stem)) }
|
|
|
|
// resetReachState clears the per-server reach caches so test order cannot
|
|
// leak observable state between handler tests (and restores after the test).
|
|
// Now operates on *Server (was package globals — Independent r2 #2); accepts
|
|
// a variadic *Server so existing call sites that didn't pass one still
|
|
// compile but the reset is a no-op (used by tests that build the Server
|
|
// fresh and don't need state cleared).
|
|
func resetReachState(t *testing.T, servers ...*Server) {
|
|
t.Helper()
|
|
clear := func() {
|
|
for _, s := range servers {
|
|
if s == nil {
|
|
continue
|
|
}
|
|
s.reach.cacheMu.Lock()
|
|
s.reach.cache = map[string]reachCacheEntry{}
|
|
s.reach.cacheMu.Unlock()
|
|
s.reach.degreeMu.Lock()
|
|
s.reach.degreeSnap = nil
|
|
s.reach.degreeMu.Unlock()
|
|
}
|
|
}
|
|
clear()
|
|
t.Cleanup(clear)
|
|
}
|
|
|
|
// newReachIntegrationDB builds a complete observer_idx-schema DB with a target
|
|
// node N, two neighbours A/B, and one observation on obsPath so the HTTP handler
|
|
// exercises real directional attribution. Pass a path that omits N's token to
|
|
// build the zero-reach case (identifiable node, no matching observations).
|
|
func newReachIntegrationDB(t *testing.T, obsPath string) (*DB, string) {
|
|
t.Helper()
|
|
conn, err := sql.Open("sqlite", ":memory:")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
n := pk64("01fa") // target — unique 2-byte token "01fa"
|
|
a := pk64("aabb") // predecessor → we hear A
|
|
b := pk64("ccdd") // successor → B hears us
|
|
now := time.Now().Unix()
|
|
stmts := []string{
|
|
`CREATE TABLE nodes (public_key TEXT, name TEXT, role TEXT, lat REAL, lon REAL, last_seen TEXT, first_seen TEXT, advert_count INTEGER)`,
|
|
`CREATE TABLE transmissions (id INTEGER PRIMARY KEY, from_pubkey TEXT, payload_type INTEGER)`,
|
|
`CREATE TABLE observers (id TEXT)`,
|
|
`CREATE TABLE observations (id INTEGER PRIMARY KEY, transmission_id INTEGER, observer_idx INTEGER, snr REAL, path_json TEXT, timestamp INTEGER)`,
|
|
`CREATE TABLE neighbor_edges (node_a TEXT, node_b TEXT, count INTEGER)`,
|
|
}
|
|
for _, s := range stmts {
|
|
if _, err := conn.Exec(s); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
ins := []struct {
|
|
q string
|
|
args []interface{}
|
|
}{
|
|
{`INSERT INTO nodes VALUES (?, 'N', 'repeater', 50.9, 5.4, ?, '2026-06-01T00:00:00Z', 3)`, []interface{}{n, "2026-06-07T00:00:00Z"}},
|
|
{`INSERT INTO nodes VALUES (?, 'A', 'repeater', 51.0, 5.5, ?, '2026-06-01T00:00:00Z', 1)`, []interface{}{a, "2026-06-07T00:00:00Z"}},
|
|
{`INSERT INTO nodes VALUES (?, 'B', 'repeater', 51.1, 5.6, ?, '2026-06-01T00:00:00Z', 1)`, []interface{}{b, "2026-06-07T00:00:00Z"}},
|
|
{`INSERT INTO observers (id) VALUES ('OBS1')`, nil},
|
|
{`INSERT INTO transmissions (id, from_pubkey, payload_type) VALUES (1, '', 5)`, nil},
|
|
{`INSERT INTO observations (id, transmission_id, observer_idx, snr, path_json, timestamp) VALUES (1,1,1,-7.0,?,?)`, []interface{}{obsPath, now}},
|
|
}
|
|
for _, in := range ins {
|
|
if _, err := conn.Exec(in.q, in.args...); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
return &DB{conn: conn, isV3: true}, n
|
|
}
|
|
|
|
func TestClampDays(t *testing.T) {
|
|
cases := []struct{ in, want int }{{0, 1}, {-5, 1}, {1, 1}, {7, 7}, {30, 30}, {31, 30}, {999, 30}}
|
|
for _, c := range cases {
|
|
if got := clampDays(c.in); got != c.want {
|
|
t.Errorf("clampDays(%d)=%d want %d", c.in, got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestNodeReach_UnknownNode(t *testing.T) {
|
|
srv := makeTestServer(makeTestGraph()) // no store/db wired → 404
|
|
rr := serveReach(srv, "/api/nodes/"+pk64("deadbeef")+"/reach")
|
|
if rr.Code != http.StatusNotFound {
|
|
t.Fatalf("status=%d want 404", rr.Code)
|
|
}
|
|
}
|
|
|
|
func TestNodeReach_InvalidPubkey(t *testing.T) {
|
|
srv := makeTestServer(makeTestGraph())
|
|
for _, bad := range []string{"deadbeef", "xyz", pk64("01") + "zz"} {
|
|
rr := serveReach(srv, "/api/nodes/"+bad+"/reach")
|
|
if rr.Code != http.StatusBadRequest {
|
|
t.Errorf("pubkey %q: status=%d want 400", bad, rr.Code)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestNodeReach_ValidPubkeyNotInNodes(t *testing.T) {
|
|
resetReachState(t)
|
|
db := setupTestDBv2(t)
|
|
cfg := &Config{}
|
|
srv := &Server{store: newTestStoreWithDB(t, db, cfg), db: db, cfg: cfg, perfStats: NewPerfStats()}
|
|
// Syntactically valid pubkey that was never inserted → real 404 path.
|
|
rr := serveReach(srv, "/api/nodes/"+pk64("beef")+"/reach")
|
|
if rr.Code != http.StatusNotFound {
|
|
t.Fatalf("status=%d want 404 (body=%s)", rr.Code, rr.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestNodeReach_BlacklistedReturns404(t *testing.T) {
|
|
pk := pk64("01fa")
|
|
cfg := &Config{NodeBlacklist: []string{pk}}
|
|
srv := &Server{cfg: cfg}
|
|
rr := serveReach(srv, "/api/nodes/"+pk+"/reach")
|
|
if rr.Code != http.StatusNotFound {
|
|
t.Fatalf("blacklisted pubkey: status=%d want 404", rr.Code)
|
|
}
|
|
}
|
|
|
|
func TestNodeReach_AttributionAndCacheHit(t *testing.T) {
|
|
resetReachState(t)
|
|
db, n := newReachIntegrationDB(t, `["AABB","01FA","CCDD"]`)
|
|
defer db.conn.Close()
|
|
cfg := &Config{}
|
|
srv := &Server{store: newTestStoreWithDB(t, db, cfg), db: db, cfg: cfg, perfStats: NewPerfStats()}
|
|
|
|
rr := serveReach(srv, "/api/nodes/"+n+"/reach?days=30")
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("status=%d want 200 (body=%s)", rr.Code, rr.Body.String())
|
|
}
|
|
var resp NodeReachResponse
|
|
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("bad json: %v", err)
|
|
}
|
|
if resp.Importance.RelayObservations < 1 {
|
|
t.Fatalf("expected ≥1 relay observation, got %d", resp.Importance.RelayObservations)
|
|
}
|
|
var weHearA, theyHearB bool
|
|
for _, l := range resp.Links {
|
|
if l.Name == "A" && l.WeHear >= 1 {
|
|
weHearA = true
|
|
}
|
|
if l.Name == "B" && l.TheyHear >= 1 {
|
|
theyHearB = true
|
|
}
|
|
}
|
|
if !weHearA {
|
|
t.Errorf("expected we_hear≥1 for neighbour A, links=%+v", resp.Links)
|
|
}
|
|
if !theyHearB {
|
|
t.Errorf("expected they_hear≥1 for neighbour B, links=%+v", resp.Links)
|
|
}
|
|
|
|
// Cache hit: the key (now generation-suffixed, #1629) must be populated
|
|
// and a second request must 200.
|
|
wantKey := n + "|30|g" + strconv.FormatUint(srv.cfg.BlacklistGeneration(), 10)
|
|
if _, ok := srv.reachCacheGet(wantKey); !ok {
|
|
t.Fatalf("expected reach response to be cached under %q", wantKey)
|
|
}
|
|
rr2 := serveReach(srv, "/api/nodes/"+n+"/reach?days=30")
|
|
if rr2.Code != http.StatusOK || rr2.Body.String() != rr.Body.String() {
|
|
t.Fatalf("cache-hit response differs: code=%d", rr2.Code)
|
|
}
|
|
}
|
|
|
|
// Zero-reach happy path: a node that IS identifiable (has reliable tokens) but
|
|
// whose observations contain none of its tokens must return 200 with empty
|
|
// arrays — NOT 404. A wrong implementation that 404s here passes every other
|
|
// test. (docs/api-spec.md contract.)
|
|
func TestNodeReach_ZeroReach(t *testing.T) {
|
|
resetReachState(t)
|
|
db, n := newReachIntegrationDB(t, `["AABB","CCDD"]`) // path omits N's "01FA" token
|
|
defer db.conn.Close()
|
|
cfg := &Config{}
|
|
srv := &Server{store: newTestStoreWithDB(t, db, cfg), db: db, cfg: cfg, perfStats: NewPerfStats()}
|
|
|
|
rr := serveReach(srv, "/api/nodes/"+n+"/reach?days=30")
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("zero-reach must be 200 not 404, got %d (body=%s)", rr.Code, rr.Body.String())
|
|
}
|
|
var resp NodeReachResponse
|
|
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("bad json: %v", err)
|
|
}
|
|
if len(resp.ReliableTokens) == 0 {
|
|
t.Fatalf("node should still be identifiable (reliable tokens present)")
|
|
}
|
|
if len(resp.Links) != 0 || len(resp.DirectObservers) != 0 || resp.Importance.RelayObservations != 0 {
|
|
t.Fatalf("expected empty reach, got links=%d obs=%d relay=%d",
|
|
len(resp.Links), len(resp.DirectObservers), resp.Importance.RelayObservations)
|
|
}
|
|
}
|
|
|
|
func TestNodeReach_ShapeAndClamp(t *testing.T) {
|
|
resetReachState(t)
|
|
db := setupTestDBv2(t)
|
|
const pk = "01fa326b475800a31105abcb9e4cac000b3e5d9e2b5ba0739981ce8d5f3a6754"
|
|
mustExecDB(t, db, `INSERT INTO nodes (public_key, name, role, lat, lon, last_seen, first_seen, advert_count)
|
|
VALUES ('`+pk+`', 'BE-Test', 'repeater', 50.9, 5.4, '2026-06-07T00:00:00Z', '2026-06-01T00:00:00Z', 3)`)
|
|
// scanReachRows joins observations on observer_idx; the v2 schema's
|
|
// observations table lacks that column. Previously the scan error was
|
|
// swallowed (issue #1631) and the test still saw empty arrays. With the
|
|
// fix that returns 500, we rebuild observations to the observer_idx
|
|
// shape (empty — no rows needed for shape/clamp assertions).
|
|
mustExecDB(t, db, `DROP TABLE observations`)
|
|
// PREFLIGHT: async=true reason="test-only in-memory schema rebuild; not a production migration"
|
|
mustExecDB(t, db, `CREATE TABLE observations (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
transmission_id INTEGER,
|
|
observer_idx INTEGER,
|
|
snr REAL,
|
|
path_json TEXT,
|
|
timestamp INTEGER
|
|
)`)
|
|
|
|
cfg := &Config{}
|
|
srv := &Server{store: newTestStoreWithDB(t, db, cfg), db: db, cfg: cfg, perfStats: NewPerfStats()}
|
|
|
|
rr := serveReach(srv, "/api/nodes/"+pk+"/reach?days=999")
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("status=%d want 200 (body=%s)", rr.Code, rr.Body.String())
|
|
}
|
|
var resp NodeReachResponse
|
|
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("bad json: %v", err)
|
|
}
|
|
if resp.Window.Days != 30 {
|
|
t.Fatalf("days not clamped to 30: %d", resp.Window.Days)
|
|
}
|
|
if resp.Links == nil || resp.DirectObservers == nil || resp.ReliableTokens == nil {
|
|
t.Fatalf("array fields must be non-nil (never null)")
|
|
}
|
|
if !contains(resp.ReliableTokens, "01FA") {
|
|
t.Fatalf("expected 01FA reliable token, got %v", resp.ReliableTokens)
|
|
}
|
|
if resp.Node.FirstSeen != "2026-06-01T00:00:00Z" {
|
|
t.Fatalf("first_seen not sourced from nodes table: %q", resp.Node.FirstSeen)
|
|
}
|
|
}
|
|
|
|
// Issue #1631: a DB failure inside scanReachRows must surface as 500, not
|
|
// as a misleading "no reach" 200 or 404. We warm the integration DB, drop
|
|
// the observations table so the next reach scan query fails inside
|
|
// QueryContext, then assert the handler returns 500 (not 200 with empty
|
|
// arrays, which is the buggy current behavior — scanReachRows swallows the
|
|
// error and returns nil).
|
|
func TestNodeReach_ScanDBErrorReturns500(t *testing.T) {
|
|
resetReachState(t)
|
|
db, n := newReachIntegrationDB(t, `["AABB","01FA","CCDD"]`)
|
|
defer db.conn.Close()
|
|
cfg := &Config{}
|
|
srv := &Server{store: newTestStoreWithDB(t, db, cfg), db: db, cfg: cfg, perfStats: NewPerfStats()}
|
|
|
|
// Warm the store's node cache (so buildNodeInfoMap on the failing call
|
|
// still finds the target node). One healthy call also primes the
|
|
// reach response cache — clear it below so the next call recomputes.
|
|
if rr := serveReach(srv, "/api/nodes/"+n+"/reach?days=30"); rr.Code != http.StatusOK {
|
|
t.Fatalf("warm-up call: status=%d want 200 (body=%s)", rr.Code, rr.Body.String())
|
|
}
|
|
srv.reach.cacheMu.Lock()
|
|
srv.reach.cache = map[string]reachCacheEntry{}
|
|
srv.reach.cacheMu.Unlock()
|
|
|
|
// Break the table that scanReachRows reads from. nodes / observers /
|
|
// neighbor_edges remain intact so the failure is isolated to the
|
|
// scanReachRows QueryContext path.
|
|
if _, err := db.conn.Exec("DROP TABLE observations"); err != nil {
|
|
t.Fatalf("drop observations: %v", err)
|
|
}
|
|
|
|
rr := serveReach(srv, "/api/nodes/"+n+"/reach?days=30")
|
|
if rr.Code != http.StatusInternalServerError {
|
|
t.Fatalf("expected 500 on DB error inside scanReachRows, got %d (body=%s)", rr.Code, rr.Body.String())
|
|
}
|
|
}
|
|
|
|
func contains(s []string, v string) bool {
|
|
for _, x := range s {
|
|
if x == v {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|