mirror of
https://github.com/MeshCore-Beacon/beacon-server.git
synced 2026-10-23 00:41:29 +00:00
feat(routes): add bounded retained observation evidence
This commit is contained in:
1 parent
b8a8188fbc
commit
c448a04ea0
24 files changed
+1319
-4
No files matched your search
@@ -55,7 +55,7 @@ jobs:
|
||||
- name: Verify stats and endpoint queries against PostgreSQL 16
|
||||
env:
|
||||
BEACON_TEST_POSTGRES_DSN: postgres://postgres:backup-ci-only@127.0.0.1:5432/postgres?sslmode=disable
|
||||
run: go test ./db -run '^Test(Signal|Paths|PacketEndpointsResolveLive|ObserverMetrics|AnalyticsRetention|AnalyticsRetentionConcurrent|DeleteOldPacketsBatches)Postgres$' -count=1 -v
|
||||
run: go test ./db -run '^Test(Signal|Paths|PacketEndpointsResolveLive|ObserverMetrics|RouteEvidence|RouteEvidenceIndex|AnalyticsRetention|AnalyticsRetentionConcurrent|DeleteOldPacketsBatches)Postgres$' -count=1 -v
|
||||
|
||||
- name: Verify backup command against PostgreSQL 16
|
||||
run: |
|
||||
|
||||
@@ -569,3 +569,13 @@ updated separately; this cannot reconstruct previously overwritten timestamps.
|
||||
The initial analytics archive migration includes unknown-type activity. Existing
|
||||
development previews that used the earlier draft require a separate operator repair;
|
||||
radio samples already discarded for those legacy rows cannot be recovered.
|
||||
|
||||
### Saved-route observation evidence
|
||||
|
||||
Known-route responses include `pathKey`, a stable identity within the route's IATA. Use `GET /api/v1/routes/{iata}/{pathKey}/observations` to fetch the **full saved route** and retained report references. Search results can contain a subsegment while sharing the full route's key; the evidence response always describes the complete saved sequence.
|
||||
|
||||
The match requires the complete saved `pathBytes`, `hashSize`, hop count and IATA. A compact digest index narrows candidates, but full bytes are still compared. Other hash widths, TRACE readings/intended routes and unclassified legacy observations are excluded. Matching short prefixes does not confirm historical node identities, forwarding or delivery. The stored route counter can include repeated processing and outlive raw reports; it is not a retained-result total.
|
||||
|
||||
`range` defaults to `24h` and accepts durations up to `720h`, anchored on the server. Alternatively supply both `since` and exclusive `until` in epoch milliseconds, with a maximum 30-day span and no future end. `limit` defaults to 50 and is capped at 200. Follow `nextPageCursor` as `pageCursor`; its route, window and microsecond/ID boundary are pinned. Do not combine it with another range, or change its explicit window. Numeric legacy `cursor` is unsupported. Responses include effective window bounds, `matchAvailable`, an empty `items` array when no matching raw evidence remains, and `hasMore`; no total-count scan or packet/message body is added. Malformed saved path metadata is explicitly unavailable, and missing routes return 404.
|
||||
|
||||
Migration 041 builds the compact observation index concurrently. Keep it as a single statement outside a transaction; the existing runner handles an interrupted or already-built index before recording completion. It does not alter retained rows or expiry configuration. Native PostgreSQL tests cover ties below millisecond precision, cursor scope, different widths/sites, TRACE/unknown exclusions, raw expiry, index retry and custom/generic indexed plans.
|
||||
@@ -0,0 +1,9 @@
|
||||
-- Copyright 2026 Beacon Contributors
|
||||
-- SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
-- One statement outside a transaction. RunMigrations recovers interrupted builds;
|
||||
-- avoid IF NOT EXISTS, which would silently accept an invalid index.
|
||||
-- A compact digest narrows candidates; queries still compare the complete bytes.
|
||||
CREATE INDEX CONCURRENTLY idx_observations_route_evidence
|
||||
ON packet_observations (iata, hash_size, (decode(md5(path_bytes), 'hex')), heard_at DESC, id DESC)
|
||||
WHERE path_bytes IS NOT NULL AND payload_type IS NOT NULL AND payload_type <> 9;
|
||||
@@ -0,0 +1,22 @@
|
||||
-- name: GetRouteEvidenceRoute :one
|
||||
SELECT * FROM known_routes
|
||||
WHERE iata = @iata::bpchar AND path_key = @path_key::bytea;
|
||||
|
||||
-- name: ListRouteEvidence :many
|
||||
-- Leading index equalities and the time/ID boundary bound both custom and generic plans.
|
||||
-- Full-byte equality is required even when the compact digest matches. TRACE path bytes
|
||||
-- carry readings; unclassified legacy observations cannot be safely called ordinary paths.
|
||||
SELECT po.id, po.packet_hash, po.observer_id, o.display_name AS observer_name,
|
||||
po.heard_at, po.payload_type, po.rssi, po.snr
|
||||
FROM packet_observations po
|
||||
JOIN observers o ON o.id = po.observer_id
|
||||
WHERE po.iata = @iata::bpchar
|
||||
AND po.hash_size = @hash_size::smallint
|
||||
AND decode(md5(po.path_bytes), 'hex') = @path_digest::bytea
|
||||
AND po.path_bytes = @path_bytes::bytea
|
||||
AND po.hop_count = @hop_count::smallint
|
||||
AND po.path_bytes IS NOT NULL AND po.payload_type IS NOT NULL AND po.payload_type <> 9
|
||||
AND po.heard_at >= @since::timestamptz AND po.heard_at < @until::timestamptz
|
||||
AND (po.heard_at, po.id) < (@before_at::timestamptz, @before_id::bigint)
|
||||
ORDER BY po.heard_at DESC, po.id DESC
|
||||
LIMIT @page_limit::integer;
|
||||
@@ -0,0 +1,93 @@
|
||||
// Copyright 2026 Beacon Contributors
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
package db
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"encoding/hex"
|
||||
"math"
|
||||
"time"
|
||||
|
||||
sqlc "github.com/MeshCore-Beacon/beacon-server/db/sqlc"
|
||||
"github.com/MeshCore-Beacon/beacon-server/internal/api"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
func savedRoutePath(prefixes [][]byte, count int32) (int16, []byte, bool) {
|
||||
if count < 2 || count > 63 || len(prefixes) != int(count) {
|
||||
return 0, nil, false
|
||||
}
|
||||
width := len(prefixes[0])
|
||||
if width < 1 || width > 3 {
|
||||
return 0, nil, false
|
||||
}
|
||||
for _, p := range prefixes {
|
||||
if len(p) != width {
|
||||
return 0, nil, false
|
||||
}
|
||||
}
|
||||
return int16(width), bytes.Join(prefixes, nil), true
|
||||
}
|
||||
|
||||
func (s *Store) GetRouteEvidence(ctx context.Context, iata, key string, query api.RouteEvidenceQuery) (*api.RouteEvidence, error) {
|
||||
if !api.ValidRouteEvidenceKey(iata, key) || !api.ValidRouteEvidenceWindow(query.Since, query.Until) || query.Limit < 1 || query.Limit > 200 {
|
||||
return nil, api.ErrRouteEvidenceInput
|
||||
}
|
||||
if c := query.Cursor; c != nil {
|
||||
if c.IATA != iata || c.PathKey != key || !c.Since.Equal(query.Since) || !c.Until.Equal(query.Until) || c.ID <= 0 || c.HeardAt.Before(query.Since) || !c.HeardAt.Before(query.Until) {
|
||||
return nil, api.ErrRouteEvidenceInput
|
||||
}
|
||||
}
|
||||
keyBytes, _ := hex.DecodeString(key)
|
||||
row, err := s.q.GetRouteEvidenceRoute(ctx, sqlc.GetRouteEvidenceRouteParams{Iata: iata, PathKey: keyBytes})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
nodes, err := s.GetNodesByIDs(ctx, row.NodeIds)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
route := toKnownRoutes([]knownRouteRow{{ID: row.ID, NodeIds: row.NodeIds, HashPrefix: row.HashPrefix, Iata: row.Iata, HopCount: row.HopCount, FirstSeen: row.FirstSeen, LastSeen: row.LastSeen, ObservationCount: row.ObservationCount}}, nodes)[0]
|
||||
route.PathKey = hex.EncodeToString(row.PathKey)
|
||||
out := &api.RouteEvidence{Page: api.Page[api.RouteObservation]{Items: []api.RouteObservation{}}, Route: route, WindowStart: query.Since.UnixMilli(), WindowEnd: query.Until.UnixMilli(), GeneratedAt: time.Now().UnixMilli(), MatchType: "saved_path_prefixes"}
|
||||
width, path, valid := savedRoutePath(row.HashPrefix, row.HopCount)
|
||||
if !valid {
|
||||
return out, nil
|
||||
}
|
||||
out.MatchAvailable = true
|
||||
out.HashSize = width
|
||||
out.PathBytes = hex.EncodeToString(path)
|
||||
beforeAt, beforeID := query.Until, int64(math.MaxInt64)
|
||||
if query.Cursor != nil {
|
||||
beforeAt, beforeID = query.Cursor.HeardAt, query.Cursor.ID
|
||||
}
|
||||
ts := func(t time.Time) pgtype.Timestamptz { return pgtype.Timestamptz{Time: t, Valid: true} }
|
||||
digest := md5.Sum(path)
|
||||
rows, err := s.q.ListRouteEvidence(ctx, sqlc.ListRouteEvidenceParams{Iata: iata, HashSize: width, HopCount: int16(row.HopCount), PathDigest: digest[:], PathBytes: path, Since: ts(query.Since), Until: ts(query.Until), BeforeAt: ts(beforeAt), BeforeID: beforeID, PageLimit: query.Limit + 1})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out.HasMore = len(rows) > int(query.Limit)
|
||||
if out.HasMore {
|
||||
rows = rows[:query.Limit]
|
||||
}
|
||||
for _, r := range rows {
|
||||
payload := int16(-1)
|
||||
if r.PayloadType != nil {
|
||||
payload = *r.PayloadType
|
||||
}
|
||||
snr := r.Snr
|
||||
if snr != nil && (math.IsNaN(float64(*snr)) || math.IsInf(float64(*snr), 0)) {
|
||||
snr = nil
|
||||
}
|
||||
out.Items = append(out.Items, api.RouteObservation{ID: r.ID, PacketHash: hex.EncodeToString(r.PacketHash), ObserverID: r.ObserverID, ObserverName: r.ObserverName, HeardAt: r.HeardAt.Time.UnixMilli(), PayloadType: payload, PayloadTypeName: api.PayloadTypeName(payload), RSSI: r.Rssi, SNR: snr})
|
||||
}
|
||||
if out.HasMore {
|
||||
last := rows[len(rows)-1]
|
||||
cursor := (api.RouteEvidenceCursor{IATA: iata, PathKey: key, Since: query.Since, Until: query.Until, HeardAt: last.HeardAt.Time, ID: last.ID}).String()
|
||||
out.NextPageCursor = &cursor
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
// Copyright 2026 Beacon Contributors
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
sqlc "github.com/MeshCore-Beacon/beacon-server/db/sqlc"
|
||||
"github.com/MeshCore-Beacon/beacon-server/internal/api"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
type evidenceCapture struct {
|
||||
pgx.Tx
|
||||
statement string
|
||||
args []any
|
||||
}
|
||||
|
||||
func (c *evidenceCapture) Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error) {
|
||||
if strings.Contains(sql, "-- name: ListRouteEvidence") {
|
||||
c.statement = sql
|
||||
c.args = args
|
||||
}
|
||||
return c.Tx.Query(ctx, sql, args...)
|
||||
}
|
||||
|
||||
func TestRouteEvidencePostgres(t *testing.T) {
|
||||
ctx, tx := retentionTx(t)
|
||||
analyticsTables(t, ctx, tx)
|
||||
ddl, err := migrationFiles.ReadFile("migrations/024_known_routes_pathkey.sql")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
text := string(ddl)
|
||||
start := strings.Index(text, "CREATE TABLE known_routes_new (")
|
||||
end := start + strings.Index(text[start:], "\n);") + 3
|
||||
table := strings.ReplaceAll(text[start:end], "known_routes_new", "known_routes")
|
||||
_, err = tx.Exec(ctx, `CREATE TABLE iata_codes (iata char(3) PRIMARY KEY); INSERT INTO iata_codes VALUES ('YOW'),('YVR');
|
||||
ALTER TABLE nodes ADD COLUMN latitude double precision, ADD COLUMN longitude double precision;`+table+`
|
||||
INSERT INTO observers(id,display_name) VALUES ('00000000-0000-0000-0000-000000000001','One'),('00000000-0000-0000-0000-000000000002','Two');
|
||||
INSERT INTO nodes(id,public_key,name) VALUES ('00000000-0000-0000-0000-000000000001','\xaa','A'),('00000000-0000-0000-0000-000000000002','\xbb','B');
|
||||
INSERT INTO packets(packet_hash) SELECT int4send(i) FROM generate_series(1,10) i;`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
index, err := migrationFiles.ReadFile("migrations/041_route_evidence_index.sql")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, strings.Replace(string(index), "CREATE INDEX CONCURRENTLY", "CREATE INDEX", 1)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
capture := &evidenceCapture{Tx: tx}
|
||||
store := &Store{q: sqlc.New(capture)}
|
||||
ids := []uuid.UUID{uuid.MustParse("00000000-0000-0000-0000-000000000001"), uuid.MustParse("00000000-0000-0000-0000-000000000002")}
|
||||
if err := store.UpsertKnownRoute(ctx, ids, [][]byte{{0xaa}, {0xbb}}, "YOW", 2); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
key := hex.EncodeToString(routePathKey(ids))
|
||||
since := time.Now().UTC().Truncate(time.Millisecond).Add(-time.Hour)
|
||||
until := since.Add(time.Hour)
|
||||
at := since.Add(time.Minute + 123456*time.Microsecond)
|
||||
_, err = tx.Exec(ctx, `INSERT INTO packet_observations(id,packet_hash,observer_id,iata,heard_at,hash_size,hop_count,path_bytes,payload_type,snr)
|
||||
SELECT i,int4send(i), '00000000-0000-0000-0000-000000000001',CASE WHEN i=5 THEN 'YVR' ELSE 'YOW' END,
|
||||
CASE WHEN i=9 THEN $2::timestamptz WHEN i=1 THEN $1::timestamptz-interval '2 microseconds' ELSE $1::timestamptz END,
|
||||
CASE WHEN i=4 THEN 2 ELSE 1 END,CASE WHEN i=4 THEN 1 WHEN i=10 THEN 3 ELSE 2 END,
|
||||
CASE WHEN i=6 THEN '\xabbb'::bytea WHEN i=10 THEN '\xaabbcc'::bytea ELSE '\xaabb'::bytea END,
|
||||
CASE WHEN i=7 THEN 9 WHEN i=8 THEN NULL ELSE 4 END,CASE WHEN i=1 THEN 'NaN'::real ELSE 0 END FROM generate_series(1,10) i;
|
||||
`, at, until)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = tx.Exec(ctx, `INSERT INTO packet_observations(id,packet_hash,observer_id,iata,heard_at,hash_size,hop_count,path_bytes,payload_type) VALUES (11,int4send(1),'00000000-0000-0000-0000-000000000002','YOW',$1,1,2,'\xaabb',4)`, at.Add(-time.Microsecond)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
query := api.RouteEvidenceQuery{Since: since, Until: until, Limit: 2}
|
||||
first, err := store.GetRouteEvidence(ctx, "YOW", key, query)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !first.HasMore || first.NextPageCursor == nil || len(first.Items) != 2 || first.Items[0].ID != 3 || first.Items[1].ID != 2 || first.Route.PathKey != key || first.HashSize != 1 || first.PathBytes != "aabb" {
|
||||
t.Fatalf("first page: %+v", first)
|
||||
}
|
||||
query.Cursor, err = api.ParseRouteEvidenceCursor(*first.NextPageCursor)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, err := store.GetRouteEvidence(ctx, "YOW", key, query)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if second.HasMore || len(second.Items) != 2 || second.Items[0].ID != 11 || second.Items[1].ID != 1 || second.Items[1].SNR != nil {
|
||||
t.Fatalf("precise second page: %+v", second)
|
||||
}
|
||||
if _, err = json.Marshal(second); err != nil {
|
||||
t.Fatalf("non-finite JSON: %v", err)
|
||||
}
|
||||
query.Cursor.IATA = "YVR"
|
||||
if _, err = store.GetRouteEvidence(ctx, "YOW", key, query); !errors.Is(err, api.ErrRouteEvidenceInput) {
|
||||
t.Fatal("cross-route cursor accepted")
|
||||
}
|
||||
query.Cursor = nil
|
||||
if _, err = store.GetRouteEvidence(ctx, "YVR", key, query); !errors.Is(err, pgx.ErrNoRows) {
|
||||
t.Fatal("unknown route did not return not-found")
|
||||
}
|
||||
if _, err = tx.Exec(ctx, "DELETE FROM packets"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
empty, err := store.GetRouteEvidence(ctx, "YOW", key, query)
|
||||
if err != nil || !empty.MatchAvailable || len(empty.Items) != 0 || empty.HasMore {
|
||||
t.Fatalf("expired evidence: %+v %v", empty, err)
|
||||
}
|
||||
|
||||
// Many unrelated paths must not turn this into a raw-table or route-ID scan.
|
||||
started := time.Now()
|
||||
_, err = tx.Exec(ctx, `INSERT INTO packets(packet_hash) SELECT int4send(i) FROM generate_series(100,200099) i;`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = tx.Exec(ctx, `INSERT INTO packet_observations(packet_hash,observer_id,iata,heard_at,hash_size,hop_count,path_bytes,payload_type)
|
||||
SELECT packet_hash,'00000000-0000-0000-0000-000000000001','YOW',$1,1,2,
|
||||
CASE WHEN get_byte(packet_hash,3)=0 THEN '\xaabb'::bytea ELSE packet_hash END,4 FROM packets;`, at)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = tx.Exec(ctx, "ANALYZE packet_observations; ANALYZE observers;"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, mode := range []string{"force_custom_plan", "force_generic_plan"} {
|
||||
if _, err = tx.Exec(ctx, "SET LOCAL plan_cache_mode = "+mode); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result, err := store.GetRouteEvidence(ctx, "YOW", key, query)
|
||||
if err != nil || len(result.Items) != 2 {
|
||||
t.Fatalf("indexed read: %v", err)
|
||||
}
|
||||
var plan string
|
||||
if err = tx.QueryRow(ctx, "EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) "+capture.statement, capture.args...).Scan(&plan); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(plan, "idx_observations_route_evidence") {
|
||||
t.Fatalf("missing evidence index: %s", plan)
|
||||
}
|
||||
t.Logf("%s plan: %s", mode, plan)
|
||||
}
|
||||
t.Logf("200000-row fixture insert/analyze/read: %s", time.Since(started))
|
||||
if _, err = tx.Exec(ctx, `UPDATE known_routes SET hash_prefix=ARRAY['\xaa'::bytea]`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
unavailable, err := store.GetRouteEvidence(ctx, "YOW", key, query)
|
||||
if err != nil || unavailable.MatchAvailable || len(unavailable.Items) != 0 {
|
||||
t.Fatalf("malformed stored path: %+v %v", unavailable, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouteEvidenceIndexPostgres(t *testing.T) {
|
||||
dsn := os.Getenv("BEACON_TEST_POSTGRES_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("set BEACON_TEST_POSTGRES_DSN")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
|
||||
defer cancel()
|
||||
conn, err := pgx.Connect(ctx, dsn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer conn.Close(context.Background())
|
||||
schema := fmt.Sprintf("route_evidence_%d", time.Now().UnixNano())
|
||||
ident := pgx.Identifier{schema}.Sanitize()
|
||||
if _, err = conn.Exec(ctx, "CREATE SCHEMA "+ident+"; SET search_path TO "+ident); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _, _ = conn.Exec(context.Background(), "DROP SCHEMA "+ident+" CASCADE") }()
|
||||
if _, err = conn.Exec(ctx, `CREATE TABLE packet_observations (id bigint,iata char(3),hash_size smallint,path_bytes bytea,heard_at timestamptz,payload_type smallint)`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
migration, err := migrationFiles.ReadFile("migrations/041_route_evidence_index.sql")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := 0; i < 2; i++ {
|
||||
if err = applyMigration(ctx, conn, string(migration)); err != nil {
|
||||
t.Fatalf("migration/retry: %v", err)
|
||||
}
|
||||
}
|
||||
if _, err = conn.Exec(ctx, `UPDATE pg_index SET indisvalid=false WHERE indexrelid='idx_observations_route_evidence'::regclass`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = applyMigration(ctx, conn, string(migration)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var valid bool
|
||||
if err = conn.QueryRow(ctx, `SELECT indisvalid FROM pg_index WHERE indexrelid='idx_observations_route_evidence'::regclass`).Scan(&valid); err != nil || !valid {
|
||||
t.Fatalf("invalid index after recovery: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
// Copyright 2026 Beacon Contributors
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
package db
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSavedRoutePath(t *testing.T) {
|
||||
size, path, ok := savedRoutePath([][]byte{{0xaa}, {0xbb}}, 2)
|
||||
if !ok || size != 1 || !bytes.Equal(path, []byte{0xaa, 0xbb}) {
|
||||
t.Fatal("valid path lost")
|
||||
}
|
||||
for _, prefixes := range [][][]byte{nil, {{1}}, {{1}, {2, 3}}, {{}, {2}}, {{1, 2, 3, 4}, {5, 6, 7, 8}}} {
|
||||
if _, _, ok := savedRoutePath(prefixes, int32(len(prefixes))); ok {
|
||||
t.Fatalf("accepted malformed path %x", prefixes)
|
||||
}
|
||||
}
|
||||
if _, _, ok := savedRoutePath([][]byte{{1}, {2}}, 3); ok {
|
||||
t.Fatal("accepted inconsistent hop count")
|
||||
}
|
||||
}
|
||||
+4
-2
@@ -118,6 +118,7 @@ func (s *Store) SearchKnownRoutes(ctx context.Context, iata, fromHash, toHash st
|
||||
hops = append(hops, hop)
|
||||
}
|
||||
items = append(items, api.KnownRoute{
|
||||
PathKey: hex.EncodeToString(routePathKey(r.NodeIds)),
|
||||
ID: r.ID,
|
||||
IATA: r.Iata,
|
||||
HopCount: int32(len(hops)),
|
||||
@@ -223,7 +224,7 @@ func (s *Store) SearchCrossIATARoutes(ctx context.Context, fromHash, fromIATA, t
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// 5. find cross-IATA links — nodes at the boundary of source routes
|
||||
// 5. find cross-IATA links — nodes at the boundary of source routes
|
||||
// that have neighbors in the target IATA at the start of target routes
|
||||
var results []api.CrossIATARoute
|
||||
|
||||
@@ -249,7 +250,7 @@ func (s *Store) SearchCrossIATARoutes(ctx context.Context, fromHash, fromIATA, t
|
||||
continue
|
||||
}
|
||||
if targetHops, ok := targetNodeSet[neighbor.ID]; ok {
|
||||
// found a cross-IATA link — build the route
|
||||
// found a cross-IATA link — build the route
|
||||
sourceSegment := sr.Hops[:i+1]
|
||||
targetSegment := extractFromNode(targetHops, neighbor.ID)
|
||||
|
||||
@@ -348,6 +349,7 @@ func toKnownRoutes(rows []knownRouteRow, nodes map[uuid.UUID]*api.ResolvedNode)
|
||||
hops = append(hops, hop)
|
||||
}
|
||||
items = append(items, api.KnownRoute{
|
||||
PathKey: hex.EncodeToString(routePathKey(r.NodeIds)),
|
||||
ID: r.ID,
|
||||
IATA: r.Iata,
|
||||
HopCount: r.HopCount,
|
||||
|
||||
@@ -684,6 +684,21 @@ func (mr *MockQuerierMockRecorder) GetRegionIATAs(ctx, regionID any) *gomock.Cal
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetRegionIATAs", reflect.TypeOf((*MockQuerier)(nil).GetRegionIATAs), ctx, regionID)
|
||||
}
|
||||
|
||||
// GetRouteEvidenceRoute mocks base method.
|
||||
func (m *MockQuerier) GetRouteEvidenceRoute(ctx context.Context, arg db.GetRouteEvidenceRouteParams) (db.KnownRoute, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "GetRouteEvidenceRoute", ctx, arg)
|
||||
ret0, _ := ret[0].(db.KnownRoute)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// GetRouteEvidenceRoute indicates an expected call of GetRouteEvidenceRoute.
|
||||
func (mr *MockQuerierMockRecorder) GetRouteEvidenceRoute(ctx, arg any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetRouteEvidenceRoute", reflect.TypeOf((*MockQuerier)(nil).GetRouteEvidenceRoute), ctx, arg)
|
||||
}
|
||||
|
||||
// GetScopeByName mocks base method.
|
||||
func (m *MockQuerier) GetScopeByName(ctx context.Context, name string) (db.GetScopeByNameRow, error) {
|
||||
m.ctrl.T.Helper()
|
||||
@@ -1223,6 +1238,21 @@ func (mr *MockQuerierMockRecorder) ListRegions(ctx any) *gomock.Call {
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListRegions", reflect.TypeOf((*MockQuerier)(nil).ListRegions), ctx)
|
||||
}
|
||||
|
||||
// ListRouteEvidence mocks base method.
|
||||
func (m *MockQuerier) ListRouteEvidence(ctx context.Context, arg db.ListRouteEvidenceParams) ([]db.ListRouteEvidenceRow, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "ListRouteEvidence", ctx, arg)
|
||||
ret0, _ := ret[0].([]db.ListRouteEvidenceRow)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// ListRouteEvidence indicates an expected call of ListRouteEvidence.
|
||||
func (mr *MockQuerierMockRecorder) ListRouteEvidence(ctx, arg any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListRouteEvidence", reflect.TypeOf((*MockQuerier)(nil).ListRouteEvidence), ctx, arg)
|
||||
}
|
||||
|
||||
// ListTraceTags mocks base method.
|
||||
func (m *MockQuerier) ListTraceTags(ctx context.Context, arg db.ListTraceTagsParams) ([]db.ListTraceTagsRow, error) {
|
||||
m.ctrl.T.Helper()
|
||||
|
||||
@@ -87,6 +87,7 @@ type Querier interface {
|
||||
GetRegion(ctx context.Context, id int32) (GetRegionRow, error)
|
||||
GetRegionBySlug(ctx context.Context, slug string) (GetRegionBySlugRow, error)
|
||||
GetRegionIATAs(ctx context.Context, regionID int32) ([]string, error)
|
||||
GetRouteEvidenceRoute(ctx context.Context, arg GetRouteEvidenceRouteParams) (KnownRoute, error)
|
||||
GetScopeByName(ctx context.Context, name string) (GetScopeByNameRow, error)
|
||||
GetScopeNames(ctx context.Context) ([]string, error)
|
||||
// Aggregate matching observations once, separately from node memberships to avoid
|
||||
@@ -198,6 +199,10 @@ type Querier interface {
|
||||
// REGIONS
|
||||
// ============================================================
|
||||
ListRegions(ctx context.Context) ([]ListRegionsRow, error)
|
||||
// Leading index equalities and the time/ID boundary bound both custom and generic plans.
|
||||
// Full-byte equality is required even when the compact digest matches. TRACE path bytes
|
||||
// carry readings; unclassified legacy observations cannot be safely called ordinary paths.
|
||||
ListRouteEvidence(ctx context.Context, arg ListRouteEvidenceParams) ([]ListRouteEvidenceRow, error)
|
||||
// ============================================================
|
||||
// TRACES
|
||||
// ============================================================
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
// source: route_evidence.sql
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
const getRouteEvidenceRoute = `-- name: GetRouteEvidenceRoute :one
|
||||
SELECT id, path_key, node_ids, hash_prefix, iata, hop_count, first_seen, last_seen, observation_count, last_reconfirmed_at FROM known_routes
|
||||
WHERE iata = $1::bpchar AND path_key = $2::bytea
|
||||
`
|
||||
|
||||
type GetRouteEvidenceRouteParams struct {
|
||||
Iata string `json:"iata"`
|
||||
PathKey []byte `json:"path_key"`
|
||||
}
|
||||
|
||||
func (q *Queries) GetRouteEvidenceRoute(ctx context.Context, arg GetRouteEvidenceRouteParams) (KnownRoute, error) {
|
||||
row := q.db.QueryRow(ctx, getRouteEvidenceRoute, arg.Iata, arg.PathKey)
|
||||
var i KnownRoute
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.PathKey,
|
||||
&i.NodeIds,
|
||||
&i.HashPrefix,
|
||||
&i.Iata,
|
||||
&i.HopCount,
|
||||
&i.FirstSeen,
|
||||
&i.LastSeen,
|
||||
&i.ObservationCount,
|
||||
&i.LastReconfirmedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const listRouteEvidence = `-- name: ListRouteEvidence :many
|
||||
SELECT po.id, po.packet_hash, po.observer_id, o.display_name AS observer_name,
|
||||
po.heard_at, po.payload_type, po.rssi, po.snr
|
||||
FROM packet_observations po
|
||||
JOIN observers o ON o.id = po.observer_id
|
||||
WHERE po.iata = $1::bpchar
|
||||
AND po.hash_size = $2::smallint
|
||||
AND decode(md5(po.path_bytes), 'hex') = $3::bytea
|
||||
AND po.path_bytes = $4::bytea
|
||||
AND po.hop_count = $5::smallint
|
||||
AND po.path_bytes IS NOT NULL AND po.payload_type IS NOT NULL AND po.payload_type <> 9
|
||||
AND po.heard_at >= $6::timestamptz AND po.heard_at < $7::timestamptz
|
||||
AND (po.heard_at, po.id) < ($8::timestamptz, $9::bigint)
|
||||
ORDER BY po.heard_at DESC, po.id DESC
|
||||
LIMIT $10::integer
|
||||
`
|
||||
|
||||
type ListRouteEvidenceParams struct {
|
||||
Iata string `json:"iata"`
|
||||
HashSize int16 `json:"hash_size"`
|
||||
PathDigest []byte `json:"path_digest"`
|
||||
PathBytes []byte `json:"path_bytes"`
|
||||
HopCount int16 `json:"hop_count"`
|
||||
Since pgtype.Timestamptz `json:"since"`
|
||||
Until pgtype.Timestamptz `json:"until"`
|
||||
BeforeAt pgtype.Timestamptz `json:"before_at"`
|
||||
BeforeID int64 `json:"before_id"`
|
||||
PageLimit int32 `json:"page_limit"`
|
||||
}
|
||||
|
||||
type ListRouteEvidenceRow struct {
|
||||
ID int64 `json:"id"`
|
||||
PacketHash []byte `json:"packet_hash"`
|
||||
ObserverID uuid.UUID `json:"observer_id"`
|
||||
ObserverName *string `json:"observer_name"`
|
||||
HeardAt pgtype.Timestamptz `json:"heard_at"`
|
||||
PayloadType *int16 `json:"payload_type"`
|
||||
Rssi *int16 `json:"rssi"`
|
||||
Snr *float32 `json:"snr"`
|
||||
}
|
||||
|
||||
// Leading index equalities and the time/ID boundary bound both custom and generic plans.
|
||||
// Full-byte equality is required even when the compact digest matches. TRACE path bytes
|
||||
// carry readings; unclassified legacy observations cannot be safely called ordinary paths.
|
||||
func (q *Queries) ListRouteEvidence(ctx context.Context, arg ListRouteEvidenceParams) ([]ListRouteEvidenceRow, error) {
|
||||
rows, err := q.db.Query(ctx, listRouteEvidence,
|
||||
arg.Iata,
|
||||
arg.HashSize,
|
||||
arg.PathDigest,
|
||||
arg.PathBytes,
|
||||
arg.HopCount,
|
||||
arg.Since,
|
||||
arg.Until,
|
||||
arg.BeforeAt,
|
||||
arg.BeforeID,
|
||||
arg.PageLimit,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []ListRouteEvidenceRow{}
|
||||
for rows.Next() {
|
||||
var i ListRouteEvidenceRow
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.PacketHash,
|
||||
&i.ObserverID,
|
||||
&i.ObserverName,
|
||||
&i.HeardAt,
|
||||
&i.PayloadType,
|
||||
&i.Rssi,
|
||||
&i.Snr,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
+166
@@ -2131,6 +2131,92 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"/routes/{iata}/{pathKey}/observations": {
|
||||
"get": {
|
||||
"description": "Matches the saved IATA, complete path bytes and hash width. Excludes TRACE and unclassified reports; other widths and search-result subsegments are not included. Byte matches do not prove hop identities or delivery. The route and its historical counter can outlive raw evidence. Cursors retain full timestamp precision and pin route/window scope.",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Routes"
|
||||
],
|
||||
"summary": "Get retained reports matching a full saved route prefix sequence",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Three-character IATA code",
|
||||
"name": "iata",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Stable 32-hex pathKey from a known-route response",
|
||||
"name": "pathKey",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Server-anchored duration (default 24h, max 720h); exclusive with since/until/pageCursor",
|
||||
"name": "range",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "Window start epoch ms; provide with until (default last 24h)",
|
||||
"name": "since",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "Exclusive window end epoch ms; maximum span 30d, no future end",
|
||||
"name": "until",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Opaque precise cursor from nextPageCursor; window is pinned",
|
||||
"name": "pageCursor",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"maximum": 200,
|
||||
"minimum": 1,
|
||||
"type": "integer",
|
||||
"description": "Default 50; positive, capped at 200",
|
||||
"name": "limit",
|
||||
"in": "query"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.RouteEvidence"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/internal_api_handlers.APIError"
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Not Found",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/internal_api_handlers.APIError"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/internal_api_handlers.APIError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/scopes": {
|
||||
"get": {
|
||||
"produces": [
|
||||
@@ -3601,6 +3687,10 @@ const docTemplate = `{
|
||||
},
|
||||
"observationCount": {
|
||||
"type": "integer"
|
||||
},
|
||||
"pathKey": {
|
||||
"description": "stable identity within this IATA; use for route evidence links",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -4845,6 +4935,50 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"github_com_MeshCore-Beacon_beacon-server_internal_api.RouteEvidence": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"generatedAt": {
|
||||
"type": "integer"
|
||||
},
|
||||
"hasMore": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"hashSize": {
|
||||
"type": "integer"
|
||||
},
|
||||
"items": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.RouteObservation"
|
||||
}
|
||||
},
|
||||
"matchAvailable": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"matchType": {
|
||||
"type": "string"
|
||||
},
|
||||
"nextCursor": {
|
||||
"type": "integer"
|
||||
},
|
||||
"nextPageCursor": {
|
||||
"type": "string"
|
||||
},
|
||||
"pathBytes": {
|
||||
"type": "string"
|
||||
},
|
||||
"route": {
|
||||
"$ref": "#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.KnownRoute"
|
||||
},
|
||||
"windowEnd": {
|
||||
"type": "integer"
|
||||
},
|
||||
"windowStart": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"github_com_MeshCore-Beacon_beacon-server_internal_api.RouteHop": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -4865,6 +4999,38 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"github_com_MeshCore-Beacon_beacon-server_internal_api.RouteObservation": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"heardAt": {
|
||||
"type": "integer"
|
||||
},
|
||||
"id": {
|
||||
"type": "integer"
|
||||
},
|
||||
"observerId": {
|
||||
"type": "string"
|
||||
},
|
||||
"observerName": {
|
||||
"type": "string"
|
||||
},
|
||||
"packetHash": {
|
||||
"type": "string"
|
||||
},
|
||||
"payloadType": {
|
||||
"type": "integer"
|
||||
},
|
||||
"payloadTypeName": {
|
||||
"type": "string"
|
||||
},
|
||||
"rssi": {
|
||||
"type": "integer"
|
||||
},
|
||||
"snr": {
|
||||
"type": "number"
|
||||
}
|
||||
}
|
||||
},
|
||||
"github_com_MeshCore-Beacon_beacon-server_internal_api.ScopeDetail": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -2129,6 +2129,92 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/routes/{iata}/{pathKey}/observations": {
|
||||
"get": {
|
||||
"description": "Matches the saved IATA, complete path bytes and hash width. Excludes TRACE and unclassified reports; other widths and search-result subsegments are not included. Byte matches do not prove hop identities or delivery. The route and its historical counter can outlive raw evidence. Cursors retain full timestamp precision and pin route/window scope.",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Routes"
|
||||
],
|
||||
"summary": "Get retained reports matching a full saved route prefix sequence",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Three-character IATA code",
|
||||
"name": "iata",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Stable 32-hex pathKey from a known-route response",
|
||||
"name": "pathKey",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Server-anchored duration (default 24h, max 720h); exclusive with since/until/pageCursor",
|
||||
"name": "range",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "Window start epoch ms; provide with until (default last 24h)",
|
||||
"name": "since",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "Exclusive window end epoch ms; maximum span 30d, no future end",
|
||||
"name": "until",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Opaque precise cursor from nextPageCursor; window is pinned",
|
||||
"name": "pageCursor",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"maximum": 200,
|
||||
"minimum": 1,
|
||||
"type": "integer",
|
||||
"description": "Default 50; positive, capped at 200",
|
||||
"name": "limit",
|
||||
"in": "query"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.RouteEvidence"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/internal_api_handlers.APIError"
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Not Found",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/internal_api_handlers.APIError"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/internal_api_handlers.APIError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/scopes": {
|
||||
"get": {
|
||||
"produces": [
|
||||
@@ -3599,6 +3685,10 @@
|
||||
},
|
||||
"observationCount": {
|
||||
"type": "integer"
|
||||
},
|
||||
"pathKey": {
|
||||
"description": "stable identity within this IATA; use for route evidence links",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -4843,6 +4933,50 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"github_com_MeshCore-Beacon_beacon-server_internal_api.RouteEvidence": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"generatedAt": {
|
||||
"type": "integer"
|
||||
},
|
||||
"hasMore": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"hashSize": {
|
||||
"type": "integer"
|
||||
},
|
||||
"items": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.RouteObservation"
|
||||
}
|
||||
},
|
||||
"matchAvailable": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"matchType": {
|
||||
"type": "string"
|
||||
},
|
||||
"nextCursor": {
|
||||
"type": "integer"
|
||||
},
|
||||
"nextPageCursor": {
|
||||
"type": "string"
|
||||
},
|
||||
"pathBytes": {
|
||||
"type": "string"
|
||||
},
|
||||
"route": {
|
||||
"$ref": "#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.KnownRoute"
|
||||
},
|
||||
"windowEnd": {
|
||||
"type": "integer"
|
||||
},
|
||||
"windowStart": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"github_com_MeshCore-Beacon_beacon-server_internal_api.RouteHop": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -4863,6 +4997,38 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"github_com_MeshCore-Beacon_beacon-server_internal_api.RouteObservation": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"heardAt": {
|
||||
"type": "integer"
|
||||
},
|
||||
"id": {
|
||||
"type": "integer"
|
||||
},
|
||||
"observerId": {
|
||||
"type": "string"
|
||||
},
|
||||
"observerName": {
|
||||
"type": "string"
|
||||
},
|
||||
"packetHash": {
|
||||
"type": "string"
|
||||
},
|
||||
"payloadType": {
|
||||
"type": "integer"
|
||||
},
|
||||
"payloadTypeName": {
|
||||
"type": "string"
|
||||
},
|
||||
"rssi": {
|
||||
"type": "integer"
|
||||
},
|
||||
"snr": {
|
||||
"type": "number"
|
||||
}
|
||||
}
|
||||
},
|
||||
"github_com_MeshCore-Beacon_beacon-server_internal_api.ScopeDetail": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -272,6 +272,9 @@ definitions:
|
||||
type: integer
|
||||
observationCount:
|
||||
type: integer
|
||||
pathKey:
|
||||
description: stable identity within this IATA; use for route evidence links
|
||||
type: string
|
||||
type: object
|
||||
github_com_MeshCore-Beacon_beacon-server_internal_api.Node:
|
||||
properties:
|
||||
@@ -1153,6 +1156,35 @@ definitions:
|
||||
description: hex-encoded prefix used for resolution
|
||||
type: string
|
||||
type: object
|
||||
github_com_MeshCore-Beacon_beacon-server_internal_api.RouteEvidence:
|
||||
properties:
|
||||
generatedAt:
|
||||
type: integer
|
||||
hasMore:
|
||||
type: boolean
|
||||
hashSize:
|
||||
type: integer
|
||||
items:
|
||||
items:
|
||||
$ref: '#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.RouteObservation'
|
||||
type: array
|
||||
matchAvailable:
|
||||
type: boolean
|
||||
matchType:
|
||||
type: string
|
||||
nextCursor:
|
||||
type: integer
|
||||
nextPageCursor:
|
||||
type: string
|
||||
pathBytes:
|
||||
type: string
|
||||
route:
|
||||
$ref: '#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.KnownRoute'
|
||||
windowEnd:
|
||||
type: integer
|
||||
windowStart:
|
||||
type: integer
|
||||
type: object
|
||||
github_com_MeshCore-Beacon_beacon-server_internal_api.RouteHop:
|
||||
properties:
|
||||
hashBytes:
|
||||
@@ -1165,6 +1197,27 @@ definitions:
|
||||
nodeId:
|
||||
type: string
|
||||
type: object
|
||||
github_com_MeshCore-Beacon_beacon-server_internal_api.RouteObservation:
|
||||
properties:
|
||||
heardAt:
|
||||
type: integer
|
||||
id:
|
||||
type: integer
|
||||
observerId:
|
||||
type: string
|
||||
observerName:
|
||||
type: string
|
||||
packetHash:
|
||||
type: string
|
||||
payloadType:
|
||||
type: integer
|
||||
payloadTypeName:
|
||||
type: string
|
||||
rssi:
|
||||
type: integer
|
||||
snr:
|
||||
type: number
|
||||
type: object
|
||||
github_com_MeshCore-Beacon_beacon-server_internal_api.ScopeDetail:
|
||||
properties:
|
||||
iataCount:
|
||||
@@ -2803,6 +2856,69 @@ paths:
|
||||
summary: List known routes
|
||||
tags:
|
||||
- Routes
|
||||
/routes/{iata}/{pathKey}/observations:
|
||||
get:
|
||||
description: Matches the saved IATA, complete path bytes and hash width. Excludes
|
||||
TRACE and unclassified reports; other widths and search-result subsegments
|
||||
are not included. Byte matches do not prove hop identities or delivery. The
|
||||
route and its historical counter can outlive raw evidence. Cursors retain
|
||||
full timestamp precision and pin route/window scope.
|
||||
parameters:
|
||||
- description: Three-character IATA code
|
||||
in: path
|
||||
name: iata
|
||||
required: true
|
||||
type: string
|
||||
- description: Stable 32-hex pathKey from a known-route response
|
||||
in: path
|
||||
name: pathKey
|
||||
required: true
|
||||
type: string
|
||||
- description: Server-anchored duration (default 24h, max 720h); exclusive with
|
||||
since/until/pageCursor
|
||||
in: query
|
||||
name: range
|
||||
type: string
|
||||
- description: Window start epoch ms; provide with until (default last 24h)
|
||||
in: query
|
||||
name: since
|
||||
type: integer
|
||||
- description: Exclusive window end epoch ms; maximum span 30d, no future end
|
||||
in: query
|
||||
name: until
|
||||
type: integer
|
||||
- description: Opaque precise cursor from nextPageCursor; window is pinned
|
||||
in: query
|
||||
name: pageCursor
|
||||
type: string
|
||||
- description: Default 50; positive, capped at 200
|
||||
in: query
|
||||
maximum: 200
|
||||
minimum: 1
|
||||
name: limit
|
||||
type: integer
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
$ref: '#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.RouteEvidence'
|
||||
"400":
|
||||
description: Bad Request
|
||||
schema:
|
||||
$ref: '#/definitions/internal_api_handlers.APIError'
|
||||
"404":
|
||||
description: Not Found
|
||||
schema:
|
||||
$ref: '#/definitions/internal_api_handlers.APIError'
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
$ref: '#/definitions/internal_api_handlers.APIError'
|
||||
summary: Get retained reports matching a full saved route prefix sequence
|
||||
tags:
|
||||
- Routes
|
||||
/routes/cross:
|
||||
get:
|
||||
parameters:
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
// Copyright 2026 Beacon Contributors
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/MeshCore-Beacon/beacon-server/internal/api"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
func routeEvidenceQuery(r *http.Request, iata, key string, now time.Time) (api.RouteEvidenceQuery, error) {
|
||||
q := api.RouteEvidenceQuery{}
|
||||
values := r.URL.Query()
|
||||
for _, name := range []string{"pageCursor", "since", "until", "range", "limit"} {
|
||||
if len(values[name]) > 1 {
|
||||
return q, api.ErrRouteEvidenceInput
|
||||
}
|
||||
}
|
||||
if values.Has("cursor") {
|
||||
return q, api.ErrRouteEvidenceInput
|
||||
}
|
||||
limit, err := parseLimit(r, 50)
|
||||
if err != nil {
|
||||
return q, err
|
||||
}
|
||||
q.Limit = limit
|
||||
if values.Has("pageCursor") {
|
||||
c, err := api.ParseRouteEvidenceCursor(values.Get("pageCursor"))
|
||||
if err != nil || c.IATA != iata || c.PathKey != key {
|
||||
return q, api.ErrRouteEvidenceInput
|
||||
}
|
||||
q.Cursor = c
|
||||
q.Since = c.Since
|
||||
q.Until = c.Until
|
||||
} else {
|
||||
q.Until = now.UTC().Truncate(time.Millisecond)
|
||||
q.Since = q.Until.Add(-24 * time.Hour)
|
||||
}
|
||||
if values.Has("range") {
|
||||
if values.Has("pageCursor") || values.Has("since") || values.Has("until") {
|
||||
return q, api.ErrRouteEvidenceInput
|
||||
}
|
||||
duration, err := time.ParseDuration(values.Get("range"))
|
||||
if err != nil || duration < time.Millisecond || duration > api.MaxRouteEvidenceWindow || duration%time.Millisecond != 0 {
|
||||
return q, api.ErrRouteEvidenceInput
|
||||
}
|
||||
q.Since = q.Until.Add(-duration)
|
||||
}
|
||||
if values.Has("since") || values.Has("until") {
|
||||
if !values.Has("since") || !values.Has("until") {
|
||||
return q, api.ErrRouteEvidenceInput
|
||||
}
|
||||
since, e1 := strconv.ParseInt(values.Get("since"), 10, 64)
|
||||
until, e2 := strconv.ParseInt(values.Get("until"), 10, 64)
|
||||
if e1 != nil || e2 != nil || since < 0 || until < 0 || since > 253402300799999 || until > 253402300799999 {
|
||||
return q, api.ErrRouteEvidenceInput
|
||||
}
|
||||
q.Since = time.UnixMilli(since).UTC()
|
||||
q.Until = time.UnixMilli(until).UTC()
|
||||
if q.Cursor != nil && (!q.Since.Equal(q.Cursor.Since) || !q.Until.Equal(q.Cursor.Until)) {
|
||||
return q, api.ErrRouteEvidenceInput
|
||||
}
|
||||
}
|
||||
if !api.ValidRouteEvidenceWindow(q.Since, q.Until) || q.Until.After(now) {
|
||||
return q, api.ErrRouteEvidenceInput
|
||||
}
|
||||
return q, nil
|
||||
}
|
||||
|
||||
// getRouteEvidence godoc
|
||||
//
|
||||
// @Summary Get retained reports matching a full saved route prefix sequence
|
||||
// @Description Matches the saved IATA, complete path bytes and hash width. Excludes TRACE and unclassified reports; other widths and search-result subsegments are not included. Byte matches do not prove hop identities or delivery. The route and its historical counter can outlive raw evidence. Cursors retain full timestamp precision and pin route/window scope.
|
||||
// @Tags Routes
|
||||
// @Produce json
|
||||
// @Param iata path string true "Three-character IATA code"
|
||||
// @Param pathKey path string true "Stable 32-hex pathKey from a known-route response"
|
||||
// @Param range query string false "Server-anchored duration (default 24h, max 720h); exclusive with since/until/pageCursor"
|
||||
// @Param since query int false "Window start epoch ms; provide with until (default last 24h)"
|
||||
// @Param until query int false "Exclusive window end epoch ms; maximum span 30d, no future end"
|
||||
// @Param pageCursor query string false "Opaque precise cursor from nextPageCursor; window is pinned"
|
||||
// @Param limit query int false "Default 50; positive, capped at 200" minimum(1) maximum(200)
|
||||
// @Success 200 {object} api.RouteEvidence
|
||||
// @Failure 400 {object} handlers.APIError
|
||||
// @Failure 404 {object} handlers.APIError
|
||||
// @Failure 500 {object} handlers.APIError
|
||||
// @Router /routes/{iata}/{pathKey}/observations [get]
|
||||
func getRouteEvidence(reader api.Reader) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
iata, key := strings.ToUpper(chi.URLParam(r, "iata")), strings.ToLower(chi.URLParam(r, "pathKey"))
|
||||
if !api.ValidRouteEvidenceKey(iata, key) {
|
||||
respondError(w, 400, "invalid IATA or route pathKey")
|
||||
return
|
||||
}
|
||||
query, err := routeEvidenceQuery(r, iata, key, time.Now())
|
||||
if err != nil {
|
||||
respondError(w, 400, "invalid route evidence window, limit or page cursor")
|
||||
return
|
||||
}
|
||||
result, err := reader.GetRouteEvidence(r.Context(), iata, key, query)
|
||||
switch {
|
||||
case errors.Is(err, pgx.ErrNoRows), err == nil && result == nil:
|
||||
respondError(w, 404, "saved route not found")
|
||||
case errors.Is(err, api.ErrRouteEvidenceInput):
|
||||
respondError(w, 400, "invalid route evidence request")
|
||||
case err != nil:
|
||||
respondError(w, 500, "internal server error")
|
||||
default:
|
||||
respond(w, 200, result)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
// Copyright 2026 Beacon Contributors
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/MeshCore-Beacon/beacon-server/internal/api"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
const evidenceKey = "f097439148601d9f3291c474f82fa64c"
|
||||
|
||||
func TestRouteEvidenceQuery(t *testing.T) {
|
||||
now := time.Date(2026, 9, 27, 12, 0, 0, 0, time.UTC)
|
||||
q, err := routeEvidenceQuery(httptest.NewRequest("GET", "/", nil), "YOW", evidenceKey, now)
|
||||
if err != nil || q.Limit != 50 || q.Until != now || q.Until.Sub(q.Since) != 24*time.Hour {
|
||||
t.Fatalf("defaults: %+v %v", q, err)
|
||||
}
|
||||
c := api.RouteEvidenceCursor{IATA: "YOW", PathKey: evidenceKey, Since: q.Since, Until: q.Until, HeardAt: q.Since.Add(time.Hour + 123*time.Microsecond), ID: 3}
|
||||
next, err := routeEvidenceQuery(httptest.NewRequest("GET", "/?pageCursor="+url.QueryEscape(c.String())+"&limit=999", nil), "YOW", evidenceKey, now.Add(time.Hour))
|
||||
if err != nil || next.Limit != 200 || !next.Until.Equal(now) || !next.Cursor.HeardAt.Equal(c.HeardAt) {
|
||||
t.Fatalf("pinned cursor: %+v %v", next, err)
|
||||
}
|
||||
for _, query := range []string{"?limit=0", "?limit=-1", "?limit=a", "?limit=1&limit=2", "?since=0", "?until=10", "?since=2&until=1", "?since=0&until=2678400000", "?since=0&until=99999999999999999", "?pageCursor=bad", "?pageCursor=", "?cursor=123", "?pageCursor=" + url.QueryEscape(c.String()) + "&since=0&until=10"} {
|
||||
if _, err := routeEvidenceQuery(httptest.NewRequest("GET", "/"+query, nil), "YOW", evidenceKey, now); err == nil {
|
||||
t.Errorf("accepted %s", query)
|
||||
}
|
||||
}
|
||||
if _, err := routeEvidenceQuery(httptest.NewRequest("GET", "/?pageCursor="+url.QueryEscape(c.String()), nil), "YVR", evidenceKey, now); err == nil {
|
||||
t.Fatal("cursor crossed IATA")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouteEvidenceHandler(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
path string
|
||||
err error
|
||||
status int
|
||||
}{
|
||||
{"/yow/" + evidenceKey + "/observations", nil, 200},
|
||||
{"/YOW/bad/observations", nil, 400},
|
||||
{"/LONG/" + evidenceKey + "/observations", nil, 400},
|
||||
{"/YOW/" + evidenceKey + "/observations", pgx.ErrNoRows, 404},
|
||||
{"/YOW/" + evidenceKey + "/observations", errors.New("database unavailable"), 500},
|
||||
} {
|
||||
called := false
|
||||
router := RoutesRouter(stubReader{getRouteEvidence: func(_ context.Context, iata, key string, q api.RouteEvidenceQuery) (*api.RouteEvidence, error) {
|
||||
called = true
|
||||
if iata != "YOW" || key != evidenceKey || q.Limit != 50 {
|
||||
t.Fatal("wrong reader scope")
|
||||
}
|
||||
return &api.RouteEvidence{Page: api.Page[api.RouteObservation]{Items: []api.RouteObservation{}}}, tc.err
|
||||
}})
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, httptest.NewRequest("GET", tc.path, nil))
|
||||
if w.Code != tc.status {
|
||||
t.Fatalf("%s: %d %s", tc.path, w.Code, w.Body.String())
|
||||
}
|
||||
if tc.status == 400 && called {
|
||||
t.Fatal("invalid request reached database")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ func RoutesRouter(reader api.Reader) http.Handler {
|
||||
r.Get("/", listKnownRoutes(reader))
|
||||
r.Get("/cross", searchCrossIATARoutes(reader))
|
||||
r.Get("/search", searchKnownRoutes(reader))
|
||||
r.Get("/{iata}/{pathKey}/observations", getRouteEvidence(reader))
|
||||
return r
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
// Unset fields return zero values. Use it for both validation tests
|
||||
// (leave all fields nil) and happy path tests (set only what you need).
|
||||
type stubReader struct {
|
||||
getRouteEvidence func(context.Context, string, string, api.RouteEvidenceQuery) (*api.RouteEvidence, error)
|
||||
getObserverComparison func(context.Context, uuid.UUID, uuid.UUID, time.Time, time.Time, []string) (*api.ObserverComparison, error)
|
||||
listIATAs func(ctx context.Context) ([]api.IATA, error)
|
||||
getIATA func(ctx context.Context, iata string) (*api.IATA, error)
|
||||
@@ -409,3 +410,10 @@ func (s stubReader) GetNodesByIDs(ctx context.Context, ids []uuid.UUID) (map[uui
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s stubReader) GetRouteEvidence(ctx context.Context, iata, key string, q api.RouteEvidenceQuery) (*api.RouteEvidence, error) {
|
||||
if s.getRouteEvidence != nil {
|
||||
return s.getRouteEvidence(ctx, iata, key, q)
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
@@ -23,6 +23,8 @@ type Page[T any] struct {
|
||||
}
|
||||
|
||||
type Reader interface {
|
||||
// GetRouteEvidence returns the full saved route and exact-prefix retained ordinary reports.
|
||||
GetRouteEvidence(ctx context.Context, iata, pathKey string, query RouteEvidenceQuery) (*RouteEvidence, error)
|
||||
// ListIATAs returns all known IATA codes with display name and coordinates.
|
||||
// IATAs are auto-created on first packet arrival from that location.
|
||||
ListIATAs(ctx context.Context) ([]IATA, error)
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
// Copyright 2026 Beacon Contributors
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const MaxRouteEvidenceWindow = 30 * 24 * time.Hour
|
||||
|
||||
var ErrRouteEvidenceInput = errors.New("invalid route evidence request")
|
||||
|
||||
// RouteObservation is a retained report reference, not a packet body or proof of hop identity.
|
||||
type RouteObservation struct {
|
||||
ID int64 `json:"id"`
|
||||
PacketHash string `json:"packetHash"`
|
||||
ObserverID uuid.UUID `json:"observerId"`
|
||||
ObserverName *string `json:"observerName,omitempty"`
|
||||
HeardAt int64 `json:"heardAt"`
|
||||
PayloadType int16 `json:"payloadType"`
|
||||
PayloadTypeName string `json:"payloadTypeName"`
|
||||
RSSI *int16 `json:"rssi,omitempty"`
|
||||
SNR *float32 `json:"snr,omitempty"`
|
||||
}
|
||||
|
||||
// RouteEvidence uses nextPageCursor rather than the lossy legacy numeric cursor.
|
||||
type RouteEvidence struct {
|
||||
Page[RouteObservation]
|
||||
Route KnownRoute `json:"route"`
|
||||
WindowStart int64 `json:"windowStart"`
|
||||
WindowEnd int64 `json:"windowEnd"`
|
||||
GeneratedAt int64 `json:"generatedAt"`
|
||||
MatchType string `json:"matchType"`
|
||||
MatchAvailable bool `json:"matchAvailable"`
|
||||
HashSize int16 `json:"hashSize"`
|
||||
PathBytes string `json:"pathBytes"`
|
||||
NextPageCursor *string `json:"nextPageCursor,omitempty"`
|
||||
}
|
||||
|
||||
type RouteEvidenceQuery struct {
|
||||
Since time.Time
|
||||
Until time.Time
|
||||
Cursor *RouteEvidenceCursor
|
||||
Limit int32
|
||||
}
|
||||
|
||||
// RouteEvidenceCursor binds the precise (heard_at DESC,id DESC) boundary to its route/window.
|
||||
type RouteEvidenceCursor struct {
|
||||
IATA string
|
||||
PathKey string
|
||||
Since time.Time
|
||||
Until time.Time
|
||||
HeardAt time.Time
|
||||
ID int64
|
||||
}
|
||||
|
||||
func ValidRouteEvidenceKey(iata, key string) bool {
|
||||
if len(iata) != 3 || len(key) != 32 {
|
||||
return false
|
||||
}
|
||||
for _, c := range iata {
|
||||
if !(c >= 'A' && c <= 'Z') && !(c >= '0' && c <= '9') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
_, err := hex.DecodeString(key)
|
||||
return err == nil && key == strings.ToLower(key)
|
||||
}
|
||||
|
||||
func ValidRouteEvidenceWindow(since, until time.Time) bool {
|
||||
return !since.Before(time.Unix(0, 0)) && until.After(since) && until.Sub(since) <= MaxRouteEvidenceWindow
|
||||
}
|
||||
|
||||
func (c RouteEvidenceCursor) String() string {
|
||||
return fmt.Sprintf("v1:%s:%s:%d:%d:%d:%d", c.IATA, c.PathKey, c.Since.UnixMilli(), c.Until.UnixMilli(), c.HeardAt.UnixMicro(), c.ID)
|
||||
}
|
||||
|
||||
func ParseRouteEvidenceCursor(raw string) (*RouteEvidenceCursor, error) {
|
||||
if len(raw) > 200 {
|
||||
return nil, ErrRouteEvidenceInput
|
||||
}
|
||||
p := strings.Split(raw, ":")
|
||||
if len(p) != 7 || p[0] != "v1" || !ValidRouteEvidenceKey(p[1], p[2]) {
|
||||
return nil, ErrRouteEvidenceInput
|
||||
}
|
||||
values := [4]int64{}
|
||||
for i, s := range p[3:] {
|
||||
n, err := strconv.ParseInt(s, 10, 64)
|
||||
if err != nil || n < 0 || strconv.FormatInt(n, 10) != s {
|
||||
return nil, ErrRouteEvidenceInput
|
||||
}
|
||||
values[i] = n
|
||||
}
|
||||
// Bound before converting; this also keeps millisecond/microsecond comparisons safe.
|
||||
if values[0] > 253402300799999 || values[1] > 253402300799999 || values[2] > 253402300799999999 || values[3] <= 0 {
|
||||
return nil, ErrRouteEvidenceInput
|
||||
}
|
||||
c := &RouteEvidenceCursor{IATA: p[1], PathKey: p[2], Since: time.UnixMilli(values[0]).UTC(), Until: time.UnixMilli(values[1]).UTC(), HeardAt: time.UnixMicro(values[2]).UTC(), ID: values[3]}
|
||||
if !ValidRouteEvidenceWindow(c.Since, c.Until) || c.HeardAt.Before(c.Since) || !c.HeardAt.Before(c.Until) {
|
||||
return nil, ErrRouteEvidenceInput
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// Copyright 2026 Beacon Contributors
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
package api
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestRouteEvidenceCursor(t *testing.T) {
|
||||
c := RouteEvidenceCursor{IATA: "YOW", PathKey: "f097439148601d9f3291c474f82fa64c", Since: time.UnixMilli(1000).UTC(), Until: time.UnixMilli(3000).UTC(), HeardAt: time.UnixMicro(2000123).UTC(), ID: 9223372036854775806}
|
||||
got, err := ParseRouteEvidenceCursor(c.String())
|
||||
if err != nil || *got != c {
|
||||
t.Fatalf("cursor round-trip: %+v %v", got, err)
|
||||
}
|
||||
if got.HeardAt.Nanosecond() != 123000 {
|
||||
t.Fatal("lost microsecond precision")
|
||||
}
|
||||
for _, bad := range []string{"", strings.Repeat("x", 300), "v2:YOW:x:1:2:3:4", strings.Replace(c.String(), "YOW", "TOOLONG", 1), strings.Replace(c.String(), c.PathKey, "bad", 1), strings.Replace(c.String(), "2000123", "4000000", 1), strings.TrimSuffix(c.String(), "9223372036854775806") + "0"} {
|
||||
if _, err := ParseRouteEvidenceCursor(bad); err == nil {
|
||||
t.Errorf("accepted %q", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ type RouteHop struct {
|
||||
// have been confirmed as high confidence.
|
||||
type KnownRoute struct {
|
||||
ID int64 `json:"id"`
|
||||
PathKey string `json:"pathKey,omitempty"` // stable identity within this IATA; use for route evidence links
|
||||
IATA string `json:"iata"`
|
||||
HopCount int32 `json:"hopCount"`
|
||||
Hops []RouteHop `json:"hops"`
|
||||
|
||||
Vendored
+5
-1
@@ -366,7 +366,7 @@ func TestCachedReader_IATASortingForStableKey(t *testing.T) {
|
||||
return &api.StatsOverview{TotalPackets: 42}, nil
|
||||
})
|
||||
|
||||
// call CachedReader with reversed order — should hit same key
|
||||
// call CachedReader with reversed order — should hit same key
|
||||
_ = cr
|
||||
result, err := getOrSet(context.Background(), c, "beacon:stats:overview:YVR,YYJ", time.Minute, func() (*api.StatsOverview, error) {
|
||||
calls++
|
||||
@@ -418,3 +418,7 @@ func TestCachedReader_InvalidateObserver(t *testing.T) {
|
||||
t.Error("expected observer scopes key to be deleted")
|
||||
}
|
||||
}
|
||||
|
||||
func (s *stubReader) GetRouteEvidence(_ context.Context, _, _ string, _ api.RouteEvidenceQuery) (*api.RouteEvidence, error) {
|
||||
return nil, nil
|
||||
}
|
||||
Vendored
+5
@@ -467,6 +467,11 @@ func (cr *CachedReader) SearchKnownRoutes(ctx context.Context, iata, fromHash, t
|
||||
return cr.inner.SearchKnownRoutes(ctx, iata, fromHash, toHash)
|
||||
}
|
||||
|
||||
// Precise cursor/window combinations are intentionally passed through, like route lists.
|
||||
func (cr *CachedReader) GetRouteEvidence(ctx context.Context, iata, key string, query api.RouteEvidenceQuery) (*api.RouteEvidence, error) {
|
||||
return cr.inner.GetRouteEvidence(ctx, iata, key, query)
|
||||
}
|
||||
|
||||
// SearchCrossIATARoutes implements [api.Reader].
|
||||
func (cr *CachedReader) SearchCrossIATARoutes(ctx context.Context, fromHash, fromIATA, toHash, toIATA string) ([]api.CrossIATARoute, error) {
|
||||
return cr.inner.SearchCrossIATARoutes(ctx, fromHash, fromIATA, toHash, toIATA)
|
||||
|
||||
Reference in new issue
Block a user