Files
meshcore-analyzer/cmd/ingestor/coverage_boost_test.go
T
40f664c587 chore(#1859): gofmt sweep + gofmt/go vet CI gate (rebase of #1881) (#1941)
Rebase of #1881 by @SaarMesh-Bot onto current master. Their three
commits are preserved, two of them cherry-picked with authorship intact;
the sweep itself had to be regenerated. Opened as a new PR rather than
force-pushing their branch.

Closes #1881 once merged. Addresses parts 1 and 3 of #1859; part 2
landed as #1937.

## Why regenerated rather than merged

The sweep in #1881 was cut on 2026-09-02 07:13 and roughly forty PRs
landed after it, so it went `CONFLICTING/DIRTY`. Re-running `gofmt` on
current master is cheaper and less error-prone than resolving 72
conflicts that are all whitespace. The drift it fixes also grew in the
meantime: 66 files now, against 72 then, but spread differently.

## The three commits

1. **`style(#1859)`** — `gofmt -w` across the 14 modules. 66 files.
2. **`test(#1859)`** — @SaarMesh-Bot's fix for the one `go vet`
copylocks finding, `cmd/ingestor/coverage_boost_test.go`: the range
variable copied a `Config` embedding `sync.Once`. Cherry-picked
unchanged.
3. **`ci(#1859)`** — @SaarMesh-Bot's CI step that fails on gofmt drift
or vet findings, plus `.git-blame-ignore-revs`. Cherry-picked with one
change, noted in the commit message: the ignore file pointed at
`04bc80ee`, the sweep commit on their branch, which does not exist on
this base and would make `git blame --ignore-revs-file` error. Repointed
at `d3a02599`, the sweep here.

## Verification

The claim "formatting only" is checked twice rather than asserted:

- Every changed file is byte-identical to `gofmt(previous content)`. 0
of 66 deviate.
- With line comments and all whitespace stripped, 0 of 66 files differ,
so no code outside comments changed.

14 of the 66 also show doc-comment reflow. Since Go 1.19 `gofmt`
re-indents indented comment blocks to tabs and inserts a blank comment
line before them; the behavior matrix above `resolveHopWithContext` in
`cmd/ingestor/path_resolver.go` is a clear example. That is gofmt's own
output, not an edit, but it is worth naming because it makes the diff
look larger than "whitespace" suggests.

The gate was run locally exactly as the workflow runs it: `gofmt` clean,
and `go vet` clean in all 14 modules, including `cmd/ingestor` which is
what commit 2 fixes.

Suites: `cmd/server` ok (80.7s), `internal/packetpath` ok (2.3s),
`cmd/ingestor` passes except
`TestWriteStatsAtomic_SymlinkAtDestIsReplaced`, which fails identically
on bare master with "A required privilege is not held by the client"
(Windows symlink privilege on my host, not code).

## Sequencing

This should go last in the queue. The sweep touches 66 files, so merging
it before the remaining open Go PRs gives each of them a conflict about
nothing but formatting. After it lands the gate is active, and any PR
with drift fails CI until it runs `gofmt -w`.

Excluded from the sweep: the misnamed `Dockerfile.go`, which is a
Dockerfile that gofmt cannot parse (the workflow excludes it too), and
`docs/DEPLOYMENT.md`, which a case-insensitive filesystem surfaces as a
spurious modification against `docs/deployment.md` and is unrelated.

---------

Co-authored-by: SaarMesh-Bot <300107934+SaarMesh-Bot@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 18:52:03 +02:00

1334 lines
38 KiB
Go

package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"os"
"path/filepath"
"testing"
"time"
)
// hmacSHA256 computes HMAC-SHA256 for test use.
func hmacSHA256(key, data []byte) []byte {
h := hmac.New(sha256.New, key)
h.Write(data)
return h.Sum(nil)
}
// newTestContext extracts the repeated newTestStore + MQTTSource boilerplate.
func newTestContext(t *testing.T) (*Store, MQTTSource) {
t.Helper()
return newTestStore(t), MQTTSource{Name: "test"}
}
// --- config.go: NodeDaysOrDefault (0% coverage) ---
func TestNodeDaysOrDefault(t *testing.T) {
tests := []struct {
name string
cfg Config
want int
}{
{"nil retention", Config{}, 7},
{"zero nodeDays", Config{Retention: &RetentionConfig{NodeDays: 0}}, 7},
{"negative nodeDays", Config{Retention: &RetentionConfig{NodeDays: -1}}, 7},
{"custom nodeDays", Config{Retention: &RetentionConfig{NodeDays: 14}}, 14},
{"one day", Config{Retention: &RetentionConfig{NodeDays: 1}}, 1},
}
for i := range tests {
tt := &tests[i]
t.Run(tt.name, func(t *testing.T) {
got := tt.cfg.NodeDaysOrDefault()
if got != tt.want {
t.Errorf("NodeDaysOrDefault() = %d, want %d", got, tt.want)
}
})
}
}
// --- config.go: ResolvedSources broker scheme normalization (71.4% → 100%) ---
func TestResolvedSourcesBrokerScheme(t *testing.T) {
cfg := &Config{
MQTTSources: []MQTTSource{
{Name: "mqtt", Broker: "mqtt://broker:1883"},
{Name: "mqtts", Broker: "mqtts://broker:8883"},
{Name: "tcp", Broker: "tcp://broker:1883"},
},
}
sources := cfg.ResolvedSources()
if sources[0].Broker != "tcp://broker:1883" {
t.Errorf("mqtt:// should become tcp://, got %s", sources[0].Broker)
}
if sources[1].Broker != "ssl://broker:8883" {
t.Errorf("mqtts:// should become ssl://, got %s", sources[1].Broker)
}
if sources[2].Broker != "tcp://broker:1883" {
t.Errorf("tcp:// should stay, got %s", sources[2].Broker)
}
}
// --- db.go: MoveStaleNodes (0% coverage) ---
func TestMoveStaleNodes(t *testing.T) {
store := newTestStore(t)
// Insert a node with last_seen 30 days ago
err := store.UpsertNode("deadbeef1234567890abcdef12345678", "OldNode", "companion", nil, nil, "2020-01-01T00:00:00Z")
if err != nil {
t.Fatal(err)
}
// Insert a recent node
err = store.UpsertNode("aabbccdd1234567890abcdef12345678", "NewNode", "repeater", nil, nil, "2099-01-01T00:00:00Z")
if err != nil {
t.Fatal(err)
}
moved, err := store.MoveStaleNodes(7)
if err != nil {
t.Fatal(err)
}
if moved != 1 {
t.Errorf("moved=%d, want 1", moved)
}
var count int
if err := store.db.QueryRow("SELECT COUNT(*) FROM inactive_nodes").Scan(&count); err != nil {
t.Fatal(err)
}
if count != 1 {
t.Errorf("inactive_nodes count=%d, want 1", count)
}
if err := store.db.QueryRow("SELECT COUNT(*) FROM nodes").Scan(&count); err != nil {
t.Fatal(err)
}
if count != 1 {
t.Errorf("nodes count=%d, want 1", count)
}
}
func TestMoveStaleNodesNoneToMove(t *testing.T) {
store := newTestStore(t)
moved, err := store.MoveStaleNodes(7)
if err != nil {
t.Fatal(err)
}
if moved != 0 {
t.Errorf("moved=%d, want 0", moved)
}
}
// --- geo_filter.go: NodePassesGeoFilter (40% → 100%) ---
func TestNodePassesGeoFilterAllBranches(t *testing.T) {
lat, lon := 37.0, -122.0
outLat, outLon := 50.0, 10.0
latMin, latMax := 36.0, 38.0
lonMin, lonMax := -123.0, -121.0
gf := &GeoFilterConfig{
LatMin: &latMin, LatMax: &latMax,
LonMin: &lonMin, LonMax: &lonMax,
}
if !NodePassesGeoFilter(nil, nil, gf) {
t.Error("nil coords should pass")
}
if !NodePassesGeoFilter(&lat, nil, gf) {
t.Error("nil lon should pass")
}
if !NodePassesGeoFilter(nil, &lon, gf) {
t.Error("nil lat should pass")
}
if !NodePassesGeoFilter(&lat, &lon, gf) {
t.Error("inside filter should pass")
}
if NodePassesGeoFilter(&outLat, &outLon, gf) {
t.Error("outside filter should fail")
}
}
// --- main.go: handleMessage channel messages (41.4% → higher) ---
func TestHandleMessageChannelMessage(t *testing.T) {
store, source := newTestContext(t)
payload := []byte(`{"text":"Alice: Hello everyone","channel_idx":3,"SNR":5.0,"RSSI":-95,"score":10,"direction":"rx","sender_timestamp":1700000000}`)
msg := &mockMessage{topic: "meshcore/message/channel/2", payload: payload}
handleMessage(store, "test", source, msg, nil, nil, &Config{})
var count int
if err := store.db.QueryRow("SELECT COUNT(*) FROM transmissions").Scan(&count); err != nil {
t.Fatal(err)
}
if count != 1 {
t.Errorf("transmissions count=%d, want 1", count)
}
// Verify stored transmission values
var decodedJSON string
if err := store.db.QueryRow("SELECT decoded_json FROM transmissions LIMIT 1").Scan(&decodedJSON); err != nil {
t.Fatal(err)
}
var decoded map[string]interface{}
if err := json.Unmarshal([]byte(decodedJSON), &decoded); err != nil {
t.Fatalf("decoded_json unmarshal: %v", err)
}
if decoded["type"] != "CHAN" {
t.Errorf("type=%v, want CHAN", decoded["type"])
}
if decoded["text"] != "Alice: Hello everyone" {
t.Errorf("text=%v, want 'Alice: Hello everyone'", decoded["text"])
}
if decoded["sender"] != "Alice" {
t.Errorf("sender=%v, want Alice", decoded["sender"])
}
if decoded["channel"] != "ch3" {
t.Errorf("channel=%v, want ch3", decoded["channel"])
}
// Verify observation values
var snr, rssi *float64
var score *float64
var direction *string
if err := store.db.QueryRow("SELECT snr, rssi, score, direction FROM observations LIMIT 1").Scan(&snr, &rssi, &score, &direction); err != nil {
t.Fatal(err)
}
if snr == nil || *snr != 5.0 {
t.Errorf("snr=%v, want 5.0", snr)
}
if direction == nil || *direction != "rx" {
t.Errorf("direction=%v, want rx", direction)
}
// Sender node should NOT be created (see issue #665: synthetic "sender-" keys
// are unreachable from the claiming/health flow)
if err := store.db.QueryRow("SELECT COUNT(*) FROM nodes").Scan(&count); err != nil {
t.Fatal(err)
}
if count != 0 {
t.Errorf("nodes count=%d, want 0 (no phantom sender node)", count)
}
}
func TestHandleMessageChannelMessageEmptyText(t *testing.T) {
store, source := newTestContext(t)
msg := &mockMessage{topic: "meshcore/message/channel/1", payload: []byte(`{"text":""}`)}
handleMessage(store, "test", source, msg, nil, nil, &Config{})
var count int
if err := store.db.QueryRow("SELECT COUNT(*) FROM transmissions").Scan(&count); err != nil {
t.Fatal(err)
}
if count != 0 {
t.Error("empty text should not insert")
}
}
func TestHandleMessageChannelNoSender(t *testing.T) {
store, source := newTestContext(t)
msg := &mockMessage{topic: "meshcore/message/channel/1", payload: []byte(`{"text":"no sender here"}`)}
handleMessage(store, "test", source, msg, nil, nil, &Config{})
var count int
if err := store.db.QueryRow("SELECT COUNT(*) FROM nodes").Scan(&count); err != nil {
t.Fatal(err)
}
if count != 0 {
t.Error("no sender should mean no node")
}
}
func TestHandleMessageDirectMessage(t *testing.T) {
store, source := newTestContext(t)
payload := []byte(`{"text":"Bob: Hey there","sender_timestamp":1700000000,"SNR":3.0,"rssi":-100,"Score":8,"Direction":"tx"}`)
msg := &mockMessage{topic: "meshcore/message/direct/abc123", payload: payload}
handleMessage(store, "test", source, msg, nil, nil, &Config{})
var count int
if err := store.db.QueryRow("SELECT COUNT(*) FROM transmissions").Scan(&count); err != nil {
t.Fatal(err)
}
if count != 1 {
t.Errorf("transmissions count=%d, want 1", count)
}
// Verify stored decoded values
var decodedJSON string
if err := store.db.QueryRow("SELECT decoded_json FROM transmissions LIMIT 1").Scan(&decodedJSON); err != nil {
t.Fatal(err)
}
var decoded map[string]interface{}
if err := json.Unmarshal([]byte(decodedJSON), &decoded); err != nil {
t.Fatalf("decoded_json unmarshal: %v", err)
}
if decoded["type"] != "DM" {
t.Errorf("type=%v, want DM", decoded["type"])
}
if decoded["sender"] != "Bob" {
t.Errorf("sender=%v, want Bob", decoded["sender"])
}
// Verify observation score=8 and direction=tx
var score *float64
var direction *string
if err := store.db.QueryRow("SELECT score, direction FROM observations LIMIT 1").Scan(&score, &direction); err != nil {
t.Fatal(err)
}
if score == nil || *score != 8.0 {
t.Errorf("score=%v, want 8.0", score)
}
if direction == nil || *direction != "tx" {
t.Errorf("direction=%v, want tx", direction)
}
}
func TestHandleMessageDirectMessageEmptyText(t *testing.T) {
store, source := newTestContext(t)
msg := &mockMessage{topic: "meshcore/message/direct/abc", payload: []byte(`{"text":""}`)}
handleMessage(store, "test", source, msg, nil, nil, &Config{})
var count int
if err := store.db.QueryRow("SELECT COUNT(*) FROM transmissions").Scan(&count); err != nil {
t.Fatal(err)
}
if count != 0 {
t.Error("empty text DM should not insert")
}
}
func TestHandleMessageDirectNoSender(t *testing.T) {
store, source := newTestContext(t)
msg := &mockMessage{topic: "meshcore/message/direct/xyz", payload: []byte(`{"text":"message with no colon"}`)}
handleMessage(store, "test", source, msg, nil, nil, &Config{})
var count int
if err := store.db.QueryRow("SELECT COUNT(*) FROM transmissions").Scan(&count); err != nil {
t.Fatal(err)
}
if count != 1 {
t.Errorf("count=%d, want 1", count)
}
}
// Test Score/Direction case-insensitive handling in raw packets
func TestHandleMessageUppercaseScoreDirection(t *testing.T) {
store, source := newTestContext(t)
rawHex := "0A00D69FD7A5A7475DB07337749AE61FA53A4788E976"
payload := []byte(`{"raw":"` + rawHex + `","Score":9.0,"Direction":"tx"}`)
msg := &mockMessage{topic: "meshcore/SJC/obs1/packets", payload: payload}
handleMessage(store, "test", source, msg, nil, nil, &Config{})
var score *float64
var direction *string
if err := store.db.QueryRow("SELECT score, direction FROM observations LIMIT 1").Scan(&score, &direction); err != nil {
t.Fatal(err)
}
if score == nil || *score != 9.0 {
t.Errorf("score=%v, want 9.0", score)
}
if direction == nil || *direction != "tx" {
t.Errorf("direction=%v, want tx", direction)
}
}
// Test channel messages with lowercase snr/rssi/Score/Direction
func TestHandleMessageChannelLowercaseFields(t *testing.T) {
store, source := newTestContext(t)
payload := []byte(`{"text":"Test: msg","snr":3.0,"rssi":-90,"Score":5,"Direction":"rx"}`)
msg := &mockMessage{topic: "meshcore/message/channel/0", payload: payload}
handleMessage(store, "test", source, msg, nil, nil, &Config{})
var count int
if err := store.db.QueryRow("SELECT COUNT(*) FROM transmissions").Scan(&count); err != nil {
t.Fatal(err)
}
if count != 1 {
t.Errorf("count=%d, want 1", count)
}
}
func TestHandleMessageDirectLowercaseFields(t *testing.T) {
store, source := newTestContext(t)
payload := []byte(`{"text":"Test: msg","snr":2.0,"rssi":-85,"score":7,"direction":"tx"}`)
msg := &mockMessage{topic: "meshcore/message/direct/xyz", payload: payload}
handleMessage(store, "test", source, msg, nil, nil, &Config{})
var count int
if err := store.db.QueryRow("SELECT COUNT(*) FROM transmissions").Scan(&count); err != nil {
t.Fatal(err)
}
if count != 1 {
t.Errorf("count=%d, want 1", count)
}
}
// --- main.go: handleMessage advert with telemetry ---
func TestHandleMessageAdvertWithTelemetry(t *testing.T) {
store, source := newTestContext(t)
// Use a known ADVERT hex
rawHex := "120046D62DE27D4C5194D7821FC5A34A45565DCC2537B300B9AB6275255CEFB65D840CE5C169C94C9AED39E8BCB6CB6EB0335497A198B33A1A610CD3B03D8DCFC160900E5244280323EE0B44CACAB8F02B5B38B91CFA18BD067B0B5E63E94CFC85F758A8530B9240933402E0E6B8F84D5252322D52"
msg := &mockMessage{
topic: "meshcore/SJC/obs1/packets",
payload: []byte(`{"raw":"` + rawHex + `"}`),
}
handleMessage(store, "test", source, msg, nil, nil, &Config{})
// Should have created transmission, node, and observer
var txCount, nodeCount, obsCount int
if err := store.db.QueryRow("SELECT COUNT(*) FROM transmissions").Scan(&txCount); err != nil {
t.Fatal(err)
}
if err := store.db.QueryRow("SELECT COUNT(*) FROM nodes").Scan(&nodeCount); err != nil {
t.Fatal(err)
}
if err := store.db.QueryRow("SELECT COUNT(*) FROM observers").Scan(&obsCount); err != nil {
t.Fatal(err)
}
if txCount != 1 {
t.Errorf("transmissions=%d, want 1", txCount)
}
if nodeCount != 1 {
t.Errorf("nodes=%d, want 1", nodeCount)
}
}
// --- main.go: handleMessage geo filter on advert ---
func TestHandleMessageAdvertGeoFiltered(t *testing.T) {
store, source := newTestContext(t)
rawHex := "120046D62DE27D4C5194D7821FC5A34A45565DCC2537B300B9AB6275255CEFB65D840CE5C169C94C9AED39E8BCB6CB6EB0335497A198B33A1A610CD3B03D8DCFC160900E5244280323EE0B44CACAB8F02B5B38B91CFA18BD067B0B5E63E94CFC85F758A8530B9240933402E0E6B8F84D5252322D52"
latMin, latMax := -1.0, 1.0
lonMin, lonMax := -1.0, 1.0
gf := &GeoFilterConfig{
LatMin: &latMin, LatMax: &latMax,
LonMin: &lonMin, LonMax: &lonMax,
}
msg := &mockMessage{
topic: "meshcore/SJC/obs1/packets",
payload: []byte(`{"raw":"` + rawHex + `"}`),
}
// Legacy silent-drop behavior is now opt-in via ForeignAdverts.Mode="drop"
// (#730). The new default — flag — is covered by foreign_advert_test.go.
handleMessage(store, "test", source, msg, nil, nil, &Config{
GeoFilter: gf,
ForeignAdverts: &ForeignAdvertConfig{Mode: "drop"},
})
// Geo-filtered adverts should not create nodes
var nodeCount int
if err := store.db.QueryRow("SELECT COUNT(*) FROM nodes").Scan(&nodeCount); err != nil {
t.Fatal(err)
}
if nodeCount != 0 {
t.Errorf("nodes=%d, want 0 (geo-filtered advert in drop mode should not create node)", nodeCount)
}
}
// --- decoder.go: decodeAdvert with features but insufficient data ---
func TestDecodeAdvertLocationTruncated(t *testing.T) {
buf := make([]byte, 105)
for i := 0; i < 32; i++ {
buf[i] = byte(i + 1)
}
for i := 36; i < 100; i++ {
buf[i] = 0xCC
}
// flags: hasLocation(0x10) | type=1(chat) = 0x11
buf[100] = 0x11
// Only 4 bytes after flags — not enough for full location (needs 8)
p := decodeAdvert(buf[:105], false)
if p.Error != "" {
t.Fatalf("error: %s", p.Error)
}
// Location should not be set (not enough data)
if p.Lat != nil {
t.Error("lat should be nil with truncated location data")
}
}
func TestDecodeAdvertFeat1Truncated(t *testing.T) {
buf := make([]byte, 102)
for i := 0; i < 32; i++ {
buf[i] = byte(i + 1)
}
for i := 36; i < 100; i++ {
buf[i] = 0xCC
}
// flags: hasFeat1(0x20) | type=1 = 0x21
buf[100] = 0x21
// Only 1 byte after flags — not enough for feat1 (needs 2)
p := decodeAdvert(buf[:102], false)
if p.Feat1 != nil {
t.Error("feat1 should be nil with truncated data")
}
}
func TestDecodeAdvertFeat2Truncated(t *testing.T) {
buf := make([]byte, 104)
for i := 0; i < 32; i++ {
buf[i] = byte(i + 1)
}
for i := 36; i < 100; i++ {
buf[i] = 0xCC
}
// flags: hasFeat1(0x20) | hasFeat2(0x40) | type=1 = 0x61
buf[100] = 0x61
// feat1: 2 bytes
buf[101] = 0x01
buf[102] = 0x00
// Only 1 byte left — not enough for feat2
p := decodeAdvert(buf[:104], false)
if p.Feat1 == nil {
t.Error("feat1 should be set")
}
if p.Feat2 != nil {
t.Error("feat2 should be nil with truncated data")
}
}
// --- decoder.go: decodeAdvert sensor with out-of-range telemetry ---
func TestDecodeAdvertSensorBadTelemetry(t *testing.T) {
buf := make([]byte, 112)
// Bytes 0-31: public key
for i := 0; i < 32; i++ {
buf[i] = byte(i + 1)
}
// Bytes 32-35: reserved (4 bytes)
// Bytes 36-99: padding (64 bytes of 0xCC)
for i := 36; i < 100; i++ {
buf[i] = 0xCC
}
// Byte 100: flags — sensor(4) | hasName(0x80) = 0x84
// off starts at 101 after flags byte
buf[100] = 0x84
// Bytes 101-102: name "S" + null terminator
// Name parsing reads from off=101, finds null at 102, advances off to 103
copy(buf[101:], []byte("S\x00"))
// Bytes 103-104: battery_mv (uint16 LE) = 0 (out of range, should be skipped)
// off=103 after name parsing
buf[103] = 0x00
buf[104] = 0x00
// Bytes 105-106: temperature (uint16 LE) = 0x4E20 = 20000 raw (200.00°C, out of range)
// off=105 after battery
buf[105] = 0x20
buf[106] = 0x4E
p := decodeAdvert(buf[:107], false)
if p.BatteryMv != nil {
t.Error("battery_mv=0 should be nil")
}
if p.TemperatureC != nil {
t.Error("out-of-range temp should be nil")
}
}
// --- decoder.go: countNonPrintable with RuneError ---
func TestCountNonPrintableRuneError(t *testing.T) {
// Invalid UTF-8 bytes
got := countNonPrintable(string([]byte{0xff, 0xfe}))
if got != 2 {
t.Errorf("countNonPrintable invalid UTF-8 = %d, want 2", got)
}
// Normal text
if countNonPrintable("hello") != 0 {
t.Error("normal text should have 0 non-printable")
}
}
// --- decoder.go: decryptChannelMessage edge cases ---
func TestDecryptChannelMessageEmptyCiphertext(t *testing.T) {
_, err := decryptChannelMessage("", "0011", "00112233445566778899aabbccddeeff")
if err == nil {
t.Error("empty ciphertext should error")
}
}
func TestDecryptChannelMessageMACFailsBeforeAlignment(t *testing.T) {
// 15 bytes of ciphertext — not aligned to AES block size (16).
// However, decryptChannelMessage checks MAC before alignment, so with
// random ciphertext the HMAC won't match and it errors on MAC first.
_, err := decryptChannelMessage("00112233445566778899aabbccddee", "0011", "00112233445566778899aabbccddeeff")
if err == nil {
t.Error("should error (MAC mismatch)")
}
if err.Error() != "MAC verification failed" {
t.Errorf("expected MAC error, got: %v", err)
}
}
func TestDecryptChannelMessageNotAligned(t *testing.T) {
// To actually exercise the alignment branch, we need ciphertext whose
// HMAC-SHA256 first 2 bytes match the provided MAC, but whose length
// is not a multiple of 16. We craft this by computing the real MAC.
key := "00112233445566778899aabbccddeeff"
// 15 bytes of ciphertext (not aligned to 16)
ciphertextBytes := []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f}
ciphertextHex := hex.EncodeToString(ciphertextBytes)
// Compute real HMAC to pass the MAC check
keyBytes, _ := hex.DecodeString(key)
channelSecret := make([]byte, 32)
copy(channelSecret, keyBytes)
h := hmacSHA256(channelSecret, ciphertextBytes)
macHex := hex.EncodeToString(h[:2])
_, err := decryptChannelMessage(ciphertextHex, macHex, key)
if err == nil {
t.Error("unaligned ciphertext should error")
}
if err.Error() != "ciphertext not aligned to AES block size" {
t.Errorf("expected alignment error, got: %v", err)
}
}
func TestDecryptChannelMessageBadMACHex(t *testing.T) {
_, err := decryptChannelMessage("00112233445566778899aabbccddeeff", "ZZ", "00112233445566778899aabbccddeeff")
if err == nil {
t.Error("invalid MAC hex should error")
}
}
func TestDecryptChannelMessageBadCiphertextHex(t *testing.T) {
_, err := decryptChannelMessage("ZZZZ", "0011", "00112233445566778899aabbccddeeff")
if err == nil {
t.Error("invalid ciphertext hex should error")
}
}
// --- db.go: Checkpoint and LogStats ---
func TestCheckpointDoesNotPanic(t *testing.T) {
store := newTestStore(t)
store.Checkpoint()
}
func TestLogStatsDoesNotPanic(t *testing.T) {
store := newTestStore(t)
store.Stats.TransmissionsInserted.Add(5)
store.LogStats()
}
// --- decoder.go: ComputeContentHash path overflow fallback ---
func TestComputeContentHashPathOverflow(t *testing.T) {
// path byte 0xFF = hashSize=4, hashCount=63 = 252 bytes needed, but only a few available
// payloadStart (2 + 252 = 254) > len(buf) (6), so fallback fires.
// rawHex is 12 chars (< 16), so fallback returns rawHex itself.
rawHex := "0AFF" + "AABBCCDD"
got := ComputeContentHash(rawHex)
if got != rawHex {
t.Errorf("got=%s, want fallback=%s", got, rawHex)
}
}
// --- main.go: handleMessage advert that fails ValidateAdvert ---
func TestHandleMessageCorruptedAdvertNoNode(t *testing.T) {
store, source := newTestContext(t)
// Build an ADVERT packet with all-zero pubkey (fails ValidateAdvert)
// header: 0x12 = FLOOD + ADVERT, path: 0x00
// Then 100+ bytes of zeros (pubkey all zeros)
rawHex := "1200"
for i := 0; i < 110; i++ {
rawHex += "00"
}
msg := &mockMessage{
topic: "meshcore/SJC/obs1/packets",
payload: []byte(`{"raw":"` + rawHex + `"}`),
}
handleMessage(store, "test", source, msg, nil, nil, &Config{})
var count int
if err := store.db.QueryRow("SELECT COUNT(*) FROM nodes").Scan(&count); err != nil {
t.Fatal(err)
}
if count != 0 {
t.Error("all-zero pubkey advert should not create a node")
}
}
// --- main.go: handleMessage non-advert packet (else branch) ---
func TestHandleMessageNonAdvertPacket(t *testing.T) {
store, source := newTestContext(t)
// ACK packet: header 0x0E = FLOOD + ACK(0x03)
rawHex := "0E00DEADBEEF"
msg := &mockMessage{
topic: "meshcore/SJC/obs1/packets",
payload: []byte(`{"raw":"` + rawHex + `"}`),
}
handleMessage(store, "test", source, msg, nil, nil, &Config{})
var count int
if err := store.db.QueryRow("SELECT COUNT(*) FROM transmissions").Scan(&count); err != nil {
t.Fatal(err)
}
if count != 1 {
t.Errorf("non-advert should insert transmission, got %d", count)
}
// Verify the stored packet type
var payloadType int
if err := store.db.QueryRow("SELECT payload_type FROM transmissions LIMIT 1").Scan(&payloadType); err != nil {
t.Fatal(err)
}
// ACK = type 3
if payloadType != 3 {
t.Errorf("payload_type=%d, want 3 (ACK)", payloadType)
}
if err := store.db.QueryRow("SELECT COUNT(*) FROM nodes").Scan(&count); err != nil {
t.Fatal(err)
}
if count != 0 {
t.Error("non-advert should not create nodes")
}
}
// --- decoder.go: decodeAdvert no name but sensor with telemetry ---
func TestDecodeAdvertSensorNoName(t *testing.T) {
buf := make([]byte, 108)
for i := 0; i < 32; i++ {
buf[i] = byte(i + 1)
}
for i := 36; i < 100; i++ {
buf[i] = 0xCC
}
// flags: sensor(4) = 0x04 (no hasName)
buf[100] = 0x04
// telemetry right after flags: battery=3700 (0x0E74), temp=2500 (25.00°C)
buf[101] = 0x74
buf[102] = 0x0E
buf[103] = 0xC4
buf[104] = 0x09
p := decodeAdvert(buf[:105], false)
if p.Error != "" {
t.Fatalf("error: %s", p.Error)
}
if p.Name != "" {
t.Errorf("name=%q, want empty", p.Name)
}
if p.BatteryMv == nil || *p.BatteryMv != 3700 {
t.Errorf("battery_mv=%v, want 3700", p.BatteryMv)
}
}
// --- db.go: OpenStore error path (invalid dir) ---
func TestOpenStoreInvalidPath(t *testing.T) {
// Create a regular file then try to open a DB inside it — impossible on all platforms.
f, err := os.CreateTemp(t.TempDir(), "not-a-dir")
if err != nil {
t.Fatalf("setup: %v", err)
}
f.Close()
_, err = OpenStore(filepath.Join(f.Name(), "db.sqlite"))
if err == nil {
t.Error("should error on impossible path")
}
}
// --- db.go: InsertTransmission default timestamp ---
func TestInsertTransmissionDefaultTimestamp(t *testing.T) {
store := newTestStore(t)
data := &PacketData{
RawHex: "AABB",
Hash: "default_ts_test1",
Timestamp: "", // empty → should use now
}
isNew, err := store.InsertTransmission(data)
if err != nil {
t.Fatal(err)
}
if !isNew {
t.Error("should be new")
}
}
// --- db.go: UpsertNode default timestamp ---
func TestUpsertNodeDefaultTimestamp(t *testing.T) {
store := newTestStore(t)
// Empty lastSeen → uses time.Now()
err := store.UpsertNode("pk_default_ts_00000000000000000000000000000001", "Node", "companion", nil, nil, "")
if err != nil {
t.Fatal(err)
}
var lastSeen string
if err := store.db.QueryRow("SELECT last_seen FROM nodes WHERE public_key = 'pk_default_ts_00000000000000000000000000000001'").Scan(&lastSeen); err != nil {
t.Fatal(err)
}
if lastSeen == "" {
t.Error("last_seen should be set")
}
}
// --- db.go: UpsertNode with lat/lon ---
func TestUpsertNodeWithLatLon(t *testing.T) {
store := newTestStore(t)
lat, lon := 37.0, -122.0
err := store.UpsertNode("pk_latlon_0000000000000000000000000000000001", "Node", "repeater", &lat, &lon, "2025-01-01T00:00:00Z")
if err != nil {
t.Fatal(err)
}
var gotLat, gotLon float64
if err := store.db.QueryRow("SELECT lat, lon FROM nodes WHERE public_key = 'pk_latlon_0000000000000000000000000000000001'").Scan(&gotLat, &gotLon); err != nil {
t.Fatal(err)
}
if gotLat != 37.0 || gotLon != -122.0 {
t.Errorf("lat=%f lon=%f", gotLat, gotLon)
}
}
// --- decoder.go: ComputeContentHash with transport route short after transport codes ---
func TestComputeContentHashTransportShortAfterCodes(t *testing.T) {
// Transport route (0x00), 4 bytes transport codes, but then nothing (no path byte).
// offset after transport codes = 5, which equals len(buf), so fallback fires.
// rawHex is 10 chars (< 16), so fallback returns rawHex itself.
rawHex := "00AABBCCDD"
got := ComputeContentHash(rawHex)
if got != rawHex {
t.Errorf("got=%s, want fallback=%s", got, rawHex)
}
}
// --- decoder.go: DecodePacket no path byte after transport ---
func TestDecodePacketNoPathByteAfterHeader(t *testing.T) {
// Non-transport route, but only header byte (no path byte)
// Actually 0A alone = 1 byte, but we need >= 2
// Header + exactly at offset boundary
_, err := DecodePacket("0A", nil, false)
if err == nil {
t.Error("should error - too short")
}
}
// --- decoder.go: decodeAdvert with name but no null terminator ---
func TestDecodeAdvertNameNoNull(t *testing.T) {
buf := make([]byte, 115)
for i := 0; i < 32; i++ {
buf[i] = byte(i + 1)
}
for i := 36; i < 100; i++ {
buf[i] = 0xCC
}
// flags: hasName(0x80) | type=1 = 0x81
buf[100] = 0x81
// Name without null terminator — goes to end of buffer
copy(buf[101:], []byte("LongNameNoNull"))
p := decodeAdvert(buf[:115], false)
if p.Name != "LongNameNoNull" {
t.Errorf("name=%q, want LongNameNoNull", p.Name)
}
}
// --- main.go: handleMessage channel with very long sender (>50 chars = no extraction) ---
func TestHandleMessageChannelLongSender(t *testing.T) {
store, source := newTestContext(t)
// Colon at index > 50 — should not extract sender
longText := "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA: msg"
payload := []byte(`{"text":"` + longText + `"}`)
msg := &mockMessage{topic: "meshcore/message/channel/1", payload: payload}
handleMessage(store, "test", source, msg, nil, nil, &Config{})
var count int
if err := store.db.QueryRow("SELECT COUNT(*) FROM nodes").Scan(&count); err != nil {
t.Fatal(err)
}
if count != 0 {
t.Error("long sender should not extract")
}
}
// --- main.go: handleMessage DM with long sender ---
func TestHandleMessageDirectLongSender(t *testing.T) {
store, source := newTestContext(t)
longText := "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB: msg"
payload := []byte(`{"text":"` + longText + `"}`)
msg := &mockMessage{topic: "meshcore/message/direct/abc", payload: payload}
handleMessage(store, "test", source, msg, nil, nil, &Config{})
var count int
if err := store.db.QueryRow("SELECT COUNT(*) FROM transmissions").Scan(&count); err != nil {
t.Fatal(err)
}
if count != 1 {
t.Errorf("count=%d, want 1", count)
}
}
// DM with uppercase Score and Direction (fallback branches)
func TestHandleMessageDirectUppercaseScoreDirection(t *testing.T) {
store, source := newTestContext(t)
payload := []byte(`{"text":"X: hi","Score":6,"Direction":"rx"}`)
msg := &mockMessage{topic: "meshcore/message/direct/d1", payload: payload}
handleMessage(store, "test", source, msg, nil, nil, &Config{})
var count int
if err := store.db.QueryRow("SELECT COUNT(*) FROM transmissions").Scan(&count); err != nil {
t.Fatal(err)
}
if count != 1 {
t.Errorf("count=%d, want 1", count)
}
// Verify uppercase Score/Direction were picked up
var score *float64
var direction *string
if err := store.db.QueryRow("SELECT score, direction FROM observations LIMIT 1").Scan(&score, &direction); err != nil {
t.Fatal(err)
}
if score == nil || *score != 6.0 {
t.Errorf("score=%v, want 6.0", score)
}
if direction == nil || *direction != "rx" {
t.Errorf("direction=%v, want rx", direction)
}
}
// Channel with uppercase Score and Direction
func TestHandleMessageChannelUppercaseScoreDirection(t *testing.T) {
store, source := newTestContext(t)
payload := []byte(`{"text":"Y: hi","Score":4,"Direction":"tx"}`)
msg := &mockMessage{topic: "meshcore/message/channel/5", payload: payload}
handleMessage(store, "test", source, msg, nil, nil, &Config{})
var count int
if err := store.db.QueryRow("SELECT COUNT(*) FROM transmissions").Scan(&count); err != nil {
t.Fatal(err)
}
if count != 1 {
t.Errorf("count=%d, want 1", count)
}
// Verify uppercase Score/Direction were picked up
var score *float64
var direction *string
if err := store.db.QueryRow("SELECT score, direction FROM observations LIMIT 1").Scan(&score, &direction); err != nil {
t.Fatal(err)
}
if score == nil || *score != 4.0 {
t.Errorf("score=%v, want 4.0", score)
}
if direction == nil || *direction != "tx" {
t.Errorf("direction=%v, want tx", direction)
}
}
// Raw packet with only lowercase score (no uppercase Score present)
func TestHandleMessageRawLowercaseScore(t *testing.T) {
store, source := newTestContext(t)
rawHex := "0A00D69FD7A5A7475DB07337749AE61FA53A4788E976"
payload := []byte(`{"raw":"` + rawHex + `","score":3.5}`)
msg := &mockMessage{topic: "meshcore/SJC/obs1/packets", payload: payload}
handleMessage(store, "test", source, msg, nil, nil, &Config{})
var score *float64
if err := store.db.QueryRow("SELECT score FROM observations LIMIT 1").Scan(&score); err != nil {
t.Fatal(err)
}
if score == nil || *score != 3.5 {
t.Errorf("score=%v, want 3.5", score)
}
}
// Test handleMessage status without origin (log fallback)
func TestHandleMessageStatusNoOrigin(t *testing.T) {
store, source := newTestContext(t)
msg := &mockMessage{
topic: "meshcore/LAX/obs5/status",
payload: []byte(`{"model":"L1"}`),
}
handleMessage(store, "test", source, msg, nil, nil, &Config{})
var count int
if err := store.db.QueryRow("SELECT COUNT(*) FROM observers WHERE id = 'obs5'").Scan(&count); err != nil {
t.Fatal(err)
}
if count != 1 {
t.Errorf("observer count=%d, want 1", count)
}
// Verify fallback behavior: origin is "" (no "origin" key in payload),
// so name should be stored as empty string, and IATA should be "LAX".
var name, iata string
if err := store.db.QueryRow("SELECT name, iata FROM observers WHERE id = 'obs5'").Scan(&name, &iata); err != nil {
t.Fatal(err)
}
if name != "" {
t.Errorf("name=%q, want empty (no origin provided)", name)
}
if iata != "LAX" {
t.Errorf("iata=%q, want LAX", iata)
}
}
// --- db.go: applySchema migrations run on fresh DB ---
func TestApplySchemaMigrationsOnFreshDB(t *testing.T) {
// OpenStore already runs all migrations; verify they completed
store := newTestStore(t)
// Check that migrations were recorded
var count int
if err := store.db.QueryRow("SELECT COUNT(*) FROM _migrations").Scan(&count); err != nil {
t.Fatal(err)
}
if count < 3 {
t.Errorf("expected at least 3 migrations recorded, got %d", count)
}
// Check observations table exists with dedup index
var tblName string
err := store.db.QueryRow("SELECT name FROM sqlite_master WHERE type='table' AND name='observations'").Scan(&tblName)
if err != nil {
t.Error("observations table should exist")
}
// Check inactive_nodes table exists
err = store.db.QueryRow("SELECT name FROM sqlite_master WHERE type='table' AND name='inactive_nodes'").Scan(&tblName)
if err != nil {
t.Error("inactive_nodes table should exist")
}
// Check packets_v view exists
err = store.db.QueryRow("SELECT name FROM sqlite_master WHERE type='view' AND name='packets_v'").Scan(&tblName)
if err != nil {
t.Error("packets_v view should exist")
}
}
// Test OpenStore runs successfully on existing DB (re-open)
func TestOpenStoreExistingDB(t *testing.T) {
dir := t.TempDir()
dbPath := dir + "/test.db"
// Open and close
s1, err := OpenStore(dbPath)
if err != nil {
t.Fatal(err)
}
s1.Close()
// Re-open — should skip migrations (already applied)
s2, err := OpenStore(dbPath)
if err != nil {
t.Fatal(err)
}
s2.Close()
}
// Test MoveStaleNodes with existing inactive nodes (REPLACE behavior)
func TestMoveStaleNodesReplace(t *testing.T) {
store := newTestStore(t)
pk := "stale_node_replace_0000000000000000000000000001"
// Insert into inactive_nodes first
if _, err := store.db.Exec("INSERT INTO inactive_nodes (public_key, name, role, last_seen, first_seen) VALUES (?, 'Old', 'companion', '2019-01-01T00:00:00Z', '2019-01-01T00:00:00Z')", pk); err != nil {
t.Fatal(err)
}
// Insert same node in nodes with old last_seen
store.UpsertNode(pk, "StaleNode", "repeater", nil, nil, "2020-01-01T00:00:00Z")
moved, err := store.MoveStaleNodes(7)
if err != nil {
t.Fatal(err)
}
if moved != 1 {
t.Errorf("moved=%d, want 1", moved)
}
// Should have replaced the inactive node
var name string
if err := store.db.QueryRow("SELECT name FROM inactive_nodes WHERE public_key = ?", pk).Scan(&name); err != nil {
t.Fatal(err)
}
if name != "StaleNode" {
t.Errorf("name=%s, want StaleNode (replaced)", name)
}
}
// --- decoder.go: ValidateAdvert name too long ---
func TestValidateAdvertNameTooLong(t *testing.T) {
longName := ""
for i := 0; i < 65; i++ {
longName += "A"
}
p := &Payload{PubKey: "aabbccdd00112233445566778899aabb", Name: longName}
ok, reason := ValidateAdvert(p)
if ok {
t.Error("name >64 chars should fail")
}
if reason == "" {
t.Error("should have reason")
}
}
// Test ValidateAdvert pubkey too short
func TestValidateAdvertPubkeyTooShort(t *testing.T) {
p := &Payload{PubKey: "aabb"}
ok, _ := ValidateAdvert(p)
if ok {
t.Error("short pubkey should fail")
}
}
// --- decoder.go: decodeTrace with extra path data ---
func TestDecodeTraceWithPath(t *testing.T) {
buf := make([]byte, 15)
// tag (4) + authCode (4) + flags (1) + path data (6)
buf[0] = 0x01 // tag
buf[4] = 0x02 // authCode
buf[8] = 0x03 // flags
buf[9] = 0xAA
buf[10] = 0xBB
buf[11] = 0xCC
buf[12] = 0xDD
buf[13] = 0xEE
buf[14] = 0xFF
p := decodeTrace(buf)
if p.PathData == "" {
t.Error("should have path data")
}
if p.TraceFlags == nil || *p.TraceFlags != 3 {
t.Errorf("flags=%v, want 3", p.TraceFlags)
}
}
// --- db.go: RemoveStaleObservers (soft-delete) ---
func TestRemoveStaleObservers(t *testing.T) {
store := newTestStore(t)
// Insert an observer with last_seen 30 days ago
err := store.UpsertObserver("obs-old", "OldObserver", "LAX", nil)
if err != nil {
t.Fatal(err)
}
// Override last_seen to 30 days ago
cutoff := time.Now().UTC().AddDate(0, 0, -30).Format(time.RFC3339)
_, err = store.db.Exec("UPDATE observers SET last_seen = ? WHERE id = ?", cutoff, "obs-old")
if err != nil {
t.Fatal(err)
}
// Insert a recent observer
err = store.UpsertObserver("obs-new", "NewObserver", "NYC", nil)
if err != nil {
t.Fatal(err)
}
removed, err := store.RemoveStaleObservers(14)
if err != nil {
t.Fatal(err)
}
if removed != 1 {
t.Errorf("removed=%d, want 1", removed)
}
// Observer should still be in the table (soft-delete), but marked inactive
var count int
if err := store.db.QueryRow("SELECT COUNT(*) FROM observers").Scan(&count); err != nil {
t.Fatal(err)
}
if count != 2 {
t.Errorf("observers count=%d, want 2 (soft-delete preserves row)", count)
}
// Check that the old observer is marked inactive
var inactive int
if err := store.db.QueryRow("SELECT inactive FROM observers WHERE id = ?", "obs-old").Scan(&inactive); err != nil {
t.Fatal(err)
}
if inactive != 1 {
t.Errorf("obs-old inactive=%d, want 1", inactive)
}
// Check that the recent observer is still active
var newInactive int
if err := store.db.QueryRow("SELECT inactive FROM observers WHERE id = ?", "obs-new").Scan(&newInactive); err != nil {
t.Fatal(err)
}
if newInactive != 0 {
t.Errorf("obs-new inactive=%d, want 0", newInactive)
}
}
func TestRemoveStaleObserversNone(t *testing.T) {
store := newTestStore(t)
removed, err := store.RemoveStaleObservers(14)
if err != nil {
t.Fatal(err)
}
if removed != 0 {
t.Errorf("removed=%d, want 0", removed)
}
}
func TestRemoveStaleObserversKeepForever(t *testing.T) {
store := newTestStore(t)
// Insert an old observer
err := store.UpsertObserver("obs-ancient", "AncientObserver", "LAX", nil)
if err != nil {
t.Fatal(err)
}
cutoff := time.Now().UTC().AddDate(0, 0, -365).Format(time.RFC3339)
_, err = store.db.Exec("UPDATE observers SET last_seen = ? WHERE id = ?", cutoff, "obs-ancient")
if err != nil {
t.Fatal(err)
}
// observerDays = -1 means keep forever
removed, err := store.RemoveStaleObservers(-1)
if err != nil {
t.Fatal(err)
}
if removed != 0 {
t.Errorf("removed=%d, want 0 (keep forever)", removed)
}
var count int
if err := store.db.QueryRow("SELECT COUNT(*) FROM observers").Scan(&count); err != nil {
t.Fatal(err)
}
if count != 1 {
t.Errorf("observers count=%d, want 1 (keep forever)", count)
}
// Observer should NOT be marked inactive
var inactive int
if err := store.db.QueryRow("SELECT inactive FROM observers WHERE id = ?", "obs-ancient").Scan(&inactive); err != nil {
t.Fatal(err)
}
if inactive != 0 {
t.Errorf("obs-ancient inactive=%d, want 0 (keep forever)", inactive)
}
}
func TestRemoveStaleObserversReactivation(t *testing.T) {
store := newTestStore(t)
// Insert and stale-mark an observer
err := store.UpsertObserver("obs-test", "TestObserver", "LAX", nil)
if err != nil {
t.Fatal(err)
}
cutoff := time.Now().UTC().AddDate(0, 0, -30).Format(time.RFC3339)
_, err = store.db.Exec("UPDATE observers SET last_seen = ? WHERE id = ?", cutoff, "obs-test")
if err != nil {
t.Fatal(err)
}
removed, err := store.RemoveStaleObservers(14)
if err != nil {
t.Fatal(err)
}
if removed != 1 {
t.Errorf("removed=%d, want 1", removed)
}
// Verify it's inactive
var inactive int
if err := store.db.QueryRow("SELECT inactive FROM observers WHERE id = ?", "obs-test").Scan(&inactive); err != nil {
t.Fatal(err)
}
if inactive != 1 {
t.Errorf("inactive=%d, want 1 after soft-delete", inactive)
}
// Now UpsertObserver should reactivate it
err = store.UpsertObserver("obs-test", "TestObserver", "LAX", nil)
if err != nil {
t.Fatal(err)
}
if err := store.db.QueryRow("SELECT inactive FROM observers WHERE id = ?", "obs-test").Scan(&inactive); err != nil {
t.Fatal(err)
}
if inactive != 0 {
t.Errorf("inactive=%d, want 0 after reactivation", inactive)
}
}
func TestObserverDaysOrDefault(t *testing.T) {
tests := []struct {
name string
cfg *Config
want int
}{
{"nil retention", &Config{}, 14},
{"zero observer days", &Config{Retention: &RetentionConfig{ObserverDays: 0}}, 14},
{"positive value", &Config{Retention: &RetentionConfig{ObserverDays: 30}}, 30},
{"keep forever", &Config{Retention: &RetentionConfig{ObserverDays: -1}}, -1},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := tt.cfg.ObserverDaysOrDefault()
if got != tt.want {
t.Errorf("ObserverDaysOrDefault() = %d, want %d", got, tt.want)
}
})
}
}