Files
meshcore-analyzer/cmd/server/openapi_packets_test.go
T
n30nexandn30nex 1ed5e4ab3f fix(compare): exclude observer TX-only packets from overlap (#2346)
Red commit: `39295da8` ([intended CI assertion
failure](https://github.com/Kpa-clawbot/CoreScope/actions/runs/40605770951/job/122544822813)).
Green commit: `d1bda496`.

Observer comparison counted an observer's TX-only packets as receptions,
producing false overlap. It now requests `excludeTx=true`: the selected
observer and non-TX direction must match the same observation in memory
and SQLite v2/v3. Normal packet lists and stored TX records remain
unchanged; missing directions retain legacy compatibility.

Refs #667. This completes a comparison slice, not the issue's remaining
observer counters or #671's fleet/SNR feature. Representative display
fields and existing time-filter semantics are unchanged. API/OpenAPI
docs describe the filter and multi-node bypass.

Validation:
- RED: Go tests compiled with 32 intended failures/16 control passes;
unit and browser regressions reproduced false overlap. CI reached the
new unit assertion.
- GREEN: all 48 packet cases pass, plus grouped-cache isolation and
OpenAPI tests. The added cache regression caught a malformed-radio
cache-key collision before correction.
- Full local server suite: 174s; ingestor: 185s; `npm run test:unit`:
pass. Final focused packet/grouped/OpenAPI checks: pass.
- E2E assertion added to
`tests/e2e/test-issue-1640-compare-discovery-e2e.js`; all 10 checks
pass, including correct 1/2 reception totals and 50% overlap.

Performance: still two capped bulk requests, indexed candidate lookups
retained, one direction check within the existing observation scan/SQL
predicate, no new persistent structures. No configuration/customizer
changes.

Final-head CI passed; independent reviews found no must-fix items.

---------

Co-authored-by: n30nex <6686206+n30nex@users.noreply.github.com>
2026-10-29 18:34:06 +01:00

267 lines
9.7 KiB
Go

package main
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"slices"
"strconv"
"strings"
"testing"
"time"
)
type packetQueryDoc struct {
Name string `json:"name"`
In string `json:"in"`
Description string `json:"description"`
Required bool `json:"required"`
Schema struct {
Type string `json:"type"`
} `json:"schema"`
}
func packetQueryDocs(t *testing.T, router http.Handler) map[string]packetQueryDoc {
t.Helper()
w := httptest.NewRecorder()
router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/spec", nil))
if w.Code != http.StatusOK {
t.Fatalf("GET /api/spec: status %d", w.Code)
}
var spec struct {
Paths map[string]struct {
Get struct {
Parameters []packetQueryDoc `json:"parameters"`
} `json:"get"`
} `json:"paths"`
}
if err := json.Unmarshal(w.Body.Bytes(), &spec); err != nil {
t.Fatal(err)
}
params := make(map[string]packetQueryDoc)
for _, param := range spec.Paths["/api/packets"].Get.Parameters {
if _, duplicate := params[param.Name]; duplicate {
t.Errorf("duplicate packet query parameter %q", param.Name)
}
params[param.Name] = param
}
return params
}
func TestOpenAPIPacketQueryParameters(t *testing.T) {
_, router := setupTestServer(t)
params := packetQueryDocs(t, router)
want := map[string]string{
"limit": "integer", "offset": "integer", "order": "string",
"type": "integer", "route": "integer", "observer": "string",
"hash": "string", "since": "string", "until": "string",
"region": "string", "node": "string", "nodes": "string",
"channel": "string", "area": "string", "expand": "string",
"radio": "string", "groupByHash": "boolean", "excludeTx": "boolean",
}
for name, typ := range want {
param, ok := params[name]
if !ok {
t.Errorf("GET /api/packets is missing supported query parameter %q", name)
continue
}
if param.In != "query" || param.Required || param.Schema.Type != typ || param.Description == "" {
t.Errorf("%s: want optional, described query parameter of type %s; got %+v", name, typ, param)
}
}
for name := range params {
if _, supported := want[name]; !supported {
t.Errorf("GET /api/packets documents unsupported query parameter %q", name)
}
}
}
// Couple the served documentation to observable route behavior, including the
// mode switches that bypass otherwise-supported packet filters (#1670).
func TestOpenAPIPacketQueryBehavior(t *testing.T) {
srv, router := setupTestServer(t)
params := packetQueryDocs(t, router)
get := func(t *testing.T, query url.Values) PacketResult {
t.Helper()
w := httptest.NewRecorder()
router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/packets?"+query.Encode(), nil))
if w.Code != http.StatusOK {
t.Fatalf("GET /api/packets?%s: status %d: %s", query.Encode(), w.Code, w.Body.String())
}
var result PacketResult
if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil {
t.Fatal(err)
}
return result
}
described := func(t *testing.T, name string, phrases ...string) {
t.Helper()
param, ok := params[name]
if !ok {
t.Errorf("working query parameter %q is missing from served spec", name)
}
for _, phrase := range phrases {
if !strings.Contains(param.Description, phrase) {
t.Errorf("%s description must include %q; got %q", name, phrase, param.Description)
}
}
}
assertHashes := func(t *testing.T, result PacketResult, want ...string) {
t.Helper()
got := make([]string, len(result.Packets))
for i, packet := range result.Packets {
got[i], _ = packet["hash"].(string)
}
if !slices.Equal(got, want) {
t.Errorf("packet hashes = %v, want %v", got, want)
}
}
const advert = "abc123def4567890"
const message = "1234567890abcdef"
const olderAdvert = "def456abc1230099"
cutoff := time.Now().UTC().Add(-12 * time.Hour).Format(time.RFC3339)
// Use the existing seeded nodes and transmissions, with one configured area.
srv.store.config = &Config{Areas: map[string]AreaEntry{
"TEST": {Polygon: [][2]float64{{37.45, -122.05}, {37.55, -122.05}, {37.55, -121.95}, {37.45, -121.95}}},
"OTHER": {Polygon: [][2]float64{{37.55, -122.15}, {37.65, -122.15}, {37.65, -122.05}, {37.55, -122.05}}},
}}
for _, tc := range []struct {
name, value string
want []string
}{
{"hash", strings.ToUpper(advert), []string{advert}},
{"hash", advert[:8], nil}, // Exact hash, not a prefix search.
{"type", "5", []string{message}},
{"route", "2", nil},
{"observer", "missing, obs2", []string{advert}},
{"region", "SFO", []string{advert}},
{"node", "TestRepeater", []string{olderAdvert, advert}},
{"nodes", "missing, TestRepeater", []string{olderAdvert, advert}},
{"channel", "#test", []string{message}},
{"area", "TEST", []string{olderAdvert, advert}},
{"area", "OTHER", nil},
{"since", cutoff, []string{advert}},
{"until", cutoff, []string{olderAdvert, message}},
{"order", "asc", []string{advert, message, olderAdvert}},
} {
t.Run(tc.name+"="+tc.value, func(t *testing.T) {
result := get(t, url.Values{tc.name: {tc.value}})
assertHashes(t, result, tc.want...)
described(t, tc.name)
})
}
t.Run("defaults and pagination", func(t *testing.T) {
result := get(t, nil)
assertHashes(t, result, olderAdvert, message, advert)
described(t, "limit", fmt.Sprintf("Defaults to %d", result.Limit))
described(t, "offset", fmt.Sprintf("Defaults to %d", result.Offset))
described(t, "order", "Defaults to desc", "ingest ID")
described(t, "groupByHash", "Defaults to false")
if _, grouped := result.Packets[0]["count"]; grouped {
t.Error("default packet list unexpectedly uses grouped summaries")
}
page := get(t, url.Values{"limit": {"1"}, "offset": {"1"}})
assertHashes(t, page, message)
if page.Limit != 1 || page.Offset != 1 || page.Total != result.Total {
t.Errorf("unexpected pagination metadata: %+v", page)
}
for _, value := range []string{"0", "-1", "invalid"} {
if got := get(t, url.Values{"limit": {value}}).Limit; got != result.Limit {
t.Errorf("limit=%s: got %d, want default %d", value, got, result.Limit)
}
}
described(t, "limit", "non-positive", "invalid", "listLimits.packetsMax", strconv.Itoa(srv.cfg.ListLimits.PacketsMax))
originalMax := srv.cfg.ListLimits.PacketsMax
t.Cleanup(func() { srv.cfg.ListLimits.PacketsMax = originalMax })
srv.cfg.ListLimits.PacketsMax = 2
capped := get(t, url.Values{"limit": {"99999"}})
if capped.Limit != 2 || len(capped.Packets) != 2 {
t.Errorf("configured cap not applied: %+v", capped)
}
})
t.Run("grouping and expansion", func(t *testing.T) {
for _, expand := range []string{"", "observations", "other"} {
result := get(t, url.Values{"hash": {advert}, "expand": {expand}})
assertHashes(t, result, advert)
_, expanded := result.Packets[0]["observations"]
if expanded != (expand == "observations") {
t.Errorf("expand=%q: observations present = %v", expand, expanded)
}
}
described(t, "expand", "observations", "in-memory", "ungrouped")
grouped := get(t, url.Values{"hash": {advert}, "groupByHash": {"true"}, "order": {"asc"}, "expand": {"observations"}})
assertHashes(t, grouped, advert)
if grouped.Packets[0]["count"] != float64(2) || grouped.Packets[0]["observations"] != nil {
t.Errorf("unexpected grouped summary: %+v", grouped.Packets[0])
}
described(t, "groupByHash", "latest observation", "order", "expand")
for _, order := range []string{"asc", "desc"} {
grouped := get(t, url.Values{"groupByHash": {"true"}, "order": {order}})
if grouped.Packets[0]["hash"] != advert {
t.Errorf("grouped order=%s: latest observation must be first", order)
}
}
})
t.Run("time bounds depend on query mode", func(t *testing.T) {
boundary := get(t, url.Values{"hash": {advert}}).Packets[0]["first_seen"].(string)
store := srv.store
t.Cleanup(func() { srv.store = store })
for _, tc := range []struct {
mode, param string
want []string
}{
{"memory", "since", nil},
{"memory", "until", []string{olderAdvert, message}},
{"SQL", "since", []string{advert}},
{"SQL", "until", []string{olderAdvert, message, advert}},
{"nodes", "since", []string{advert}},
{"nodes", "until", []string{olderAdvert, advert}},
} {
t.Run(tc.mode+"/"+tc.param, func(t *testing.T) {
srv.store = store
query := url.Values{tc.param: {boundary}}
if tc.mode == "SQL" {
srv.store = nil
} else if tc.mode == "nodes" {
query.Set("nodes", "TestRepeater")
}
assertHashes(t, get(t, query), tc.want...)
})
}
described(t, "since", "first_seen > since", "observations >= since", "first_seen >= since")
described(t, "until", "first_seen < until", "observations <= until", "first_seen <= until")
})
t.Run("multi-node mode", func(t *testing.T) {
result := get(t, url.Values{"nodes": {"TestRepeater"}, "hash": {"missing"}, "type": {"5"}, "groupByHash": {"true"}})
assertHashes(t, result, olderAdvert, advert)
described(t, "nodes", fmt.Sprintf("%d non-empty", maxMultiNodePubkeys), "only limit, offset, order, since and until")
tooMany := url.Values{"nodes": {strings.Repeat("missing,", maxMultiNodePubkeys) + "missing"}}
w := httptest.NewRecorder()
router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/packets?"+tooMany.Encode(), nil))
if w.Code != http.StatusBadRequest {
t.Errorf("over-cap nodes list: status %d, want 400", w.Code)
}
})
t.Run("SQL fallback limitations", func(t *testing.T) {
assertHashes(t, get(t, url.Values{"hash": {advert}, "area": {"OTHER"}}))
store := srv.store
t.Cleanup(func() { srv.store = store })
srv.store = nil
result := get(t, url.Values{"hash": {strings.ToUpper(advert)}, "area": {"OTHER"}, "expand": {"observations"}})
assertHashes(t, result, advert)
if result.Packets[0]["observations"] != nil {
t.Error("SQL result unexpectedly expanded observations")
}
described(t, "area", "SQL", "ignored")
described(t, "expand", "SQL", "ignored")
})
}