Files
meshcore-analyzer/cmd/server/channel_message_area_test.go
T
dborup de5cd11d5f feat: self-healing schema-detection flags, closes the startup race class
SaarMesh-Bot flagged on PR #1867 that the server's one-time detectSchema()
PRAGMA snapshot racing the ingestor's ALTER TABLE migrations isn't specific
to configured_scope -- it's a general property of every optional column,
and offered to send a fix. Built it ourselves instead.

hasResolvedPath/hasObsRawHex/hasScopeName/hasDefaultScope/
hasConfiguredScope/hasMultibyteSupCols/hasLastSeen are now schemaFlag
(atomic.Bool) methods instead of plain bools, self-healed by a background
ticker (healSchemaFlags) started once in OpenDB and stopped in Close().
Unlike an eager reprobe-on-read, the healer never nests inside another
caller's already-open *sql.Rows cursor, so it can't self-deadlock a
single-connection pool the way a naive "reprobe inside get()" version of
this fix did (caught immediately by the existing test suite hanging).
detectSchemaWithRetry's fixed 150ms budget is gone -- replaced by an
unbounded self-heal that catches the migration whenever it actually lands.

Every read site across db.go/store.go/chunked_load.go/main.go is now a
method call; test fixtures that previously force-set the old bool fields
now call .forceTrue() on the underlying schemaFlag.
2026-07-27 09:21:34 +02:00

316 lines
12 KiB
Go

package main
import (
"encoding/json"
"net/http/httptest"
"testing"
"time"
)
// TestHandleChannelMessages_EntryPointArea covers dborup's request: a
// channel message needs BOTH the scope it was sent with (already shown as
// "Scope: #dk (Danmark (alle))") AND the area the sender was actually
// physically in, resolved from the message's own path[0] entry-point
// repeater -- distinct because someone in Aarhus can still send with the
// broad #dk scope. Also confirms the internal "entryPrefix" field used to
// carry path[0] through to the resolution step never reaches the client.
func TestHandleChannelMessages_EntryPointArea(t *testing.T) {
srv, router := setupTestServer(t)
if _, err := srv.db.conn.Exec(`DELETE FROM transmissions`); err != nil {
t.Fatalf("clear transmissions: %v", err)
}
if _, err := srv.db.conn.Exec(`DELETE FROM observations`); err != nil {
t.Fatalf("clear observations: %v", err)
}
if !srv.db.hasScopeName() {
if _, err := srv.db.conn.Exec(`ALTER TABLE transmissions ADD COLUMN scope_name TEXT DEFAULT NULL`); err != nil {
t.Fatalf("add scope_name column: %v", err)
}
srv.db.hasScopeNameFlag.forceTrue()
}
f := func(v float64) *float64 { return &v }
srv.cfg.Areas = map[string]AreaEntry{
"AAR": {Label: "Aarhus by", LatMin: f(56.05), LatMax: f(56.25), LonMin: f(9.95), LonMax: f(10.35)},
}
// A repeater physically in Aarhus, resolvable only via a unique
// full-length prefix match (mirrors TestResolveHopsAPI_UniquePrefix).
if _, err := srv.db.conn.Exec("INSERT OR IGNORE INTO nodes (public_key, name, lat, lon, role) VALUES (?, ?, ?, ?, ?)",
"aar0011223344", "AarhusRepeater", 56.1503, 10.1965, "repeater"); err != nil {
t.Fatalf("insert node: %v", err)
}
srv.store.InvalidateNodeCache()
now := time.Now().UTC().Format(time.RFC3339)
res, err := srv.db.conn.Exec(
`INSERT INTO transmissions (raw_hex,hash,first_seen,route_type,payload_type,channel_hash,decoded_json,scope_name) VALUES (?,?,?,0,5,'#dk',?,'#dk')`,
"aa", "chmsg1", now, `{"sender":"AarhusSender","text":"AarhusSender: hey from #dk"}`,
)
if err != nil {
t.Fatalf("insert tx: %v", err)
}
txID, _ := res.LastInsertId()
obsRes, err := srv.db.conn.Exec(`INSERT INTO observers (id, name, iata) VALUES (?,?,?)`, "obsAAR", "AarhusObs", "AAR")
if err != nil {
t.Fatalf("insert observer: %v", err)
}
obsIdx, _ := obsRes.LastInsertId()
if _, err := srv.db.conn.Exec(
`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp) VALUES (?,?,?,?,?,?)`,
txID, obsIdx, 1.0, -90.0, `["aar0011223344"]`, time.Now().Unix(),
); err != nil {
t.Fatalf("insert observation: %v", err)
}
req := httptest.NewRequest("GET", "/api/channels/%23dk/messages?limit=10", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != 200 {
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
}
var body map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
t.Fatalf("decode: %v", err)
}
messages, _ := body["messages"].([]interface{})
if len(messages) != 1 {
t.Fatalf("messages = %+v, want 1", messages)
}
msg, _ := messages[0].(map[string]interface{})
if msg["scope"] != "#dk" {
t.Errorf("scope = %v, want #dk", msg["scope"])
}
if msg["area"] != "Aarhus by" {
t.Errorf("area = %v, want \"Aarhus by\" (resolved from path[0], not the #dk scope)", msg["area"])
}
if _, present := msg["entryPrefix"]; present {
t.Error("entryPrefix must never reach the client -- it's an internal-only intermediate field")
}
}
// TestHandleChannelMessages_EntryPointArea_Unresolved confirms "area" is
// simply omitted (never guessed) when the entry-point prefix doesn't
// resolve unambiguously, or when no areas are configured at all.
func TestHandleChannelMessages_EntryPointArea_Unresolved(t *testing.T) {
srv, router := setupTestServer(t)
if _, err := srv.db.conn.Exec(`DELETE FROM transmissions`); err != nil {
t.Fatalf("clear transmissions: %v", err)
}
if _, err := srv.db.conn.Exec(`DELETE FROM observations`); err != nil {
t.Fatalf("clear observations: %v", err)
}
if !srv.db.hasScopeName() {
if _, err := srv.db.conn.Exec(`ALTER TABLE transmissions ADD COLUMN scope_name TEXT DEFAULT NULL`); err != nil {
t.Fatalf("add scope_name column: %v", err)
}
srv.db.hasScopeNameFlag.forceTrue()
}
// No areas configured at all.
srv.cfg.Areas = nil
now := time.Now().UTC().Format(time.RFC3339)
res, err := srv.db.conn.Exec(
`INSERT INTO transmissions (raw_hex,hash,first_seen,route_type,payload_type,channel_hash,decoded_json,scope_name) VALUES (?,?,?,0,5,'#dk',?,'#dk')`,
"aa", "chmsg2", now, `{"sender":"Someone","text":"Someone: hi"}`,
)
if err != nil {
t.Fatalf("insert tx: %v", err)
}
txID, _ := res.LastInsertId()
obsRes, err := srv.db.conn.Exec(`INSERT INTO observers (id, name, iata) VALUES (?,?,?)`, "obsX", "ObsX", "XXX")
if err != nil {
t.Fatalf("insert observer: %v", err)
}
obsIdx, _ := obsRes.LastInsertId()
if _, err := srv.db.conn.Exec(
`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp) VALUES (?,?,?,?,?,?)`,
txID, obsIdx, 1.0, -90.0, `["deadbeef99"]`, time.Now().Unix(),
); err != nil {
t.Fatalf("insert observation: %v", err)
}
req := httptest.NewRequest("GET", "/api/channels/%23dk/messages?limit=10", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != 200 {
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
}
var body map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
t.Fatalf("decode: %v", err)
}
messages, _ := body["messages"].([]interface{})
if len(messages) != 1 {
t.Fatalf("messages = %+v, want 1", messages)
}
msg, _ := messages[0].(map[string]interface{})
if _, present := msg["area"]; present {
t.Errorf("area = %v, want absent (no areas configured)", msg["area"])
}
if _, present := msg["entryPrefix"]; present {
t.Error("entryPrefix must never reach the client")
}
}
// TestHandleChannelMessages_EntryPointArea_DirectObserverFallback covers a
// 0-hop (direct) reception: there's no relay path at all, so path[0]
// resolution has nothing to work with, but the hearing station's own GPS
// fix is a reasonable stand-in for "where this happened" at 0 hops. The
// observer here has role "client" (not repeater), deliberately proving the
// fallback bypasses the path-hop role filter that buildPrefixMap applies --
// a listening station doesn't need to be relay-eligible to have its own
// position count here.
func TestHandleChannelMessages_EntryPointArea_DirectObserverFallback(t *testing.T) {
srv, router := setupTestServer(t)
if _, err := srv.db.conn.Exec(`DELETE FROM transmissions`); err != nil {
t.Fatalf("clear transmissions: %v", err)
}
if _, err := srv.db.conn.Exec(`DELETE FROM observations`); err != nil {
t.Fatalf("clear observations: %v", err)
}
if !srv.db.hasScopeName() {
if _, err := srv.db.conn.Exec(`ALTER TABLE transmissions ADD COLUMN scope_name TEXT DEFAULT NULL`); err != nil {
t.Fatalf("add scope_name column: %v", err)
}
srv.db.hasScopeNameFlag.forceTrue()
}
f := func(v float64) *float64 { return &v }
srv.cfg.Areas = map[string]AreaEntry{
"DK_DJURS": {Label: "Djursland", LatMin: f(56.10), LatMax: f(56.55), LonMin: f(10.35), LonMax: f(10.90)},
}
// The hearing station itself: a plain client, not a repeater, sitting
// in Djursland.
if _, err := srv.db.conn.Exec("INSERT OR IGNORE INTO nodes (public_key, name, lat, lon, role) VALUES (?, ?, ?, ?, ?)",
"pkebeltoftobserver", "DK_8400_Ebeltoft Observer", 56.1959, 10.6801, "client"); err != nil {
t.Fatalf("insert node: %v", err)
}
srv.store.InvalidateNodeCache()
now := time.Now().UTC().Format(time.RFC3339)
res, err := srv.db.conn.Exec(
`INSERT INTO transmissions (raw_hex,hash,first_seen,route_type,payload_type,channel_hash,decoded_json,scope_name) VALUES (?,?,?,0,5,'#test',?,'#dk')`,
"aa", "chmsgdirect1", now, `{"sender":"HSVI","text":"HSVI: Tak tak"}`,
)
if err != nil {
t.Fatalf("insert tx: %v", err)
}
txID, _ := res.LastInsertId()
obsRes, err := srv.db.conn.Exec(`INSERT INTO observers (id, name, iata) VALUES (?,?,?)`,
"pkebeltoftobserver", "DK_8400_Ebeltoft Observer", "EBT")
if err != nil {
t.Fatalf("insert observer: %v", err)
}
obsIdx, _ := obsRes.LastInsertId()
// path_json '[]' -- direct reception, no relay hops at all.
if _, err := srv.db.conn.Exec(
`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp) VALUES (?,?,?,?,?,?)`,
txID, obsIdx, 12.8, -70.0, `[]`, time.Now().Unix(),
); err != nil {
t.Fatalf("insert observation: %v", err)
}
req := httptest.NewRequest("GET", "/api/channels/%23test/messages?limit=10", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != 200 {
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
}
var body map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
t.Fatalf("decode: %v", err)
}
messages, _ := body["messages"].([]interface{})
if len(messages) != 1 {
t.Fatalf("messages = %+v, want 1", messages)
}
msg, _ := messages[0].(map[string]interface{})
if msg["hops"] != float64(0) {
t.Errorf("hops = %v, want 0 (direct)", msg["hops"])
}
if msg["area"] != "Djursland" {
t.Errorf("area = %v, want \"Djursland\" (resolved from the hearing station's own position, no relay path needed)", msg["area"])
}
if _, present := msg["entryObserverPubkey"]; present {
t.Error("entryObserverPubkey must never reach the client -- it's an internal-only intermediate field")
}
}
// TestHandleChannelMessages_EntryPointArea_DirectObserverFallback_NoGPS
// confirms the fallback stays silent (never guesses) when the hearing
// station of a 0-hop message has no GPS fix of its own on file -- the
// common case, since most observers aren't also positioned mesh nodes.
func TestHandleChannelMessages_EntryPointArea_DirectObserverFallback_NoGPS(t *testing.T) {
srv, router := setupTestServer(t)
if _, err := srv.db.conn.Exec(`DELETE FROM transmissions`); err != nil {
t.Fatalf("clear transmissions: %v", err)
}
if _, err := srv.db.conn.Exec(`DELETE FROM observations`); err != nil {
t.Fatalf("clear observations: %v", err)
}
if !srv.db.hasScopeName() {
if _, err := srv.db.conn.Exec(`ALTER TABLE transmissions ADD COLUMN scope_name TEXT DEFAULT NULL`); err != nil {
t.Fatalf("add scope_name column: %v", err)
}
srv.db.hasScopeNameFlag.forceTrue()
}
f := func(v float64) *float64 { return &v }
srv.cfg.Areas = map[string]AreaEntry{
"DK_DJURS": {Label: "Djursland", LatMin: f(56.20), LatMax: f(56.55), LonMin: f(10.35), LonMax: f(10.90)},
}
// Deliberately no nodes row for the observer -- no GPS fix on file.
now := time.Now().UTC().Format(time.RFC3339)
res, err := srv.db.conn.Exec(
`INSERT INTO transmissions (raw_hex,hash,first_seen,route_type,payload_type,channel_hash,decoded_json,scope_name) VALUES (?,?,?,0,5,'#test',?,'#dk')`,
"aa", "chmsgdirect2", now, `{"sender":"Someone","text":"Someone: hi"}`,
)
if err != nil {
t.Fatalf("insert tx: %v", err)
}
txID, _ := res.LastInsertId()
obsRes, err := srv.db.conn.Exec(`INSERT INTO observers (id, name, iata) VALUES (?,?,?)`,
"pknogps", "No GPS Observer", "XXX")
if err != nil {
t.Fatalf("insert observer: %v", err)
}
obsIdx, _ := obsRes.LastInsertId()
if _, err := srv.db.conn.Exec(
`INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp) VALUES (?,?,?,?,?,?)`,
txID, obsIdx, 5.0, -90.0, `[]`, time.Now().Unix(),
); err != nil {
t.Fatalf("insert observation: %v", err)
}
req := httptest.NewRequest("GET", "/api/channels/%23test/messages?limit=10", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != 200 {
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
}
var body map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
t.Fatalf("decode: %v", err)
}
messages, _ := body["messages"].([]interface{})
if len(messages) != 1 {
t.Fatalf("messages = %+v, want 1", messages)
}
msg, _ := messages[0].(map[string]interface{})
if _, present := msg["area"]; present {
t.Errorf("area = %v, want absent (observer has no GPS fix on file)", msg["area"])
}
if _, present := msg["entryObserverPubkey"]; present {
t.Error("entryObserverPubkey must never reach the client")
}
}