diff --git a/db/node_location_integration_test.go b/db/node_location_integration_test.go new file mode 100644 index 0000000..dd06001 --- /dev/null +++ b/db/node_location_integration_test.go @@ -0,0 +1,71 @@ +// Copyright 2026 Beacon Contributors +// SPDX-License-Identifier: AGPL-3.0-or-later + +package db + +import ( + "context" + "os" + "testing" + "time" + + sqlc "github.com/MeshCore-Beacon/beacon-server/db/sqlc" + "github.com/MeshCore-Beacon/beacon-server/internal/ingest" + "github.com/jackc/pgx/v5" +) + +func TestNodeLocationResetPostgres(t *testing.T) { + dsn := os.Getenv("BEACON_TEST_POSTGRES_DSN") + if dsn == "" { + t.Skip("set BEACON_TEST_POSTGRES_DSN for PostgreSQL regression") + } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + conn, err := pgx.Connect(ctx, dsn) + if err != nil { + t.Fatal(err) + } + defer conn.Close(context.Background()) + tx, err := conn.Begin(ctx) + if err != nil { + t.Fatal(err) + } + defer tx.Rollback(context.Background()) + if _, err := tx.Exec(ctx, "CREATE TEMP TABLE nodes (LIKE public.nodes INCLUDING ALL) ON COMMIT DROP"); err != nil { + t.Fatal(err) + } + store := &Store{q: sqlc.New(tx)} + lat, lon := 45.0, -75.0 + params := ingest.UpsertNodeParams{PublicKey: []byte{0x97}, NodeType: 2, Name: "location fixture", Latitude: &lat, Longitude: &lon} + id, err := store.UpsertNode(ctx, params, ingest.RadioSettings{}) + if err != nil { + t.Fatal(err) + } + for _, tc := range []struct { + name string + present bool + lat, lon float64 + }{ + {"omission preserves location", false, 45, -75}, + {"explicit reset replaces location", true, 0, 0}, + {"omission preserves reset", false, 0, 0}, + } { + t.Run(tc.name, func(t *testing.T) { + params.Latitude, params.Longitude = nil, nil + if tc.present { + params.Latitude, params.Longitude = &tc.lat, &tc.lon + } + updatedID, err := store.UpsertNode(ctx, params, ingest.RadioSettings{}) + if err != nil || updatedID != id { + t.Fatalf("node identity changed: %v", err) + } + var gotLat, gotLon float64 + if err := tx.QueryRow(ctx, "SELECT latitude,longitude FROM nodes WHERE id=$1", id).Scan(&gotLat, &gotLon); err != nil { + t.Fatal(err) + } + if gotLat != tc.lat || gotLon != tc.lon { + t.Fatalf("location = (%g,%g), want (%g,%g)", gotLat, gotLon, tc.lat, tc.lon) + } + }) + } +} diff --git a/internal/ingest/ingest_test.go b/internal/ingest/ingest_test.go index 94bd6fd..4783011 100644 --- a/internal/ingest/ingest_test.go +++ b/internal/ingest/ingest_test.go @@ -175,6 +175,7 @@ type stubDB struct { setCapabilityCalls []setCapabilityCall upsertNodeCalls int + upsertNodeParams UpsertNodeParams upsertChannelCalls int upsertChannelHashOnlyCalls int upsertChannelIATACalls int @@ -209,8 +210,9 @@ func (s *stubDB) InsertObservation(_ context.Context, _ InsertObservationParams) return s.observationInserted, nil } func (s *stubDB) SetNodeDefaultScope(_ context.Context, _ uuid.UUID, _ int32) error { return nil } -func (s *stubDB) UpsertNode(_ context.Context, _ UpsertNodeParams, _ RadioSettings) (uuid.UUID, error) { +func (s *stubDB) UpsertNode(_ context.Context, params UpsertNodeParams, _ RadioSettings) (uuid.UUID, error) { s.upsertNodeCalls++ + s.upsertNodeParams = params return uuid.Nil, nil } func (s *stubDB) UpsertNodeIATA(_ context.Context, _ uuid.UUID, _ string) error { return nil } diff --git a/internal/ingest/side_effects.go b/internal/ingest/side_effects.go index e802f0c..f465c99 100644 --- a/internal/ingest/side_effects.go +++ b/internal/ingest/side_effects.go @@ -80,7 +80,8 @@ func (w *Worker) handlePayloadTypeSideEffects(ctx context.Context, packet *meshc return } var lat, lon *float64 - if advert.AppData().Lat != 0 || advert.AppData().Lon != 0 { + // Presence, not value: an explicit 0/0 advert resets the stored location. + if advert.Flags()&meshcore.AdvertLatLonMask != 0 { la := float64(advert.AppData().Lat) / 1e6 lo := float64(advert.AppData().Lon) / 1e6 lat = &la diff --git a/internal/ingest/side_effects_test.go b/internal/ingest/side_effects_test.go index 25bdb2d..782b79f 100644 --- a/internal/ingest/side_effects_test.go +++ b/internal/ingest/side_effects_test.go @@ -6,6 +6,7 @@ package ingest import ( "context" "crypto/ed25519" + "encoding/binary" "encoding/hex" "encoding/json" "testing" @@ -31,6 +32,10 @@ func (k *mapKeys) GetKey(hash []byte) []keystore.Entry { // buildAdvertPacket signs (or, if tamper is true, signs then mutates) an // advert payload and wraps it in a minimal Packet with no path (zero-hop). func buildAdvertPacket(t *testing.T, tamper bool) *meshcore.Packet { + return buildAdvertPacketWithData(t, []byte{meshcore.AdvertTypeRepeater}, tamper) +} + +func buildAdvertPacketWithData(t *testing.T, data []byte, tamper bool) *meshcore.Packet { t.Helper() pub, priv, err := ed25519.GenerateKey(nil) if err != nil { @@ -43,7 +48,7 @@ func buildAdvertPacket(t *testing.T, tamper bool) *meshcore.Packet { advert := &meshcore.Advert{ PublicKey: id, Timestamp: 12345, - RawAppData: []byte{meshcore.AdvertTypeRepeater}, // flags byte only, no optional fields + RawAppData: data, } advert.Sign(priv) if tamper { @@ -61,6 +66,54 @@ func buildAdvertPacket(t *testing.T, tamper bool) *meshcore.Packet { } } +func TestAdvertLocationPresence(t *testing.T) { + for _, tc := range []struct { + name string + present, tamper bool + lat, lon int32 + }{ + {"ordinary location", true, false, 45000000, -75000000}, + {"explicit reset", true, false, 0, 0}, + {"zero latitude", true, false, 0, -75000000}, + {"zero longitude", true, false, 45000000, 0}, + {"no location", false, false, 0, 0}, + {"tampered reset", true, true, 0, 0}, + } { + t.Run(tc.name, func(t *testing.T) { + data := []byte{meshcore.AdvertTypeRepeater} + if tc.present { + // Preserve the wire presence bit even for zero coordinates; the + // library's AppData encoder omits location when both values are zero. + data[0] |= meshcore.AdvertLatLonMask + data = binary.LittleEndian.AppendUint32(data, uint32(tc.lat)) + data = binary.LittleEndian.AppendUint32(data, uint32(tc.lon)) + } + worker, store := newTestWorker() + packet := buildAdvertPacketWithData(t, data, tc.tamper) + worker.handlePayloadTypeSideEffects(context.Background(), packet, "YOW", []byte{1}, RadioSettings{}, nil, nil, nil, 0) + if tc.tamper { + if store.upsertNodeCalls != 0 { + t.Fatal("invalid signature updated the node") + } + return + } + if store.upsertNodeCalls != 1 { + t.Fatal("signed advert did not update the node") + } + got := store.upsertNodeParams + if !tc.present { + if got.Latitude != nil || got.Longitude != nil { + t.Fatal("absent location became an update") + } + return + } + if got.Latitude == nil || got.Longitude == nil || *got.Latitude != float64(tc.lat)/1e6 || *got.Longitude != float64(tc.lon)/1e6 { + t.Fatal("advertised coordinates did not reach the node update") + } + }) + } +} + func TestHandlePayloadTypeSideEffects_Advert_ValidSignature_UpsertsNode(t *testing.T) { w, db := newTestWorker() packet := buildAdvertPacket(t, false)