mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-10-15 04:40:05 +00:00
Closes #2065. Master's `🏁 Race detector (ingestor)` job has been red since the pushes at 2026-09-22 21:55 and 21:56. ## Correcting my own diagnosis The issue says the fault is a goroutine outliving its test and racing a later one, and proposes making it joinable. That was wrong, and it matters because it changes the fix: `Close()` **already** waits on `backfillWg` (`cmd/ingestor/db.go`), and `newTestStore` registers it as `t.Cleanup`. The goroutines are joined before the next test starts. The race is inside a single test. `OpenStore` schedules two async migrations — `obs_observer_ts_idx_v1` and `tx_last_seen_backfill_v1` — whose goroutines log while they run. `TestHandleMessageDecodeErrorLog_PII_Issue1211` then points the standard logger at a `bytes.Buffer` and reads it, so its **own** store's migrations write into the buffer it reads: ``` Write by goroutine 760: RunAsyncMigration.func1 async_migration.go:124 (log.Printf) Read by goroutine 757: ...PII_Issue1211 decode_error_log_test.go:37 (buf.String) ``` ## Why the helper rather than the one test These tests capture the standard logger in **21 places across 7 files**. Any of them that also builds a store is exposed to the same thing; the decode-error test is just the one whose timing lost. So `newTestStore` now waits after `OpenStore` instead of only at cleanup, and no test body can run while a migration is in flight. Checked before touching a shared helper: no test references either boot migration by name, and the `pending_async` assertions in `async_migration_test.go` use their own names with a blocking `fn`, so they are unaffected. Cost is a few milliseconds against an empty temp database. ## Tests The race detector only catches this when the scheduler cooperates — it sat latent from 2026-09-03, when those files were last touched, until it surfaced three weeks later, and a re-run would have made it look like a flake. So both new tests are deterministic: - **`TestNewTestStoreWaitsForBootMigrations`** — `tx_last_seen_backfill_v1` is scheduled unconditionally by `OpenStore`, so on a fresh temp database it is pending at that instant and can only read `done` if something waited. Remove the wait and this fails every run. - **`TestCapturedLogIsFreeOfMigrationOutput`** — asserts a captured buffer holds no `[migration/async]` or `[async-migration]` output, which is the failing test's own situation stated as an assertion. ## Verification `gofmt` clean. Go tests were not run locally: no cgo toolchain on this machine since #1992, and per AGENTS.md `CGO_ENABLED=0` links a stub that proves nothing. CI is their first run, and the race-detector job is the one that matters here. ## Not done The decode-error path still logs through the standard logger, so a future test capturing it while any other goroutine logs will race again. An injectable logger would close that class properly. This closes the store-boot case, which is the one that exists today. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1294 lines
44 KiB
Go
1294 lines
44 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/hmac"
|
|
"crypto/sha256"
|
|
"database/sql"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"math"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
mqtt "github.com/eclipse/paho.mqtt.golang"
|
|
)
|
|
|
|
func TestToFloat64(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
input interface{}
|
|
want float64
|
|
wantOK bool
|
|
}{
|
|
{"float64", float64(3.14), 3.14, true},
|
|
{"float32", float32(2.5), 2.5, true},
|
|
{"int", int(42), 42.0, true},
|
|
{"int64", int64(100), 100.0, true},
|
|
{"json.Number valid", json.Number("9.5"), 9.5, true},
|
|
{"json.Number invalid", json.Number("not_a_number"), 0, false},
|
|
{"string valid", "3.14", 3.14, true},
|
|
{"string with spaces", " -7.5 ", -7.5, true},
|
|
{"string integer", "42", 42.0, true},
|
|
{"string invalid", "hello", 0, false},
|
|
{"string empty", "", 0, false},
|
|
{"uint", uint(10), 10.0, true},
|
|
{"uint64", uint64(999), 999.0, true},
|
|
{"bool unsupported", true, 0, false},
|
|
{"nil unsupported", nil, 0, false},
|
|
{"slice unsupported", []int{1}, 0, false},
|
|
{"float64 zero", float64(0), 0.0, true},
|
|
{"float64 negative", float64(-5.5), -5.5, true},
|
|
{"int64 large", int64(math.MaxInt32), float64(math.MaxInt32), true},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got, ok := toFloat64(tt.input)
|
|
if ok != tt.wantOK {
|
|
t.Errorf("toFloat64(%v) ok=%v, want %v", tt.input, ok, tt.wantOK)
|
|
}
|
|
if ok && got != tt.want {
|
|
t.Errorf("toFloat64(%v) = %v, want %v", tt.input, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestFirstNonEmpty(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
args []string
|
|
want string
|
|
}{
|
|
{"all empty", []string{"", "", ""}, ""},
|
|
{"first non-empty", []string{"", "hello", "world"}, "hello"},
|
|
{"first value", []string{"first", "second"}, "first"},
|
|
{"single empty", []string{""}, ""},
|
|
{"single value", []string{"only"}, "only"},
|
|
{"no args", nil, ""},
|
|
{"empty then value", []string{"", "", "last"}, "last"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := firstNonEmpty(tt.args...)
|
|
if got != tt.want {
|
|
t.Errorf("firstNonEmpty(%v) = %q, want %q", tt.args, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestUnixTime(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
epoch int64
|
|
want time.Time
|
|
}{
|
|
{"zero epoch", 0, time.Unix(0, 0)},
|
|
{"known date", 1700000000, time.Unix(1700000000, 0)},
|
|
{"negative epoch", -1, time.Unix(-1, 0)},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := unixTime(tt.epoch)
|
|
if !got.Equal(tt.want) {
|
|
t.Errorf("unixTime(%d) = %v, want %v", tt.epoch, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// mockMessage implements mqtt.Message for testing handleMessage
|
|
type mockMessage struct {
|
|
topic string
|
|
payload []byte
|
|
retained bool
|
|
}
|
|
|
|
func (m *mockMessage) Duplicate() bool { return false }
|
|
func (m *mockMessage) Qos() byte { return 0 }
|
|
func (m *mockMessage) Retained() bool { return m.retained }
|
|
func (m *mockMessage) Topic() string { return m.topic }
|
|
func (m *mockMessage) MessageID() uint16 { return 0 }
|
|
func (m *mockMessage) Payload() []byte { return m.payload }
|
|
func (m *mockMessage) Ack() {}
|
|
|
|
func newTestStore(t *testing.T) *Store {
|
|
t.Helper()
|
|
dir := t.TempDir()
|
|
dbPath := dir + "/test.db"
|
|
s, err := OpenStore(dbPath)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// OpenStore schedules two async migrations (db.go: obs_observer_ts_idx_v1
|
|
// and tx_last_seen_backfill_v1), and their goroutines log while they run.
|
|
// Twenty-one places in these tests capture output by pointing the standard
|
|
// logger at a bytes.Buffer, so a test that does so while its OWN store is
|
|
// still migrating has two goroutines on one buffer with no synchronisation.
|
|
// That is the data race the race detector caught on master (#2065): the
|
|
// writer was RunAsyncMigration's goroutine, the reader
|
|
// TestHandleMessageDecodeErrorLog_PII_Issue1211 reading buf.String().
|
|
//
|
|
// Close() already waits, so the goroutines never outlive the test; the race
|
|
// is inside one test, not across them. Waiting here rather than only at
|
|
// cleanup means no test body can observe a migration in flight, which
|
|
// closes the whole class rather than the one test that happened to fail.
|
|
//
|
|
// Cost is a few milliseconds against an empty temp DB. Pinned by
|
|
// TestNewTestStoreWaitsForBootMigrations.
|
|
s.WaitForAsyncMigrations()
|
|
t.Cleanup(func() { s.Close() })
|
|
return s
|
|
}
|
|
|
|
func TestHandleMessageRawPacket(t *testing.T) {
|
|
store := newTestStore(t)
|
|
source := MQTTSource{Name: "test"}
|
|
|
|
rawHex := "0A00D69FD7A5A7475DB07337749AE61FA53A4788E976"
|
|
payload := []byte(`{"raw":"` + rawHex + `","SNR":5.5,"RSSI":-100.0,"origin":"myobs"}`)
|
|
msg := &mockMessage{topic: "meshcore/SJC/obs1/packets", payload: payload}
|
|
|
|
handleMessage(store, "test", source, msg, nil, nil, &Config{})
|
|
|
|
var count int
|
|
store.db.QueryRow("SELECT COUNT(*) FROM transmissions").Scan(&count)
|
|
if count != 1 {
|
|
t.Errorf("transmissions count=%d, want 1", count)
|
|
}
|
|
}
|
|
|
|
func TestHandleMessageRawPacketAdvert(t *testing.T) {
|
|
store := newTestStore(t)
|
|
source := MQTTSource{Name: "test"}
|
|
|
|
rawHex := "120046D62DE27D4C5194D7821FC5A34A45565DCC2537B300B9AB6275255CEFB65D840CE5C169C94C9AED39E8BCB6CB6EB0335497A198B33A1A610CD3B03D8DCFC160900E5244280323EE0B44CACAB8F02B5B38B91CFA18BD067B0B5E63E94CFC85F758A8530B9240933402E0E6B8F84D5252322D52"
|
|
payload := []byte(`{"raw":"` + rawHex + `"}`)
|
|
msg := &mockMessage{topic: "meshcore/SJC/obs1/packets", payload: payload}
|
|
|
|
handleMessage(store, "test", source, msg, nil, nil, &Config{})
|
|
|
|
// Should create a node from the ADVERT
|
|
var count int
|
|
store.db.QueryRow("SELECT COUNT(*) FROM nodes").Scan(&count)
|
|
if count != 1 {
|
|
t.Errorf("nodes count=%d, want 1 (advert should upsert node)", count)
|
|
}
|
|
|
|
// Should create observer
|
|
store.db.QueryRow("SELECT COUNT(*) FROM observers").Scan(&count)
|
|
if count != 1 {
|
|
t.Errorf("observers count=%d, want 1", count)
|
|
}
|
|
}
|
|
|
|
func TestHandleMessageInvalidJSON(t *testing.T) {
|
|
store := newTestStore(t)
|
|
source := MQTTSource{Name: "test"}
|
|
msg := &mockMessage{topic: "meshcore/SJC/obs1/packets", payload: []byte(`not json`)}
|
|
|
|
// Should not panic
|
|
handleMessage(store, "test", source, msg, nil, nil, &Config{})
|
|
|
|
var count int
|
|
store.db.QueryRow("SELECT COUNT(*) FROM transmissions").Scan(&count)
|
|
if count != 0 {
|
|
t.Error("invalid JSON should not insert")
|
|
}
|
|
}
|
|
|
|
func TestHandleMessageStatusTopic(t *testing.T) {
|
|
store := newTestStore(t)
|
|
source := MQTTSource{Name: "test"}
|
|
msg := &mockMessage{
|
|
topic: "meshcore/SJC/obs1/status",
|
|
payload: []byte(`{"origin":"MyObserver"}`),
|
|
}
|
|
|
|
handleMessage(store, "test", source, msg, nil, nil, &Config{})
|
|
|
|
var name, iata string
|
|
err := store.db.QueryRow("SELECT name, iata FROM observers WHERE id = 'obs1'").Scan(&name, &iata)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if name != "MyObserver" {
|
|
t.Errorf("name=%s, want MyObserver", name)
|
|
}
|
|
if iata != "SJC" {
|
|
t.Errorf("iata=%s, want SJC", iata)
|
|
}
|
|
}
|
|
|
|
func TestHandleMessageSkipStatusTopics(t *testing.T) {
|
|
store := newTestStore(t)
|
|
source := MQTTSource{Name: "test"}
|
|
|
|
// meshcore/status should be skipped
|
|
msg1 := &mockMessage{topic: "meshcore/status", payload: []byte(`{"raw":"0A00"}`)}
|
|
handleMessage(store, "test", source, msg1, nil, nil, &Config{})
|
|
|
|
// meshcore/events/connection should be skipped
|
|
msg2 := &mockMessage{topic: "meshcore/events/connection", payload: []byte(`{"raw":"0A00"}`)}
|
|
handleMessage(store, "test", source, msg2, nil, nil, &Config{})
|
|
|
|
var count int
|
|
store.db.QueryRow("SELECT COUNT(*) FROM transmissions").Scan(&count)
|
|
if count != 0 {
|
|
t.Error("status/connection topics should be skipped")
|
|
}
|
|
}
|
|
|
|
func TestHandleMessageIATAFilter(t *testing.T) {
|
|
store := newTestStore(t)
|
|
source := MQTTSource{Name: "test", IATAFilter: []string{"LAX"}}
|
|
|
|
rawHex := "0A00D69FD7A5A7475DB07337749AE61FA53A4788E976"
|
|
// SJC is not in filter, should be skipped
|
|
msg := &mockMessage{
|
|
topic: "meshcore/SJC/obs1/packets",
|
|
payload: []byte(`{"raw":"` + rawHex + `"}`),
|
|
}
|
|
handleMessage(store, "test", source, msg, nil, nil, &Config{})
|
|
|
|
var count int
|
|
store.db.QueryRow("SELECT COUNT(*) FROM transmissions").Scan(&count)
|
|
if count != 0 {
|
|
t.Error("IATA filter should skip non-matching regions")
|
|
}
|
|
|
|
// LAX is in filter, should be accepted
|
|
msg2 := &mockMessage{
|
|
topic: "meshcore/LAX/obs2/packets",
|
|
payload: []byte(`{"raw":"` + rawHex + `"}`),
|
|
}
|
|
handleMessage(store, "test", source, msg2, nil, nil, &Config{})
|
|
|
|
store.db.QueryRow("SELECT COUNT(*) FROM transmissions").Scan(&count)
|
|
if count != 1 {
|
|
t.Errorf("IATA filter should allow matching region, got count=%d", count)
|
|
}
|
|
}
|
|
|
|
func TestHandleMessageIATAFilterNoRegion(t *testing.T) {
|
|
store := newTestStore(t)
|
|
source := MQTTSource{Name: "test", IATAFilter: []string{"LAX"}}
|
|
|
|
rawHex := "0A00D69FD7A5A7475DB07337749AE61FA53A4788E976"
|
|
// topic with only 1 part — no region to filter on
|
|
msg := &mockMessage{
|
|
topic: "meshcore",
|
|
payload: []byte(`{"raw":"` + rawHex + `"}`),
|
|
}
|
|
handleMessage(store, "test", source, msg, nil, nil, &Config{})
|
|
|
|
// No region part → filter doesn't apply, message goes through
|
|
// Actually the code checks len(parts) > 1 for IATA filter
|
|
// Without > 1 parts, the filter is skipped and the message proceeds
|
|
}
|
|
|
|
func TestHandleMessageNoRawHex(t *testing.T) {
|
|
store := newTestStore(t)
|
|
source := MQTTSource{Name: "test"}
|
|
|
|
// Valid JSON but no "raw" field → falls through to "other formats"
|
|
msg := &mockMessage{
|
|
topic: "meshcore/SJC/obs1/packets",
|
|
payload: []byte(`{"type":"companion","data":"something"}`),
|
|
}
|
|
handleMessage(store, "test", source, msg, nil, nil, &Config{})
|
|
|
|
var count int
|
|
store.db.QueryRow("SELECT COUNT(*) FROM transmissions").Scan(&count)
|
|
if count != 0 {
|
|
t.Error("no raw hex should not insert")
|
|
}
|
|
}
|
|
|
|
func TestHandleMessageBadRawHex(t *testing.T) {
|
|
store := newTestStore(t)
|
|
source := MQTTSource{Name: "test"}
|
|
|
|
// Invalid hex → decode error
|
|
msg := &mockMessage{
|
|
topic: "meshcore/SJC/obs1/packets",
|
|
payload: []byte(`{"raw":"ZZZZ"}`),
|
|
}
|
|
handleMessage(store, "test", source, msg, nil, nil, &Config{})
|
|
|
|
var count int
|
|
store.db.QueryRow("SELECT COUNT(*) FROM transmissions").Scan(&count)
|
|
if count != 0 {
|
|
t.Error("bad hex should not insert")
|
|
}
|
|
}
|
|
|
|
func TestHandleMessageWithSNRRSSIAsNumbers(t *testing.T) {
|
|
store := newTestStore(t)
|
|
source := MQTTSource{Name: "test"}
|
|
|
|
rawHex := "0A00D69FD7A5A7475DB07337749AE61FA53A4788E976"
|
|
payload := []byte(`{"raw":"` + rawHex + `","SNR":7.2,"RSSI":-95}`)
|
|
msg := &mockMessage{topic: "meshcore/SJC/obs1/packets", payload: payload}
|
|
|
|
handleMessage(store, "test", source, msg, nil, nil, &Config{})
|
|
|
|
var snr, rssi *float64
|
|
store.db.QueryRow("SELECT snr, rssi FROM observations LIMIT 1").Scan(&snr, &rssi)
|
|
if snr == nil || *snr != 7.2 {
|
|
t.Errorf("snr=%v, want 7.2", snr)
|
|
}
|
|
}
|
|
|
|
func TestHandleMessageMinimalTopic(t *testing.T) {
|
|
store := newTestStore(t)
|
|
source := MQTTSource{Name: "test"}
|
|
|
|
rawHex := "0A00D69FD7A5A7475DB07337749AE61FA53A4788E976"
|
|
// Topic with only 2 parts: meshcore/region (no observer ID)
|
|
msg := &mockMessage{
|
|
topic: "meshcore/SJC",
|
|
payload: []byte(`{"raw":"` + rawHex + `"}`),
|
|
}
|
|
handleMessage(store, "test", source, msg, nil, nil, &Config{})
|
|
|
|
var count int
|
|
store.db.QueryRow("SELECT COUNT(*) FROM transmissions").Scan(&count)
|
|
if count != 1 {
|
|
t.Errorf("should insert even with short topic, got count=%d", count)
|
|
}
|
|
}
|
|
|
|
func TestHandleMessageCorruptedAdvert(t *testing.T) {
|
|
store := newTestStore(t)
|
|
source := MQTTSource{Name: "test"}
|
|
|
|
// An ADVERT that's too short to be valid — decoded but fails ValidateAdvert
|
|
// header 0x12 = FLOOD+ADVERT, path 0x00 = 0 hops
|
|
// Then a short payload that decodeAdvert will mark as "too short for advert"
|
|
rawHex := "1200" + "AABBCCDD"
|
|
msg := &mockMessage{
|
|
topic: "meshcore/SJC/obs1/packets",
|
|
payload: []byte(`{"raw":"` + rawHex + `"}`),
|
|
}
|
|
handleMessage(store, "test", source, msg, nil, nil, &Config{})
|
|
|
|
// Transmission should be inserted (even if advert is invalid)
|
|
var count int
|
|
store.db.QueryRow("SELECT COUNT(*) FROM transmissions").Scan(&count)
|
|
if count != 1 {
|
|
t.Errorf("transmission should be inserted even with corrupted advert, got %d", count)
|
|
}
|
|
|
|
// But no node should be created
|
|
store.db.QueryRow("SELECT COUNT(*) FROM nodes").Scan(&count)
|
|
if count != 0 {
|
|
t.Error("corrupted advert should not create a node")
|
|
}
|
|
}
|
|
|
|
func TestHandleMessageNoObserverID(t *testing.T) {
|
|
store := newTestStore(t)
|
|
source := MQTTSource{Name: "test"}
|
|
|
|
rawHex := "0A00D69FD7A5A7475DB07337749AE61FA53A4788E976"
|
|
// Topic with only 1 part — no observer
|
|
msg := &mockMessage{
|
|
topic: "packets",
|
|
payload: []byte(`{"raw":"` + rawHex + `","origin":"obs1"}`),
|
|
}
|
|
handleMessage(store, "test", source, msg, nil, nil, &Config{})
|
|
|
|
var count int
|
|
store.db.QueryRow("SELECT COUNT(*) FROM transmissions").Scan(&count)
|
|
if count != 1 {
|
|
t.Errorf("count=%d, want 1", count)
|
|
}
|
|
// No observer should be upserted since observerID is empty
|
|
store.db.QueryRow("SELECT COUNT(*) FROM observers").Scan(&count)
|
|
if count != 0 {
|
|
t.Error("no observer should be created when observerID is empty")
|
|
}
|
|
}
|
|
|
|
func TestHandleMessageSNRNotFloat(t *testing.T) {
|
|
store := newTestStore(t)
|
|
source := MQTTSource{Name: "test"}
|
|
|
|
rawHex := "0A00D69FD7A5A7475DB07337749AE61FA53A4788E976"
|
|
// SNR as a string value — should not parse as float
|
|
payload := []byte(`{"raw":"` + rawHex + `","SNR":"bad","RSSI":"bad"}`)
|
|
msg := &mockMessage{topic: "meshcore/SJC/obs1/packets", payload: payload}
|
|
handleMessage(store, "test", source, msg, nil, nil, &Config{})
|
|
|
|
var count int
|
|
store.db.QueryRow("SELECT COUNT(*) FROM transmissions").Scan(&count)
|
|
if count != 1 {
|
|
t.Error("should still insert even with bad SNR/RSSI")
|
|
}
|
|
}
|
|
|
|
func TestHandleMessageOriginExtraction(t *testing.T) {
|
|
store := newTestStore(t)
|
|
source := MQTTSource{Name: "test"}
|
|
|
|
rawHex := "0A00D69FD7A5A7475DB07337749AE61FA53A4788E976"
|
|
payload := []byte(`{"raw":"` + rawHex + `","origin":"MyOrigin"}`)
|
|
msg := &mockMessage{topic: "meshcore/SJC/obs1/packets", payload: payload}
|
|
handleMessage(store, "test", source, msg, nil, nil, &Config{})
|
|
|
|
// Verify origin was extracted to observer name
|
|
var name string
|
|
store.db.QueryRow("SELECT name FROM observers WHERE id = 'obs1'").Scan(&name)
|
|
if name != "MyOrigin" {
|
|
t.Errorf("observer name=%s, want MyOrigin", name)
|
|
}
|
|
}
|
|
|
|
func TestHandleMessagePanicRecovery(t *testing.T) {
|
|
// Close the store to cause panics on prepared statement use
|
|
store := newTestStore(t)
|
|
store.Close()
|
|
|
|
source := MQTTSource{Name: "test"}
|
|
rawHex := "0A00D69FD7A5A7475DB07337749AE61FA53A4788E976"
|
|
msg := &mockMessage{
|
|
topic: "meshcore/SJC/obs1/packets",
|
|
payload: []byte(`{"raw":"` + rawHex + `"}`),
|
|
}
|
|
|
|
// Should not panic — the defer/recover should catch it
|
|
handleMessage(store, "test", source, msg, nil, nil, &Config{})
|
|
}
|
|
|
|
func TestHandleMessageStatusOriginFallback(t *testing.T) {
|
|
store := newTestStore(t)
|
|
source := MQTTSource{Name: "test"}
|
|
|
|
// Status topic without origin field
|
|
msg := &mockMessage{
|
|
topic: "meshcore/SJC/obs1/status",
|
|
payload: []byte(`{"type":"status"}`),
|
|
}
|
|
handleMessage(store, "test", source, msg, nil, nil, &Config{})
|
|
|
|
var name string
|
|
err := store.db.QueryRow("SELECT name FROM observers WHERE id = 'obs1'").Scan(&name)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// firstNonEmpty with empty name should use observerID as fallback in log
|
|
// The observer should still be inserted
|
|
}
|
|
|
|
func TestEpochToISO(t *testing.T) {
|
|
// epoch 0 → 1970-01-01
|
|
iso := epochToISO(0)
|
|
if iso != "1970-01-01T00:00:00.000Z" {
|
|
t.Errorf("epochToISO(0) = %s, want 1970-01-01T00:00:00.000Z", iso)
|
|
}
|
|
|
|
// Known timestamp
|
|
iso2 := epochToISO(1700000000)
|
|
if iso2 == "" {
|
|
t.Error("epochToISO should return non-empty string")
|
|
}
|
|
}
|
|
|
|
func TestAdvertRole(t *testing.T) {
|
|
// advertRole now keys off AdvertFlags.Type (firmware ADV_TYPE_*) — see
|
|
// firmware/src/helpers/AdvertDataHelpers.h:7-12 and issue #1279 P1 #3.
|
|
tests := []struct {
|
|
name string
|
|
flags *AdvertFlags
|
|
want string
|
|
}{
|
|
{"none (type 0)", &AdvertFlags{Type: 0}, "none"},
|
|
{"companion (type 1)", &AdvertFlags{Type: 1, Chat: true}, "companion"},
|
|
{"repeater (type 2)", &AdvertFlags{Type: 2, Repeater: true}, "repeater"},
|
|
{"room (type 3)", &AdvertFlags{Type: 3, Room: true}, "room"},
|
|
{"sensor (type 4)", &AdvertFlags{Type: 4, Sensor: true}, "sensor"},
|
|
{"future type-5", &AdvertFlags{Type: 5}, "type-5"},
|
|
{"nil flags falls back to companion", nil, "companion"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := advertRole(tt.flags)
|
|
if got != tt.want {
|
|
t.Errorf("advertRole(%+v) = %s, want %s", tt.flags, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestDeriveHashtagChannelKey(t *testing.T) {
|
|
// Test vectors validated against Node.js server-helpers.js
|
|
tests := []struct {
|
|
name string
|
|
want string
|
|
}{
|
|
{"#General", "649af2cab73ed5a890890a5485a0c004"},
|
|
{"#test", "9cd8fcf22a47333b591d96a2b848b73f"},
|
|
{"#MeshCore", "dcf73f393fa217f6b28fcec6ffc411ad"},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := deriveHashtagChannelKey(tt.name)
|
|
if got != tt.want {
|
|
t.Errorf("deriveHashtagChannelKey(%q) = %q, want %q", tt.name, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
|
|
// Deterministic
|
|
k1 := deriveHashtagChannelKey("#foo")
|
|
k2 := deriveHashtagChannelKey("#foo")
|
|
if k1 != k2 {
|
|
t.Error("deriveHashtagChannelKey should be deterministic")
|
|
}
|
|
|
|
// Returns 32-char hex string (16 bytes)
|
|
if len(k1) != 32 {
|
|
t.Errorf("key length = %d, want 32", len(k1))
|
|
}
|
|
|
|
// Different inputs → different keys
|
|
k3 := deriveHashtagChannelKey("#bar")
|
|
if k1 == k3 {
|
|
t.Error("different inputs should produce different keys")
|
|
}
|
|
}
|
|
|
|
func TestLoadChannelKeysMergePriority(t *testing.T) {
|
|
dir := t.TempDir()
|
|
cfgPath := filepath.Join(dir, "config.json")
|
|
|
|
// Create a rainbow file with two keys: #rainbow (unique) and #override (to be overridden)
|
|
rainbowPath := filepath.Join(dir, "channel-rainbow.json")
|
|
t.Setenv("CHANNEL_KEYS_PATH", rainbowPath)
|
|
rainbow := map[string]string{
|
|
"#rainbow": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
|
"#override": "rainbow_value_should_be_overridden",
|
|
}
|
|
rainbowJSON, err := json.Marshal(rainbow)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(rainbowPath, rainbowJSON, 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
cfg := &Config{
|
|
HashChannels: []string{"General", "#override"},
|
|
ChannelKeys: map[string]string{"#override": "explicit_wins"},
|
|
}
|
|
|
|
keys := loadChannelKeys(cfg, cfgPath)
|
|
|
|
// Rainbow key loaded
|
|
if keys["#rainbow"] != "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" {
|
|
t.Errorf("rainbow key missing or wrong: %q", keys["#rainbow"])
|
|
}
|
|
|
|
// HashChannels derived #General
|
|
expected := deriveHashtagChannelKey("#General")
|
|
if keys["#General"] != expected {
|
|
t.Errorf("#General = %q, want %q (derived)", keys["#General"], expected)
|
|
}
|
|
|
|
// Explicit config wins over both rainbow and derived
|
|
if keys["#override"] != "explicit_wins" {
|
|
t.Errorf("#override = %q, want explicit_wins", keys["#override"])
|
|
}
|
|
}
|
|
|
|
func TestLoadChannelKeysHashChannelsNormalization(t *testing.T) {
|
|
t.Setenv("CHANNEL_KEYS_PATH", "")
|
|
dir := t.TempDir()
|
|
cfgPath := filepath.Join(dir, "config.json")
|
|
|
|
cfg := &Config{
|
|
HashChannels: []string{
|
|
"NoPound", // should become #NoPound
|
|
"#HasPound", // stays #HasPound
|
|
" Spaced ", // trimmed → #Spaced
|
|
"", // skipped
|
|
},
|
|
}
|
|
|
|
keys := loadChannelKeys(cfg, cfgPath)
|
|
|
|
if _, ok := keys["#NoPound"]; !ok {
|
|
t.Error("should derive key for #NoPound (auto-prefixed)")
|
|
}
|
|
if _, ok := keys["#HasPound"]; !ok {
|
|
t.Error("should derive key for #HasPound")
|
|
}
|
|
if _, ok := keys["#Spaced"]; !ok {
|
|
t.Error("should derive key for #Spaced (trimmed)")
|
|
}
|
|
// 3 derived + builtins (Public)
|
|
expected := 3 + len(builtinChannelKeys())
|
|
if len(keys) != expected {
|
|
t.Errorf("expected %d keys, got %d", expected, len(keys))
|
|
}
|
|
}
|
|
|
|
// Default Public channel must always be present from the built-in floor,
|
|
// regardless of whether a rainbow file is provided.
|
|
func TestLoadChannelKeysBuiltinPublic(t *testing.T) {
|
|
t.Setenv("CHANNEL_KEYS_PATH", "")
|
|
dir := t.TempDir()
|
|
cfgPath := filepath.Join(dir, "config.json")
|
|
cfg := &Config{}
|
|
|
|
keys := loadChannelKeys(cfg, cfgPath)
|
|
|
|
if got := keys["Public"]; got != "8b3387e9c5cdea6ac9e5edbaa115cd72" {
|
|
t.Errorf("Public key = %q, want firmware-default 8b3387e9c5cdea6ac9e5edbaa115cd72", got)
|
|
}
|
|
}
|
|
|
|
// Explicit config and rainbow entries must still override the built-in floor.
|
|
func TestLoadChannelKeysBuiltinOverridable(t *testing.T) {
|
|
t.Setenv("CHANNEL_KEYS_PATH", "")
|
|
dir := t.TempDir()
|
|
cfgPath := filepath.Join(dir, "config.json")
|
|
cfg := &Config{
|
|
ChannelKeys: map[string]string{"Public": "deadbeefdeadbeefdeadbeefdeadbeef"},
|
|
}
|
|
|
|
keys := loadChannelKeys(cfg, cfgPath)
|
|
|
|
if got := keys["Public"]; got != "deadbeefdeadbeefdeadbeefdeadbeef" {
|
|
t.Errorf("Public key = %q, want explicit override deadbeef...", got)
|
|
}
|
|
}
|
|
|
|
func TestLoadChannelKeysSkipExplicit(t *testing.T) {
|
|
t.Setenv("CHANNEL_KEYS_PATH", "")
|
|
dir := t.TempDir()
|
|
cfgPath := filepath.Join(dir, "config.json")
|
|
|
|
cfg := &Config{
|
|
HashChannels: []string{"General"},
|
|
ChannelKeys: map[string]string{"#General": "my_explicit_key"},
|
|
}
|
|
|
|
keys := loadChannelKeys(cfg, cfgPath)
|
|
|
|
// Explicit key should win — hashChannels derivation should be skipped
|
|
if keys["#General"] != "my_explicit_key" {
|
|
t.Errorf("#General = %q, want my_explicit_key", keys["#General"])
|
|
}
|
|
}
|
|
|
|
// --- Bug #321: SNR/RSSI case-insensitive fallback ---
|
|
|
|
func TestHandleMessageWithLowercaseSNRRSSI(t *testing.T) {
|
|
store := newTestStore(t)
|
|
source := MQTTSource{Name: "test"}
|
|
|
|
rawHex := "0A00D69FD7A5A7475DB07337749AE61FA53A4788E976"
|
|
payload := []byte(`{"raw":"` + rawHex + `","snr":5.5,"rssi":-102}`)
|
|
msg := &mockMessage{topic: "meshcore/SJC/obs1/packets", payload: payload}
|
|
|
|
handleMessage(store, "test", source, msg, nil, nil, &Config{})
|
|
|
|
var snr, rssi *float64
|
|
store.db.QueryRow("SELECT snr, rssi FROM observations LIMIT 1").Scan(&snr, &rssi)
|
|
if snr == nil || *snr != 5.5 {
|
|
t.Errorf("snr=%v, want 5.5 (lowercase key)", snr)
|
|
}
|
|
if rssi == nil || *rssi != -102 {
|
|
t.Errorf("rssi=%v, want -102 (lowercase key)", rssi)
|
|
}
|
|
}
|
|
|
|
func TestHandleMessageSNRRSSIUppercaseWins(t *testing.T) {
|
|
store := newTestStore(t)
|
|
source := MQTTSource{Name: "test"}
|
|
|
|
// Both uppercase and lowercase present — uppercase should take precedence
|
|
rawHex := "0A00D69FD7A5A7475DB07337749AE61FA53A4788E976"
|
|
payload := []byte(`{"raw":"` + rawHex + `","SNR":7.2,"snr":1.0,"RSSI":-95,"rssi":-50}`)
|
|
msg := &mockMessage{topic: "meshcore/SJC/obs1/packets", payload: payload}
|
|
|
|
handleMessage(store, "test", source, msg, nil, nil, &Config{})
|
|
|
|
var snr, rssi *float64
|
|
store.db.QueryRow("SELECT snr, rssi FROM observations LIMIT 1").Scan(&snr, &rssi)
|
|
if snr == nil || *snr != 7.2 {
|
|
t.Errorf("snr=%v, want 7.2 (uppercase should take precedence)", snr)
|
|
}
|
|
if rssi == nil || *rssi != -95 {
|
|
t.Errorf("rssi=%v, want -95 (uppercase should take precedence)", rssi)
|
|
}
|
|
}
|
|
|
|
func TestHandleMessageNoSNRRSSI(t *testing.T) {
|
|
store := newTestStore(t)
|
|
source := MQTTSource{Name: "test"}
|
|
|
|
rawHex := "0A00D69FD7A5A7475DB07337749AE61FA53A4788E976"
|
|
payload := []byte(`{"raw":"` + rawHex + `"}`)
|
|
msg := &mockMessage{topic: "meshcore/SJC/obs1/packets", payload: payload}
|
|
|
|
handleMessage(store, "test", source, msg, nil, nil, &Config{})
|
|
|
|
var snr, rssi *float64
|
|
store.db.QueryRow("SELECT snr, rssi FROM observations LIMIT 1").Scan(&snr, &rssi)
|
|
if snr != nil {
|
|
t.Errorf("snr should be nil when not present, got %v", *snr)
|
|
}
|
|
if rssi != nil {
|
|
t.Errorf("rssi should be nil when not present, got %v", *rssi)
|
|
}
|
|
}
|
|
|
|
func TestStripUnitSuffix(t *testing.T) {
|
|
tests := []struct {
|
|
input, want string
|
|
}{
|
|
{"-110dBm", "-110"},
|
|
{"-110DBM", "-110"},
|
|
{"5.5dB", "5.5"},
|
|
{"100mW", "100"},
|
|
{"1.5km", "1.5"},
|
|
{"500m", "500"},
|
|
{"10mi", "10"},
|
|
{"42", "42"},
|
|
{"", ""},
|
|
{"hello", "hello"},
|
|
}
|
|
for _, tt := range tests {
|
|
got := stripUnitSuffix(tt.input)
|
|
if got != tt.want {
|
|
t.Errorf("stripUnitSuffix(%q) = %q, want %q", tt.input, got, tt.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestToFloat64WithUnits(t *testing.T) {
|
|
tests := []struct {
|
|
input interface{}
|
|
want float64
|
|
ok bool
|
|
}{
|
|
{"-110dBm", -110.0, true},
|
|
{"5.5dB", 5.5, true},
|
|
{"100mW", 100.0, true},
|
|
{"-85.3dBm", -85.3, true},
|
|
{"42", 42.0, true},
|
|
{"not_a_number", 0, false},
|
|
}
|
|
for _, tt := range tests {
|
|
got, ok := toFloat64(tt.input)
|
|
if ok != tt.ok {
|
|
t.Errorf("toFloat64(%v) ok=%v, want %v", tt.input, ok, tt.ok)
|
|
}
|
|
if ok && got != tt.want {
|
|
t.Errorf("toFloat64(%v) = %v, want %v", tt.input, got, tt.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestIATAFilterDoesNotDropStatusMessages verifies that status messages from
|
|
// out-of-region observers are still processed (noise_floor, battery, etc.)
|
|
// even when an IATA filter is configured for packet data.
|
|
func TestIATAFilterDoesNotDropStatusMessages(t *testing.T) {
|
|
store := newTestStore(t)
|
|
source := MQTTSource{Name: "test", IATAFilter: []string{"SJC"}}
|
|
|
|
// BFL observer sends a status message with noise_floor — outside the IATA filter.
|
|
msg := &mockMessage{
|
|
topic: "meshcore/BFL/bfl-obs1/status",
|
|
payload: []byte(`{"origin":"BFLObserver","stats":{"noise_floor":-105.0}}`),
|
|
}
|
|
handleMessage(store, "test", source, msg, nil, nil, &Config{})
|
|
|
|
var name string
|
|
var noiseFloor *float64
|
|
err := store.db.QueryRow("SELECT name, noise_floor FROM observers WHERE id = 'bfl-obs1'").Scan(&name, &noiseFloor)
|
|
if err != nil {
|
|
t.Fatalf("observer not found after status from out-of-region observer: %v", err)
|
|
}
|
|
if name != "BFLObserver" {
|
|
t.Errorf("name=%q, want BFLObserver", name)
|
|
}
|
|
if noiseFloor == nil || *noiseFloor != -105.0 {
|
|
t.Errorf("noise_floor=%v, want -105.0 — status message was dropped by IATA filter when it should not be", noiseFloor)
|
|
}
|
|
|
|
// Verify that a packet from BFL is still filtered.
|
|
rawHex := "0A00D69FD7A5A7475DB07337749AE61FA53A4788E976"
|
|
pktMsg := &mockMessage{
|
|
topic: "meshcore/BFL/bfl-obs1/packets",
|
|
payload: []byte(`{"raw":"` + rawHex + `"}`),
|
|
}
|
|
handleMessage(store, "test", source, pktMsg, nil, nil, &Config{})
|
|
var count int
|
|
store.db.QueryRow("SELECT COUNT(*) FROM transmissions").Scan(&count)
|
|
if count != 0 {
|
|
t.Error("packet from out-of-region BFL should still be filtered by IATA")
|
|
}
|
|
}
|
|
|
|
func TestLoadRegionKeys(t *testing.T) {
|
|
cfg := &Config{HashRegions: []string{"#belgium", "eu", " #Test ", "", "#belgium"}}
|
|
keys := loadRegionKeys(cfg)
|
|
|
|
// Deduplication + normalization
|
|
if len(keys) != 3 {
|
|
t.Fatalf("len(keys) = %d, want 3", len(keys))
|
|
}
|
|
// Pre-computed: SHA256("#belgium")[:16]. Hardcoded so a change to the key
|
|
// derivation algorithm (hash function, truncation length) breaks this test
|
|
// even if both sides were updated together.
|
|
wantBelgium, _ := hex.DecodeString("7085b78ed010599094f8c8e7d1aa0e27")
|
|
if got := keys["#belgium"]; !bytes.Equal(got, wantBelgium) {
|
|
t.Errorf("#belgium key mismatch: got %x, want %x", got, wantBelgium)
|
|
}
|
|
// "eu" should be normalized to "#eu"
|
|
if _, ok := keys["#eu"]; !ok {
|
|
t.Error("expected #eu key")
|
|
}
|
|
// " #Test " should be normalized to "#Test"
|
|
if _, ok := keys["#Test"]; !ok {
|
|
t.Error("expected #Test key")
|
|
}
|
|
}
|
|
|
|
func TestMatchScope(t *testing.T) {
|
|
// Fixed known-answer vectors only — no in-test HMAC computation.
|
|
// Keys and Code1 values are pre-computed externally so a wrong algorithm
|
|
// that produces consistent wrong results on both sides would still fail.
|
|
|
|
// Vector 1: "#test"/payloadType=5/"hello" → Code1=2AB5
|
|
// Key = SHA256("#test")[:16] = 9cd8fcf22a47333b591d96a2b848b73f
|
|
testKey, _ := hex.DecodeString("9cd8fcf22a47333b591d96a2b848b73f")
|
|
testKeys := map[string][]byte{"#test": testKey}
|
|
if got := regionKeySetFromKeys(testKeys).matchScopeName(5, []byte("hello"), "2AB5"); got != "#test" {
|
|
t.Errorf("#test vector: matchScope = %q, want #test", got)
|
|
}
|
|
|
|
// Vector 2: "#belgium"/payloadType=5/"hello" → Code1=4A75
|
|
// Key = SHA256("#belgium")[:16] = 7085b78ed010599094f8c8e7d1aa0e27
|
|
belgiumKey, _ := hex.DecodeString("7085b78ed010599094f8c8e7d1aa0e27")
|
|
belgiumKeys := map[string][]byte{"#belgium": belgiumKey}
|
|
if got := regionKeySetFromKeys(belgiumKeys).matchScopeName(5, []byte("hello"), "4A75"); got != "#belgium" {
|
|
t.Errorf("#belgium vector: matchScope = %q, want #belgium", got)
|
|
}
|
|
|
|
// Code1=0000 (unscoped transport) → no region matched
|
|
if got := regionKeySetFromKeys(belgiumKeys).matchScopeName(5, []byte("hello"), "0000"); got != "" {
|
|
t.Errorf("unscoped: matchScope = %q, want empty", got)
|
|
}
|
|
|
|
// Code1 present but matches no configured region → empty string
|
|
if got := regionKeySetFromKeys(belgiumKeys).matchScopeName(5, []byte("hello"), "BEEF"); got != "" {
|
|
t.Errorf("no match: matchScope = %q, want empty", got)
|
|
}
|
|
}
|
|
|
|
func TestBuildPacketDataScopeMatching(t *testing.T) {
|
|
// Fixed known-answer packet: TRANSPORT_FLOOD, payloadType=5, payload="hello",
|
|
// Code1=2AB5 (pre-computed for region "#test").
|
|
// header=0x14 (route_type=0 FLOOD, payloadType=5 → 5<<2), Code1=[0x2A,0xB5],
|
|
// Code2=[0,0], path_len=0, payload="hello" (68 65 6C 6C 6F).
|
|
const rawHex = "142AB500000068656C6C6F"
|
|
key, _ := hex.DecodeString("9cd8fcf22a47333b591d96a2b848b73f") // SHA256("#test")[:16]
|
|
regionKeys := map[string][]byte{"#test": key}
|
|
|
|
decoded, err := DecodePacket(rawHex, nil, false)
|
|
if err != nil {
|
|
t.Fatalf("DecodePacket: %v", err)
|
|
}
|
|
|
|
msg := &MQTTPacketMessage{Raw: rawHex}
|
|
pktData := BuildPacketData(msg, decoded, "obs1", "region1", regionKeySetFromKeys(regionKeys))
|
|
if pktData.ScopeName != "#test" {
|
|
t.Errorf("ScopeName = %q, want #test", pktData.ScopeName)
|
|
}
|
|
if !pktData.IsTransportScoped {
|
|
t.Error("IsTransportScoped should be true")
|
|
}
|
|
}
|
|
|
|
// TestMQTTConnectRetryTimeoutDoesNotBlock verifies that WaitTimeout returns within
|
|
// the deadline for an unreachable broker when ConnectRetry=true (#910). Previously,
|
|
// token.Wait() would block forever in this configuration.
|
|
func TestMQTTConnectRetryTimeoutDoesNotBlock(t *testing.T) {
|
|
opts := mqtt.NewClientOptions().
|
|
AddBroker("tcp://127.0.0.1:1"). // port 1 — nothing listening, fast refusal
|
|
SetConnectRetry(true).
|
|
SetAutoReconnect(true)
|
|
|
|
client := mqtt.NewClient(opts)
|
|
token := client.Connect()
|
|
defer client.Disconnect(100)
|
|
|
|
start := time.Now()
|
|
connected := token.WaitTimeout(3 * time.Second)
|
|
elapsed := time.Since(start)
|
|
|
|
if connected {
|
|
t.Skip("port 1 unexpectedly accepted a connection — skipping")
|
|
}
|
|
if elapsed > 4*time.Second {
|
|
t.Errorf("WaitTimeout blocked for %v — token.Wait() would block forever with ConnectRetry=true", elapsed)
|
|
}
|
|
}
|
|
|
|
// TestBL1_GoroutineLeakOnHardFailure reproduces BLOCKER 1: without Disconnect()
|
|
// on the error path, Paho's internal retry goroutines leak when a client is
|
|
// discarded after Connect() with ConnectRetry=true.
|
|
//
|
|
// We prove the leak by creating N clients WITHOUT Disconnect — goroutines grow
|
|
// proportionally. The fix (client.Disconnect(0) before continue) prevents this.
|
|
func TestBL1_GoroutineLeakOnHardFailure(t *testing.T) {
|
|
runtime.GC()
|
|
time.Sleep(100 * time.Millisecond)
|
|
baseline := runtime.NumGoroutine()
|
|
|
|
// Create multiple clients connected to unreachable broker, WITHOUT disconnecting.
|
|
// Each one spawns Paho retry goroutines that accumulate.
|
|
const numClients = 10
|
|
clients := make([]mqtt.Client, numClients)
|
|
for i := 0; i < numClients; i++ {
|
|
opts := mqtt.NewClientOptions().
|
|
AddBroker("tcp://127.0.0.1:1").
|
|
SetConnectRetry(true).
|
|
SetAutoReconnect(true).
|
|
SetConnectTimeout(500 * time.Millisecond)
|
|
c := mqtt.NewClient(opts)
|
|
tok := c.Connect()
|
|
tok.WaitTimeout(1 * time.Second)
|
|
clients[i] = c
|
|
}
|
|
|
|
time.Sleep(200 * time.Millisecond)
|
|
leaked := runtime.NumGoroutine()
|
|
goroutineGrowth := leaked - baseline
|
|
|
|
// Clean up to not actually leak in test
|
|
for _, c := range clients {
|
|
c.Disconnect(0)
|
|
}
|
|
|
|
t.Logf("baseline=%d, after %d undisconnected clients=%d, growth=%d",
|
|
baseline, numClients, leaked, goroutineGrowth)
|
|
|
|
// With ConnectRetry=true, each Connect() spawns retry goroutines.
|
|
// Without Disconnect, these accumulate. Verify growth is meaningful.
|
|
if goroutineGrowth < 3 {
|
|
t.Skip("Connect didn't spawn enough extra goroutines to measure leak")
|
|
}
|
|
|
|
// The fix: calling client.Disconnect(0) on the error path prevents accumulation.
|
|
// Anti-tautology: removing the Disconnect(0) call from main.go's error path
|
|
// would cause goroutine accumulation proportional to failed broker count.
|
|
t.Logf("CONFIRMED: %d leaked goroutines from %d clients without Disconnect — fix adds Disconnect(0) on error path", goroutineGrowth, numClients)
|
|
}
|
|
|
|
// TestBL2_ZeroConnectedFatals verifies BLOCKER 2: when all brokers are unreachable,
|
|
// connectedCount==0 must be detected. We test the logic directly — if only timed-out
|
|
// clients exist (appended to clients slice) but connectedCount is 0, the guard triggers.
|
|
func TestBL2_ZeroConnectedFatals(t *testing.T) {
|
|
// Simulate the connection loop result: 1 timed-out client, 0 connected
|
|
var clients []mqtt.Client
|
|
connectedCount := 0
|
|
|
|
// Create a client that times out (unreachable broker)
|
|
opts := mqtt.NewClientOptions().
|
|
AddBroker("tcp://127.0.0.1:1").
|
|
SetConnectRetry(true).
|
|
SetAutoReconnect(true)
|
|
|
|
client := mqtt.NewClient(opts)
|
|
token := client.Connect()
|
|
if !token.WaitTimeout(2 * time.Second) {
|
|
// Timed out — PR #926 appends to clients
|
|
clients = append(clients, client)
|
|
}
|
|
defer func() {
|
|
for _, c := range clients {
|
|
c.Disconnect(0)
|
|
}
|
|
}()
|
|
|
|
// OLD bug: len(clients) == 0 would be false (1 timed-out client in list)
|
|
// → ingestor would silently run with zero connections
|
|
if len(clients) == 0 {
|
|
t.Fatal("expected timed-out client to be in clients slice")
|
|
}
|
|
|
|
// NEW fix: connectedCount == 0 catches this
|
|
if connectedCount != 0 {
|
|
t.Errorf("connectedCount should be 0, got %d", connectedCount)
|
|
}
|
|
|
|
// The real code does: if connectedCount == 0 { log.Fatal(...) }
|
|
// This test proves len(clients) > 0 but connectedCount == 0 — the old guard
|
|
// would have missed it.
|
|
if len(clients) > 0 && connectedCount == 0 {
|
|
t.Log("BL2 confirmed: old guard len(clients)==0 would NOT fatal; new guard connectedCount==0 correctly catches zero-connected state")
|
|
}
|
|
}
|
|
|
|
func TestHandleMessageObserverIATAWhitelist(t *testing.T) {
|
|
store := newTestStore(t)
|
|
source := MQTTSource{Name: "test"}
|
|
cfg := &Config{
|
|
ObserverIATAWhitelist: []string{"ARN"},
|
|
}
|
|
|
|
// Message from non-whitelisted region GOT — should be dropped
|
|
handleMessage(store, "test", source, &mockMessage{
|
|
topic: "meshcore/GOT/obs1/status",
|
|
payload: []byte(`{"origin":"node1","noise_floor":-110}`),
|
|
}, nil, nil, cfg)
|
|
|
|
var count int
|
|
store.db.QueryRow("SELECT COUNT(*) FROM observers WHERE id='obs1'").Scan(&count)
|
|
if count != 0 {
|
|
t.Error("observer from non-whitelisted IATA GOT should be dropped")
|
|
}
|
|
|
|
// Message from whitelisted region ARN — should be accepted
|
|
handleMessage(store, "test", source, &mockMessage{
|
|
topic: "meshcore/ARN/obs2/status",
|
|
payload: []byte(`{"origin":"node2","noise_floor":-105}`),
|
|
}, nil, nil, cfg)
|
|
|
|
store.db.QueryRow("SELECT COUNT(*) FROM observers WHERE id='obs2'").Scan(&count)
|
|
if count != 1 {
|
|
t.Errorf("observer from whitelisted IATA ARN should be accepted, got count=%d", count)
|
|
}
|
|
}
|
|
|
|
// TestBuildPacketDataScopeMatchingNoMatch covers the #1534 regression: a
|
|
// transport-scoped advert from a non-matching region carries
|
|
// IsTransportScoped=true and ScopeName="". The default_scope update guard
|
|
// must skip these packets so previously-correct scopes aren't overwritten
|
|
// with the empty string.
|
|
func TestBuildPacketDataScopeMatchingNoMatch(t *testing.T) {
|
|
// Code1=2AB5 is the precomputed code for region "#test" (payload="hello",
|
|
// payloadType=5). Build a region-key map for a DIFFERENT region so
|
|
// matchScope() finds no match and returns "".
|
|
const rawHex = "142AB500000068656C6C6F"
|
|
otherKey, _ := hex.DecodeString("aabbccddeeff00112233445566778899")
|
|
regionKeys := map[string][]byte{"#other": otherKey}
|
|
|
|
decoded, err := DecodePacket(rawHex, nil, false)
|
|
if err != nil {
|
|
t.Fatalf("DecodePacket: %v", err)
|
|
}
|
|
msg := &MQTTPacketMessage{Raw: rawHex}
|
|
pktData := BuildPacketData(msg, decoded, "obs1", "region1", regionKeySetFromKeys(regionKeys))
|
|
|
|
if !pktData.IsTransportScoped {
|
|
t.Fatalf("precondition: IsTransportScoped should be true (Code1 != 0000)")
|
|
}
|
|
if pktData.ScopeName != "" {
|
|
t.Fatalf("precondition: ScopeName should be empty (no region match), got %q", pktData.ScopeName)
|
|
}
|
|
|
|
// Regression assertion: when ScopeName is empty, the guard must skip the
|
|
// UpdateNodeDefaultScope call so an empty value never overwrites a
|
|
// previously-correct default_scope (#1534).
|
|
if shouldUpdateDefaultScope(pktData) {
|
|
t.Errorf("shouldUpdateDefaultScope = true for empty ScopeName; want false (would overwrite default_scope with \"\")")
|
|
}
|
|
}
|
|
|
|
// TestHandleMessageAdvert_EmptyScopeSkipsDefaultScopeUpdate is the call-site
|
|
// regression test for #1534. It drives a transport-scoped ADVERT whose
|
|
// region key does NOT match any configured region (so ScopeName=="") through
|
|
// handleMessage end-to-end and asserts that a pre-existing default_scope on
|
|
// the node is NOT overwritten with the empty string. This anchors the
|
|
// call-site guard at main.go:720 — a future refactor that drops the
|
|
// `if shouldUpdateDefaultScope(...)` wrapper and calls
|
|
// `store.UpdateNodeDefaultScope(pubkey, pktData.ScopeName)` unconditionally
|
|
// would re-introduce the #1534 bug and fail this test.
|
|
func TestHandleMessageAdvert_EmptyScopeSkipsDefaultScopeUpdate(t *testing.T) {
|
|
store := newTestStore(t)
|
|
source := MQTTSource{Name: "test"}
|
|
|
|
// A transport-scoped ADVERT: header byte 0x10 = route_type 0
|
|
// (TRANSPORT_FLOOD) + payload_type 4 (ADVERT). Code1=AABB (non-zero, so
|
|
// IsTransportScoped becomes true), Code2=0000, path_byte=00, then a
|
|
// 100-byte ADVERT payload (32-byte pubkey starting 46D62D… + 4-byte ts
|
|
// + 64-byte signature) reused from TestHandleMessageAdvertWithTelemetry.
|
|
const rawHex = "10AABB00000046D62DE27D4C5194D7821FC5A34A45565DCC2537B300B9AB6275255CEFB65D840CE5C169C94C9AED39E8BCB6CB6EB0335497A198B33A1A610CD3B03D8DCFC160900E5244280323EE0B44CACAB8F02B5B38B91CFA18BD067B0B5E63E94CFC85F758A8530B9240933402E0E6B8F84D5252322D52"
|
|
const pubkey = "46d62de27d4c5194d7821fc5a34a45565dcc2537b300b9ab6275255cefb65d84"
|
|
|
|
// Pre-seed the node with a non-empty default_scope so we can detect an
|
|
// erroneous overwrite with "".
|
|
if _, err := store.db.Exec(`INSERT INTO nodes (public_key, name, default_scope) VALUES (?, 'Node1', '#belgium')`, pubkey); err != nil {
|
|
t.Fatalf("seed node: %v", err)
|
|
}
|
|
|
|
// Empty regionKeys → matchScope() returns "" for any Code1 → ScopeName "".
|
|
msg := &mockMessage{
|
|
topic: "meshcore/SJC/obs1/packets",
|
|
payload: []byte(`{"raw":"` + rawHex + `"}`),
|
|
}
|
|
handleMessage(store, "test", source, msg, nil, nil, &Config{})
|
|
|
|
var got sql.NullString
|
|
if err := store.db.QueryRow(`SELECT default_scope FROM nodes WHERE public_key = ?`, pubkey).Scan(&got); err != nil {
|
|
t.Fatalf("read default_scope: %v", err)
|
|
}
|
|
if !got.Valid || got.String != "#belgium" {
|
|
t.Errorf("default_scope after empty-scope advert = %q (valid=%v), want #belgium — call-site guard at main.go:720 is missing or broken (#1534)", got.String, got.Valid)
|
|
}
|
|
}
|
|
|
|
// TestHandleMessageAdvert_MatchedScopeUpdatesDefaultScope is the positive
|
|
// counterpart: a transport-scoped ADVERT whose Code1 matches a configured
|
|
// region key MUST cause default_scope to be updated to the matched region
|
|
// name. Together with the empty-scope test above this proves the call-site
|
|
// branch routes correctly for both ScopeName states.
|
|
func TestHandleMessageAdvert_MatchedScopeUpdatesDefaultScope(t *testing.T) {
|
|
store := newTestStore(t)
|
|
source := MQTTSource{Name: "test"}
|
|
|
|
// Same ADVERT bytes; this time we compute the matching region key for
|
|
// the (payloadType=4, payload=<advert bytes>) tuple so matchScope() will
|
|
// return "#de".
|
|
const advertBytes = "46D62DE27D4C5194D7821FC5A34A45565DCC2537B300B9AB6275255CEFB65D840CE5C169C94C9AED39E8BCB6CB6EB0335497A198B33A1A610CD3B03D8DCFC160900E5244280323EE0B44CACAB8F02B5B38B91CFA18BD067B0B5E63E94CFC85F758A8530B9240933402E0E6B8F84D5252322D52"
|
|
const pubkey = "46d62de27d4c5194d7821fc5a34a45565dcc2537b300b9ab6275255cefb65d84"
|
|
|
|
advertRaw, _ := hex.DecodeString(advertBytes)
|
|
// Derive the region key whose HMAC produces Code1 we can plant in the
|
|
// header. Choose key = first 16 bytes of HMAC-SHA256(zeros, advertBytes)
|
|
// is non-deterministic to find; instead pick an arbitrary key and
|
|
// compute Code1 from it, then build the packet around that Code1.
|
|
regionKey, _ := hex.DecodeString("0123456789abcdef0123456789abcdef")
|
|
mac := hmacSHA256(regionKey, append([]byte{4}, advertRaw...))
|
|
// Per firmware (#1534 helper logic): Code1 is the first 2 bytes of the
|
|
// HMAC, sentinel-shifted so 0x0000 → 0x0001 and 0xFFFF → 0xFFFE.
|
|
code := uint16(mac[0]) | (uint16(mac[1]) << 8)
|
|
if code == 0x0000 {
|
|
code = 0x0001
|
|
} else if code == 0xFFFF {
|
|
code = 0xFFFE
|
|
}
|
|
code1 := fmt.Sprintf("%02X%02X", byte(code&0xFF), byte(code>>8))
|
|
rawHex := "10" + code1 + "000000" + advertBytes
|
|
|
|
if _, err := store.db.Exec(`INSERT INTO nodes (public_key, name, default_scope) VALUES (?, 'Node1', '#old')`, pubkey); err != nil {
|
|
t.Fatalf("seed node: %v", err)
|
|
}
|
|
|
|
msg := &mockMessage{
|
|
topic: "meshcore/SJC/obs1/packets",
|
|
payload: []byte(`{"raw":"` + rawHex + `"}`),
|
|
}
|
|
handleMessage(store, "test", source, msg, nil, regionKeySetFromKeys(map[string][]byte{"#de": regionKey}), &Config{})
|
|
|
|
var got sql.NullString
|
|
if err := store.db.QueryRow(`SELECT default_scope FROM nodes WHERE public_key = ?`, pubkey).Scan(&got); err != nil {
|
|
t.Fatalf("read default_scope: %v", err)
|
|
}
|
|
if !got.Valid || got.String != "#de" {
|
|
t.Errorf("default_scope after matched-scope advert = %q (valid=%v), want #de", got.String, got.Valid)
|
|
}
|
|
}
|
|
|
|
// codeForRegion derives the on-wire code1 a sender in this region would emit
|
|
// for this payload: the forward direction of what matchScope inverts.
|
|
func codeForRegion(name string, payloadType byte, payload []byte) string {
|
|
if !strings.HasPrefix(name, "#") {
|
|
name = "#" + name
|
|
}
|
|
sum := sha256.Sum256([]byte(name))
|
|
mac := hmac.New(sha256.New, sum[:16])
|
|
mac.Write([]byte{payloadType})
|
|
mac.Write(payload)
|
|
h := mac.Sum(nil)
|
|
code := uint16(h[0]) | uint16(h[1])<<8
|
|
if code == 0 {
|
|
code = 1
|
|
} else if code == 0xFFFF {
|
|
code = 0xFFFE
|
|
}
|
|
return strings.ToUpper(hex.EncodeToString([]byte{byte(code & 0xFF), byte(code >> 8)}))
|
|
}
|
|
|
|
// findRegionCollision searches for a payload whose code1 is identical under two
|
|
// region names. code1 is two bytes, so one turns up after ~65k tries and the
|
|
// search costs a fraction of a second.
|
|
//
|
|
// A hand-built fixture cannot stand in here: the whole point of the case below
|
|
// is what happens when the matcher genuinely finds two names for one packet,
|
|
// and a fabricated code1 would only prove the test agrees with itself.
|
|
func findRegionCollision(t *testing.T, nameA, nameB string, payloadType byte) ([]byte, string) {
|
|
t.Helper()
|
|
payload := make([]byte, 4)
|
|
for i := 0; i < 1<<22; i++ {
|
|
payload[0], payload[1] = byte(i), byte(i>>8)
|
|
payload[2], payload[3] = byte(i>>16), byte(i>>24)
|
|
if a, b := codeForRegion(nameA, payloadType, payload), codeForRegion(nameB, payloadType, payload); a == b {
|
|
return append([]byte(nil), payload...), a
|
|
}
|
|
}
|
|
t.Fatalf("no code1 collision between %s and %s in 2^22 payloads", nameA, nameB)
|
|
return nil, ""
|
|
}
|
|
|
|
// TestMatchScopeNamesAnUnambiguousPacket is the ordinary case: one configured
|
|
// region derives the packet's code1, so the packet carries that region's name.
|
|
func TestMatchScopeNamesAnUnambiguousPacket(t *testing.T) {
|
|
keys := loadRegionKeys(&Config{HashRegions: []string{"#be", "#nl"}})
|
|
payload := []byte{0x01, 0x02, 0x03, 0x04}
|
|
code1 := codeForRegion("#be", 5, payload)
|
|
|
|
if got := regionKeySetFromKeys(keys).matchScopeName(5, payload, code1); got != "#be" {
|
|
t.Errorf("matchScope = %q, want %q", got, "#be")
|
|
}
|
|
}
|
|
|
|
// TestMatchScopeStoresAmbiguousAsUnmatched pins the reason this changed. Two
|
|
// configured regions derive the same code1 for one payload; naming the packet
|
|
// after either is a coin flip, and before this the flip was Go's map iteration
|
|
// order, so the same packet could be stored under different regions on two
|
|
// runs of the same binary.
|
|
func TestMatchScopeStoresAmbiguousAsUnmatched(t *testing.T) {
|
|
payload, code1 := findRegionCollision(t, "#be", "#zz", 5)
|
|
keys := loadRegionKeys(&Config{HashRegions: []string{"#be", "#zz"}})
|
|
|
|
if n := len(matchingRegions(keys, 5, payload, code1)); n != 2 {
|
|
t.Fatalf("matchingRegions returned %d names, want 2 — the collision fixture is wrong", n)
|
|
}
|
|
if got := regionKeySetFromKeys(keys).matchScopeName(5, payload, code1); got != "" {
|
|
t.Errorf("matchScope = %q, want the unmatched state: two equally-sourced candidates have no principled winner", got)
|
|
}
|
|
}
|
|
|
|
// TestMatchScopeIsOrderIndependent runs the ambiguous case repeatedly. Map
|
|
// iteration order is randomised per range in Go, so a first-match matcher
|
|
// returns different names across iterations of this loop; the answer must not
|
|
// move.
|
|
func TestMatchScopeIsOrderIndependent(t *testing.T) {
|
|
payload, code1 := findRegionCollision(t, "#be", "#zz", 5)
|
|
keys := loadRegionKeys(&Config{HashRegions: []string{"#be", "#zz"}})
|
|
|
|
for i := 0; i < 50; i++ {
|
|
if got := regionKeySetFromKeys(keys).matchScopeName(5, payload, code1); got != "" {
|
|
t.Fatalf("iteration %d: matchScope = %q, want a stable answer across map iteration orders", i, got)
|
|
}
|
|
}
|
|
}
|