mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-09-25 21:03:42 +00:00
Swaps the SQLite driver from `modernc.org/sqlite` (pure Go, SQLite
3.46.0) to `github.com/mattn/go-sqlite3` (cgo, bundled SQLite 3.53.4),
and pays the resulting cross-compilation cost with `zig cc`.
Draft because the riskiest part of this deletes rows — see [Please
review this part first](#please-review-this-part-first) — and because
three things remain unverified at the bottom.
`modernc.org/sqlite` is a transpilation of the C amalgamation. This repo
is read-heavy: `cmd/server` chunk-loads a graph at startup and fans out
neighbour/topology/analytics queries per request, and it pays for that
transpilation on exactly those paths. Head-to-head on the same
120k-transmission / 240k-observation database, running our own hot-path
SQL under both drivers (Apple M4, `-count=5`, medians):
| workload | modernc | mattn | |
|---|---:|---:|---|
| chunk load (`chunked_load.go` v3 join, 20k tx) | 449ms | 196ms |
**2.3×** |
| aggregate scan (240k-row join + `GROUP BY`) | 276ms | 137ms | **2.0×**
|
| 1500 prepared-statement lookups | 512ms | 403ms | **1.3×** |
Allocations fall with it: 1.12M vs 1.64M allocs and 21MB vs 30MB on the
chunk load.
**Superseded by a production run.** @efiten measured both drivers on a
real instance — 11,077,038 observations, 9.7GB database, 4-core arm64 —
as server-only containers against the same live volume, one at a time,
with round 2 reversing the order so the page cache favours the old
driver:
| | audit 7d | audit 24h | background fill (13 chunks) | start →
/api/health |
|---|---:|---:|---:|---:|
| modernc, round 1 | 16.67s | 2.27s | 130.2s | 16.6s |
| mattn, round 1 | 7.87s | 1.34s | 93.8s | 13.5s |
| mattn, round 2 | 8.15s | 1.35s | 96.4s | 13.0s |
| modernc, round 2 | 13.46s | 2.29s | 137.8s | 15.5s |
Warm, the old driver improves to 13.46s on the 7d audit and still loses
by ~1.8×. Chunk load is ~1.4×. `/api/nodes?limit=500` is 0.039s against
0.037s — nothing.
**So the real gain is ~1.4–1.8× on the paths that matter, not 2–2.3×.**
The shape the harness predicted holds — scans and joins gain, small
lookups do not — which is more reassuring than the magnitude would have
been. Quote these numbers.
**The counterweight**, cold and native on that machine: a build goes
from **52s to 163s**. An instance that builds its own image pays that
per deploy.
## The build is cgo now, and one thing about that is a trap
**`CGO_ENABLED=0` still builds.** mattn links a stub, and the binary
dies on its first query with `go-sqlite3 requires cgo to work. This is a
stub`. A green build is not evidence of anything here, which is why
`AGENTS.md` now says so explicitly. `GOOS=linux go build` genuinely
cannot cross-compile any more.
A new root `Makefile` is the entry point. `make crossbuild` uses `zig cc
-target {x86_64,aarch64}-linux-musl` and links static, so each artifact
stays a single self-contained file and the `alpine:3.20` runtime no
longer depends on the base image's libc at all.
`-Wl,-s` is load-bearing: Go's own `-s -w` does not reach the musl
objects zig links in, and without it the server binary is 19.8MB instead
of 12.1MB.
The Dockerfile keeps its single `$BUILDPLATFORM` builder — still no QEMU
for compilation — and gains a checksum-pinned zig plus BuildKit cache
mounts. The mounts are not a nicety: without them an image build
recompiles the amalgamation from cold and takes over half an hour.
## Please review this part first
`internal/dbschema/dedup_index.go` **deletes observation rows**. It is
the one part of this change that can lose data, and it exists because
the migration exposed a real bug rather than causing one.
`stmtInsertObservation` resolves its `ON CONFLICT` against
`idx_observations_dedup`, which `cmd/ingestor/db.go` only ever created
inside the branch that creates the `observations` table for the first
time. Any database whose table predates that branch never got one, so
the UPSERT had no conflict target. modernc failed on the first insert;
mattn fails at `OpenStore`. Same bug, found earlier.
Creating the index unconditionally repairs it — but the index is what
was supposed to prevent duplicates, so a database that never had it can
already hold rows violating it. **`test-fixtures/e2e-fixture.db` in this
repo holds one.** So duplicates are collapsed first. Refusing is not the
safer option: without the index the ingestor cannot prepare its UPSERT,
so it cannot start at all.
Replaying that UPSERT faithfully is subtler than it looks, and a first
cut of this got it wrong twice:
- `COALESCE(excluded.x, x)` means the **incoming** value wins, so down a
group in id order the survivor keeps the **last** non-NULL value. Taking
the first silently discarded newer readings.
- The UPSERT names exactly five columns (`snr`, `rssi`, `score`,
`raw_hex`, `resolved_path`). Every other column must keep the surviving
row's own value; merging those too invents history the ingestor would
never have written.
Merge, delete and `CREATE UNIQUE INDEX` now share one transaction. Split
apart, a writer inserting a duplicate in the gap fails the index
creation while leaving the deletions committed — rows destroyed and no
index to show for it.
Cost, measured on 2.4M synthetic rows holding 5 duplicates: **4.1s**,
holding the write lock throughout, once, at ingestor startup before MQTT
subscribe. Materialising the duplicate-group scan once rather than per
column took that from 9.7s; the pathological case (400k of 600k rows
duplicated) is 5.7s, slightly worse than the 4.2s it was before that
change.
## Four more behavioural differences
Full detail in `docs/sqlite-driver-migration.md`. Briefly:
**Statement preparation is eager.** modernc's `newStmt` stored the SQL
and compiled lazily; mattn calls `sqlite3_prepare_v2` inside `Prepare`,
so SQL naming a missing table fails at *open*. 59 server tests failed on
this alone, all fixtures with partial schemas. `OpenDB` keeps failing
loudly (#1901; `main.go` gates on `dbschema.AssertReady` anyway) and the
fixtures now declare what they are prepared against via
`ensurePreparable`. This also exposed nine `nodes(pubkey …)`
declarations across seven files, where production has only ever had
`public_key` — lazy compilation had hidden the mismatch for as long as
it existed.
**`synchronous` silently dropped FULL → NORMAL.** mattn defaults it to
NORMAL and executes the pragma unconditionally, where SQLite's own
default (what modernc left alone) is FULL. In WAL mode that weakens
durability under power loss. Pinned in `dbschema.WriterDSN`, which both
writers now share — `cmd/migrate` kept a bare path at first and so
quietly wrote at NORMAL, which is what a second copy of a DSN buys you.
**The DSN dialects are mutually invisible.** modernc understood only
`_pragma=name(value)`, mattn only `_`-prefixed parameters, and neither
errors on the other's form — a driver-only rename would have dropped
every pragma in silence. `_journal_mode=WAL` is also gone from the
server's read handle: modernc ignored it, mattn honours it, and setting
`journal_mode` on a read-only connection is a write. Dropping
`_busy_timeout` with it costs nothing, since mattn already defaults to
5000ms — which means the read handle finally *gets* the busy timeout it
had silently lacked.
**`mode=ro` survives for a non-obvious reason.** mattn always passes
`READWRITE|CREATE` and its amalgamation has `SQLITE_USE_URI=0`; what
makes the URI work is its C wrapper ORing `SQLITE_OPEN_URI` in. So the
#1283/#1289 invariant holds with no build flags — but it depends on the
`file:` prefix. `cmd/decrypt` had been building its DSN without one, so
its `mode=ro` had never applied and a missing path was created
read-write. Fixed in passing; never a migration regression.
## What did not change
No modernc-specific API was in use: no `RegisterFunction`, no
`*sqlite.Conn`, no `sqlite/lib` error constants, no `sql.Register`. No
`time.Time` is ever bound as a query argument, so driver time handling
is not in play. Both drivers convert declared
`DATE`/`DATETIME`/`TIMESTAMP` columns to `time.Time`, so
`/api/dropped-packets` keeps emitting `dropped_at` as RFC3339 — an
earlier draft "fixed" that with a `CAST` and would have been the
regression.
## Tests and CI
New regression tests, each written because something got through without
it:
- `TestEnsureObservationsDedupIndexKeepsLatestValues` — the merge
ordering. The original test used complementary NULLs, which passes
whichever direction you pick, which is why the bug survived it.
- `TestCollapseDuplicatesAndIndexIsAtomic` — a failed index creation
must roll the deletions back.
- `TestOpenStorePragmas` / `TestWriterDSNPragmas` — every writer pragma,
read back through the store's own connection. A separate `sqlite3`
session or the startup log line would prove nothing.
- `TestOpenDBRefusesMissingDatabase` — the read-only invariant, which
now rests on a detail of the driver's C wrapper.
- `TestEnsurePreparableMatchesPrepareStatements` — fails when a new
prepared statement outgrows the fixture helper.
CI gains test execution for `cmd/migrate` and `internal/dbschema`, which
had none and both open the database. A PR-time two-arch build plus an
arm64 QEMU smoke gate is new: the GHCR push is push/tag-only, so without
it nothing on a PR would exercise zig, static musl linking or arm64, and
the first signal would arrive on master. `cache-dependency-path` widens
from 2 of the 5 tracked `go.sum` files to all of them.
`make test` passes across all 14 modules, `cmd/server` also under `-race
-count=2` with no failures and no races. `gofmt` and `go vet` clean.
Release-routing and Dockerfile COPY-invariant gates pass.
## Verified by running
- All 8 cross-builds static and correct-architecture; both arches of the
container image built, exported and run under QEMU, serving
`/api/health` and `/api/nodes` against a 2.9M-observation production
snapshot.
- The `migrate` binary repairing that snapshot's duplicate on bare
Alpine.
- `CGO_ENABLED=0` producing a binary that builds and then fails on first
query.
## Not verified
- ~~The 2–2.3× figures come from a standalone harness, not this load
under the old driver.~~ **Closed** by @efiten's production run above,
which also corrected the multiplier.
- SQLite 3.46.0 → 3.53.4 query-planner differences on queries with no
total `ORDER BY`.
- Sustained live ingest through the new writer DSN, and the duplicate
collapse against a database an ingestor is actively writing to. Verified
against a static snapshot only, and the collapse is measured at 4.1s on
2.4M synthetic rows with 5 duplicates — well short of an 11M-row
instance. @efiten has offered a staging instance taking real MQTT
traffic; **this is the item to close before the PR leaves draft.**
An earlier revision of this branch shipped the dedup merge in the wrong
direction with a green test suite, and review then found three more
things in the same file: the repair gated on an error string, a
non-atomic TEMP table drop aimed at the wrong connection, and a deletion
whose only record was a row count. All fixed in ac7e8d38. Passing tests
did not establish safety here, which is why the deletion path wanted a
second pair of eyes rather than a rubber stamp.
1351 lines
58 KiB
Go
1351 lines
58 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/gorilla/mux"
|
|
)
|
|
|
|
// Full 64-hex pubkeys for fixtures. path_json hops are truncated hashes of
|
|
// 1 to 4 bytes, so the forwarder join matches a hop as a PREFIX of the full
|
|
// pubkey rather than by equality; the tests need a real full-length key to
|
|
// exercise that.
|
|
var (
|
|
testFullPubkeyA = "1a2b" + strings.Repeat("11", 30) // 64 hex chars
|
|
testFullPubkeyB = "bbbb" + strings.Repeat("22", 30) // 64 hex chars
|
|
)
|
|
|
|
// setupScopeConformanceDB builds an in-memory transmissions/observations pair
|
|
// carrying exactly the columns ScopeConformance reads: code1/scope_name/
|
|
// first_seen/route_type on transmissions, path_json on observations. Mirrors
|
|
// the live ingestor schema (cmd/ingestor/db.go) rather than a convenient
|
|
// fiction, so the join behaves the way it does against a real database.
|
|
func setupScopeConformanceDB(t *testing.T) *DB {
|
|
t.Helper()
|
|
conn, err := sql.Open("sqlite3", ":memory:")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
conn.SetMaxOpenConns(1)
|
|
schema := `
|
|
CREATE TABLE transmissions (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
raw_hex TEXT NOT NULL,
|
|
hash TEXT NOT NULL UNIQUE,
|
|
first_seen TEXT NOT NULL,
|
|
route_type INTEGER,
|
|
payload_type INTEGER,
|
|
code1 TEXT,
|
|
code2 TEXT,
|
|
scope_name TEXT
|
|
);
|
|
CREATE TABLE observations (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
transmission_id INTEGER NOT NULL REFERENCES transmissions(id),
|
|
path_json TEXT,
|
|
timestamp INTEGER NOT NULL
|
|
);
|
|
`
|
|
if _, err := conn.Exec(schema); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return &DB{conn: conn}
|
|
}
|
|
|
|
// newScopeTestStore wires a *PacketStore to a freshly seeded scope-conformance
|
|
// schema. ScopeConformance is a pure SQL read against s.db, so no other
|
|
// PacketStore field needs to be populated.
|
|
func newScopeTestStore(t *testing.T) *PacketStore {
|
|
t.Helper()
|
|
db := setupScopeConformanceDB(t)
|
|
return newTestStoreWithDB(t, db, &Config{})
|
|
}
|
|
|
|
// scopeSeed carries the code1/scope_name pair for one of the three states a
|
|
// seeded transmission can be in. scopeName mirrors scopeNameForDB's own
|
|
// encoding: nil means the packet carried no scope at all, a non-nil pointer
|
|
// to an empty string means transport-scoped but unmatched, and a non-nil
|
|
// pointer to a name means matched.
|
|
type scopeSeed struct {
|
|
code1 string
|
|
scopeName *string
|
|
}
|
|
|
|
func scopeMatched(name string) scopeSeed {
|
|
n := name
|
|
return scopeSeed{code1: "1234", scopeName: &n}
|
|
}
|
|
|
|
func scopeUnmatched() scopeSeed {
|
|
empty := ""
|
|
return scopeSeed{code1: "1234", scopeName: &empty}
|
|
}
|
|
|
|
func scopeUnscoped() scopeSeed {
|
|
return scopeSeed{code1: "0000", scopeName: nil}
|
|
}
|
|
|
|
var scopeSeedCounter int
|
|
|
|
// seedTransmissionRoute inserts one transmission plus a single observation
|
|
// attributing it to forwarder, built the way the ingestor would build it: the
|
|
// path_json hop is uppercase (packetpath.DecodePathFromRawHex does
|
|
// strings.ToUpper on every hop), so the seed exercises the same case the
|
|
// live join has to cope with rather than a lowercase convenience fiction.
|
|
func seedTransmissionRoute(t *testing.T, s *PacketStore, forwarder string, seed scopeSeed, routeType int) {
|
|
t.Helper()
|
|
seedTransmissionRouteAt(t, s, forwarder, seed, routeType, "2026-01-15T12:00:00Z")
|
|
}
|
|
|
|
// seedTransmissionRouteAt is seedTransmissionRoute with an explicit
|
|
// first_seen, for tests (e.g. the handler tests below) that need a
|
|
// transmission to fall inside a real-wall-clock ?window= lookback rather
|
|
// than the fixed date the ScopeConformance unit tests above use.
|
|
func seedTransmissionRouteAt(t *testing.T, s *PacketStore, forwarder string, seed scopeSeed, routeType int, firstSeen string) {
|
|
t.Helper()
|
|
scopeSeedCounter++
|
|
hash := fmt.Sprintf("scopehash%d", scopeSeedCounter)
|
|
|
|
res, err := s.db.conn.Exec(
|
|
`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, code1, code2, scope_name)
|
|
VALUES ('AA', ?, ?, ?, 1, ?, '00', ?)`,
|
|
hash, firstSeen, routeType, seed.code1, seed.scopeName,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("seed transmission: %v", err)
|
|
}
|
|
txID, err := res.LastInsertId()
|
|
if err != nil {
|
|
t.Fatalf("seed transmission id: %v", err)
|
|
}
|
|
|
|
pathJSON := fmt.Sprintf(`["%s"]`, strings.ToUpper(forwarder))
|
|
if _, err := s.db.conn.Exec(
|
|
`INSERT INTO observations (transmission_id, path_json, timestamp) VALUES (?, ?, ?)`,
|
|
txID, pathJSON, time.Now().Unix(),
|
|
); err != nil {
|
|
t.Fatalf("seed observation: %v", err)
|
|
}
|
|
}
|
|
|
|
// seedTransmission seeds a FLOOD packet (route_type=1) — path[last] is the
|
|
// actual transmitter, so forwarder is attributable.
|
|
func seedTransmission(t *testing.T, s *PacketStore, forwarder string, seed scopeSeed) {
|
|
t.Helper()
|
|
seedTransmissionRoute(t, s, forwarder, seed, RouteFlood)
|
|
}
|
|
|
|
// seedDirectTransmission seeds a DIRECT packet (route_type=2) — path[last] is
|
|
// the route's far end, never the transmitter, so forwarder must NOT be
|
|
// attributed even though it appears in path_json.
|
|
func seedDirectTransmission(t *testing.T, s *PacketStore, forwarder string, seed scopeSeed) {
|
|
t.Helper()
|
|
seedTransmissionRoute(t, s, forwarder, seed, RouteDirect)
|
|
}
|
|
|
|
func TestScopeAuditForwardingAttributesUnambiguousHop(t *testing.T) {
|
|
s := newScopeTestStore(t)
|
|
hop := testFullPubkeyA[:4]
|
|
recent := time.Now().UTC().Add(-time.Minute).Format(time.RFC3339)
|
|
seedTransmissionRouteAt(t, s, hop, scopeMatched("#be"), RouteFlood, recent)
|
|
|
|
got, err := s.ScopeAuditForwarding("2026-01-01T00:00:00Z", []string{testFullPubkeyA})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
agg := got[testFullPubkeyA]
|
|
if agg == nil || agg.scopes["be"] == nil || agg.scopes["be"].Packets != 1 {
|
|
t.Fatalf("want the hop attributed to the sole matching target, got %+v", got)
|
|
}
|
|
if agg.ambiguousHops != 0 {
|
|
t.Errorf("ambiguousHops = %d, want 0 — nothing here is ambiguous", agg.ambiguousHops)
|
|
}
|
|
}
|
|
|
|
// TestScopeAuditForwardingAmbiguousHopCreditsNeitherTarget is FIX 1's core
|
|
// case: two declared targets share a 2-byte prefix. A hop at that prefix
|
|
// must be attributed to NEITHER of them (crediting both would let a
|
|
// colliding neighbour's traffic silently paper over a real notObserved
|
|
// finding), and BOTH candidates' ambiguousHops counters must increment (so
|
|
// the row can surface the caveat regardless of which candidate is being
|
|
// looked at).
|
|
func TestScopeAuditForwardingAmbiguousHopCreditsNeitherTarget(t *testing.T) {
|
|
s := newScopeTestStore(t)
|
|
pkA := "1a2b" + strings.Repeat("11", 30)
|
|
pkB := "1a2b" + strings.Repeat("22", 30) // shares pkA's first 4 hex chars
|
|
hop := pkA[:4]
|
|
recent := time.Now().UTC().Add(-time.Minute).Format(time.RFC3339)
|
|
seedTransmissionRouteAt(t, s, hop, scopeMatched("#be"), RouteFlood, recent)
|
|
|
|
targets := []string{pkA, pkB}
|
|
got, err := s.ScopeAuditForwarding("2026-01-01T00:00:00Z", targets)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, pk := range targets {
|
|
agg := got[pk]
|
|
if agg == nil {
|
|
t.Fatalf("%s: want an agg present to carry the ambiguity counter, got none (result = %+v)", pk, got)
|
|
}
|
|
if len(agg.scopes) != 0 {
|
|
t.Errorf("%s: scopes = %+v, want empty — an ambiguous hop must not be attributed to either candidate", pk, agg.scopes)
|
|
}
|
|
if agg.unscopedPackets != 0 {
|
|
t.Errorf("%s: unscopedPackets = %d, want 0", pk, agg.unscopedPackets)
|
|
}
|
|
if agg.ambiguousHops != 1 {
|
|
t.Errorf("%s: ambiguousHops = %d, want 1", pk, agg.ambiguousHops)
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- GET /api/scope-audit handler tests ---
|
|
|
|
// setupScopeAuditServer builds a *Server over the ScopeConformance schema
|
|
// plus a nodes table carrying the confirmed-scope columns from #1865/#1971,
|
|
// which is where this endpoint reads its declared side. detectSchema must run
|
|
// after the columns exist so hasConfiguredScope is picked up; without it the
|
|
// handler correctly serves an empty audit and every assertion below would pass
|
|
// vacuously.
|
|
func setupScopeAuditServer(t *testing.T) (*Server, *mux.Router) {
|
|
t.Helper()
|
|
db := setupScopeConformanceDB(t)
|
|
if _, err := db.conn.Exec(`CREATE TABLE nodes (
|
|
public_key TEXT PRIMARY KEY,
|
|
name TEXT,
|
|
role TEXT,
|
|
configured_scope TEXT,
|
|
configured_scope_at TEXT
|
|
)`); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := db.detectSchema(context.Background(), db.conn); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !db.hasConfiguredScope {
|
|
t.Fatal("hasConfiguredScope is false after creating the column: the fixture would test nothing")
|
|
}
|
|
cfg := &Config{Port: 3000}
|
|
hub := NewHub()
|
|
srv := NewServer(db, cfg, hub)
|
|
srv.store = newTestStoreWithDB(t, db, cfg)
|
|
router := mux.NewRouter()
|
|
srv.RegisterRoutes(router)
|
|
return srv, router
|
|
}
|
|
|
|
// insertDeclared records a node's confirmed region list. Keeps the original
|
|
// signature so the ported cases read unchanged, but writes to the upstream
|
|
// source: nodes.configured_scope, which an observer /neighbors report fills
|
|
// (#1865/#1971).
|
|
//
|
|
// truncated is accepted and ignored. The old source recorded whether an answer
|
|
// was cut short; /neighbors has its own size cap but does not report whether it
|
|
// fired, so the audit cannot claim either way and the page omits the caveat
|
|
// rather than showing a wrong one. Kept in the signature so the ported tests
|
|
// still document which fixtures meant "this answer was incomplete".
|
|
func insertDeclared(t *testing.T, srv *Server, target, observedAt, regionsCSV string, truncated int) {
|
|
t.Helper()
|
|
_ = truncated
|
|
if _, err := srv.db.conn.Exec(
|
|
`INSERT INTO nodes (public_key, configured_scope, configured_scope_at) VALUES (?, ?, ?)
|
|
ON CONFLICT(public_key) DO UPDATE SET configured_scope = excluded.configured_scope,
|
|
configured_scope_at = excluded.configured_scope_at`,
|
|
target, regionsCSV, observedAt); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func getScopeAudit(t *testing.T, router *mux.Router, query string) ScopeAuditResponse {
|
|
t.Helper()
|
|
req := httptest.NewRequest("GET", "/api/scope-audit"+query, nil)
|
|
w := httptest.NewRecorder()
|
|
router.ServeHTTP(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200: %s", w.Code, w.Body.String())
|
|
}
|
|
var got ScopeAuditResponse
|
|
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
return got
|
|
}
|
|
|
|
// TestHandleScopeAuditNormalisesHashPrefix pins trap 1: transmissions.scope_name
|
|
// keeps the '#' (hashRegions config), regions_csv arrives from the firmware
|
|
// with it already stripped. Declared "be-van" and observed "#be-van" must be
|
|
// recognised as the same scope, not reported as both missing and undeclared.
|
|
func TestHandleScopeAuditNormalisesHashPrefix(t *testing.T) {
|
|
srv, router := setupScopeAuditServer(t)
|
|
pk := testFullPubkeyA
|
|
hop := pk[:4]
|
|
recent := time.Now().UTC().Add(-time.Minute).Format(time.RFC3339)
|
|
insertDeclared(t, srv, pk, time.Now().UTC().Format(time.RFC3339), "be-van", 0)
|
|
seedTransmissionRouteAt(t, srv.store, hop, scopeMatched("#be-van"), RouteFlood, recent)
|
|
|
|
// FIX 2: the DECLARED side must be normalised too, not just observed.
|
|
// regions_csv is not guaranteed to arrive with '#' already stripped (a
|
|
// firmware variant, an operator-seeded row, or a future collector could
|
|
// leave it on) — declaring "#be-van" and observing "#be-van" (which the
|
|
// observed side always normalises to "be-van") must still match. Without
|
|
// the fix, declaredNamed/declaredSet keep the raw "#be-van", so this
|
|
// second repeater's row would show BOTH "#be-van" in notObserved (the
|
|
// declared value never matches the normalised agg.scopes key) AND
|
|
// "be-van" in undeclaredObserved (the normalised observed name isn't in
|
|
// declaredSet) — the exact trap normScope exists to prevent, reappearing
|
|
// on the other side of the comparison. Seeded alongside pk (not as a
|
|
// separate getScopeAudit call) so both land in one response and neither
|
|
// is masked by the 30s response cache.
|
|
pk2 := testFullPubkeyB
|
|
hop2 := pk2[:4]
|
|
insertDeclared(t, srv, pk2, time.Now().UTC().Format(time.RFC3339), "#be-van", 0)
|
|
seedTransmissionRouteAt(t, srv.store, hop2, scopeMatched("#be-van"), RouteFlood, recent)
|
|
|
|
got := getScopeAudit(t, router, "")
|
|
if len(got.Repeaters) != 2 {
|
|
t.Fatalf("repeaters = %+v, want exactly 2", got.Repeaters)
|
|
}
|
|
|
|
row := findScopeAuditRow(t, got.Repeaters, pk)
|
|
if len(row.NotObserved) != 0 {
|
|
t.Errorf("notObserved = %v, want empty — declared \"be-van\" observed as \"#be-van\" must match after normalisation", row.NotObserved)
|
|
}
|
|
if len(row.UndeclaredObserved) != 0 {
|
|
t.Errorf("undeclaredObserved = %+v, want empty", row.UndeclaredObserved)
|
|
}
|
|
|
|
row2 := findScopeAuditRow(t, got.Repeaters, pk2)
|
|
if len(row2.DeclaredRegions) != 1 || row2.DeclaredRegions[0] != "be-van" {
|
|
t.Errorf("declaredRegions = %v, want [\"be-van\"] — the leading '#' must be stripped from the declared side too", row2.DeclaredRegions)
|
|
}
|
|
if len(row2.NotObserved) != 0 {
|
|
t.Errorf("notObserved = %v, want empty — declared \"#be-van\" observed as \"#be-van\" must match after normalising BOTH sides", row2.NotObserved)
|
|
}
|
|
if len(row2.UndeclaredObserved) != 0 {
|
|
t.Errorf("undeclaredObserved = %+v, want empty", row2.UndeclaredObserved)
|
|
}
|
|
}
|
|
|
|
// findScopeAuditRow locates the row for pk, failing the test if absent —
|
|
// used wherever more than one repeater is seeded in the same response, since
|
|
// sort order is not by pubkey and cannot be relied on to pick the right row.
|
|
func findScopeAuditRow(t *testing.T, rows []ScopeAuditRow, pk string) ScopeAuditRow {
|
|
t.Helper()
|
|
for _, r := range rows {
|
|
if r.PublicKey == pk {
|
|
return r
|
|
}
|
|
}
|
|
t.Fatalf("no row for pubkey %s in %+v", pk, rows)
|
|
return ScopeAuditRow{}
|
|
}
|
|
|
|
// TestHandleScopeAuditExcludesWildcardFromComparison pins trap 2: '*' is the
|
|
// root of the region tree (governs plain FLOOD), not a scope. It must never
|
|
// appear in declaredRegions (or its count) or in notObserved/undeclaredObserved.
|
|
func TestHandleScopeAuditExcludesWildcardFromComparison(t *testing.T) {
|
|
srv, router := setupScopeAuditServer(t)
|
|
pk := testFullPubkeyA
|
|
insertDeclared(t, srv, pk, time.Now().UTC().Format(time.RFC3339), "*,be", 0)
|
|
|
|
got := getScopeAudit(t, router, "")
|
|
if len(got.Repeaters) != 1 {
|
|
t.Fatalf("repeaters = %+v, want 1", got.Repeaters)
|
|
}
|
|
row := got.Repeaters[0]
|
|
if !row.DeclaredWildcard {
|
|
t.Error("declaredWildcard = false, want true")
|
|
}
|
|
if len(row.DeclaredRegions) != 1 || row.DeclaredRegions[0] != "be" {
|
|
t.Errorf("declaredRegions = %v, want [\"be\"] — '*' must be excluded from the region list and its count", row.DeclaredRegions)
|
|
}
|
|
for _, r := range row.NotObserved {
|
|
if r == "*" {
|
|
t.Error("notObserved contains '*' — it must never be treated as a scope")
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestHandleScopeAuditOmitsRepeaterWithNoDeclaredRow pins trap 3: a repeater
|
|
// that was never successfully asked is absent from the response entirely —
|
|
// not shown as a row declaring nothing, which is a distinct, meaningful fact.
|
|
func TestHandleScopeAuditOmitsRepeaterWithNoDeclaredRow(t *testing.T) {
|
|
srv, router := setupScopeAuditServer(t)
|
|
declaredPk := testFullPubkeyA
|
|
neverAskedPk := testFullPubkeyB
|
|
insertDeclared(t, srv, declaredPk, time.Now().UTC().Format(time.RFC3339), "be", 0)
|
|
if _, err := srv.db.conn.Exec(`INSERT INTO nodes (public_key, name, role) VALUES (?, 'never-asked', 'repeater')`, neverAskedPk); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
got := getScopeAudit(t, router, "")
|
|
if len(got.Repeaters) != 1 || got.Repeaters[0].PublicKey != declaredPk {
|
|
t.Fatalf("repeaters = %+v, want exactly the one repeater that has declared", got.Repeaters)
|
|
}
|
|
}
|
|
|
|
// TestHandleScopeAuditUnknownNodeNameIsNullNotEmpty proves a declared target
|
|
// this instance holds no nodes row for serialises name/role as null, not "".
|
|
// A declared-regions answer can name a repeater the network has never recorded,
|
|
// and "" would make that indistinguishable from a node we DO know that simply
|
|
// has no name — the same absent-is-not-empty rule the declared side follows.
|
|
func TestHandleScopeAuditUnknownNodeNameIsNullNotEmpty(t *testing.T) {
|
|
srv, router := setupScopeAuditServer(t)
|
|
pk := testFullPubkeyA
|
|
insertDeclared(t, srv, pk, time.Now().UTC().Format(time.RFC3339), "be", 0)
|
|
// deliberately NO nodes row for pk
|
|
|
|
req := httptest.NewRequest("GET", "/api/scope-audit", nil)
|
|
w := httptest.NewRecorder()
|
|
router.ServeHTTP(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200: %s", w.Code, w.Body.String())
|
|
}
|
|
body := w.Body.String()
|
|
if !strings.Contains(body, `"name":null`) {
|
|
t.Errorf(`body must carry "name":null for a target with no nodes row, got: %s`, body)
|
|
}
|
|
if strings.Contains(body, `"name":""`) {
|
|
t.Error(`"name":"" collapses "unknown node" into "node with no name"`)
|
|
}
|
|
|
|
var got ScopeAuditResponse
|
|
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(got.Repeaters) != 1 {
|
|
t.Fatalf("repeaters = %d, want 1", len(got.Repeaters))
|
|
}
|
|
if got.Repeaters[0].Name != nil {
|
|
t.Errorf("Name = %q, want nil", *got.Repeaters[0].Name)
|
|
}
|
|
}
|
|
|
|
// TestHandleScopeAuditWildcardContradiction: observed forwarding unscoped
|
|
// (plain-FLOOD) traffic while the declared list omits '*' is the wildcard
|
|
// contradiction this endpoint must flag — the repeater says it does NOT
|
|
// forward those packets, and the traffic says otherwise.
|
|
func TestHandleScopeAuditWildcardContradiction(t *testing.T) {
|
|
srv, router := setupScopeAuditServer(t)
|
|
pk := testFullPubkeyA
|
|
hop := pk[:4]
|
|
insertDeclared(t, srv, pk, time.Now().UTC().Format(time.RFC3339), "be", 0) // no '*'
|
|
recent := time.Now().UTC().Add(-time.Minute).Format(time.RFC3339)
|
|
seedTransmissionRouteAt(t, srv.store, hop, scopeUnscoped(), RouteFlood, recent)
|
|
|
|
got := getScopeAudit(t, router, "")
|
|
if len(got.Repeaters) != 1 {
|
|
t.Fatalf("repeaters = %+v, want 1", got.Repeaters)
|
|
}
|
|
row := got.Repeaters[0]
|
|
if !row.WildcardContradiction {
|
|
t.Error("wildcardContradiction = false, want true — observed unscoped forwarding but '*' not declared")
|
|
}
|
|
if row.ObservedUnscopedPackets != 1 {
|
|
t.Errorf("observedUnscopedPackets = %d, want 1", row.ObservedUnscopedPackets)
|
|
}
|
|
}
|
|
|
|
// TestHandleScopeAuditWildcardDeclaredIsNotAContradiction is the other half:
|
|
// the same observed unscoped traffic is expected, not a contradiction, once
|
|
// '*' is declared.
|
|
func TestHandleScopeAuditWildcardDeclaredIsNotAContradiction(t *testing.T) {
|
|
srv, router := setupScopeAuditServer(t)
|
|
pk := testFullPubkeyA
|
|
hop := pk[:4]
|
|
insertDeclared(t, srv, pk, time.Now().UTC().Format(time.RFC3339), "*,be", 0)
|
|
recent := time.Now().UTC().Add(-time.Minute).Format(time.RFC3339)
|
|
seedTransmissionRouteAt(t, srv.store, hop, scopeUnscoped(), RouteFlood, recent)
|
|
|
|
got := getScopeAudit(t, router, "")
|
|
if got.Repeaters[0].WildcardContradiction {
|
|
t.Error("wildcardContradiction = true, want false — '*' IS declared, so unscoped forwarding is expected")
|
|
}
|
|
}
|
|
|
|
// TestHandleScopeAuditNotObservedAndUndeclaredObserved exercises both
|
|
// comparison directions in one repeater: a declared region with zero
|
|
// observed forwarding, and an observed scope absent from the declared list.
|
|
func TestHandleScopeAuditNotObservedAndUndeclaredObserved(t *testing.T) {
|
|
srv, router := setupScopeAuditServer(t)
|
|
pk := testFullPubkeyA
|
|
hop := pk[:4]
|
|
insertDeclared(t, srv, pk, time.Now().UTC().Format(time.RFC3339), "be,be-vlg", 0)
|
|
recent := time.Now().UTC().Add(-time.Minute).Format(time.RFC3339)
|
|
seedTransmissionRouteAt(t, srv.store, hop, scopeMatched("#be"), RouteFlood, recent)
|
|
seedTransmissionRouteAt(t, srv.store, hop, scopeMatched("#de-nw"), RouteFlood, recent)
|
|
|
|
got := getScopeAudit(t, router, "")
|
|
row := got.Repeaters[0]
|
|
if len(row.NotObserved) != 1 || row.NotObserved[0] != "be-vlg" {
|
|
t.Errorf("notObserved = %v, want [\"be-vlg\"]", row.NotObserved)
|
|
}
|
|
if len(row.UndeclaredObserved) != 1 || row.UndeclaredObserved[0].Scope != "de-nw" {
|
|
t.Errorf("undeclaredObserved = %+v, want exactly \"de-nw\"", row.UndeclaredObserved)
|
|
}
|
|
}
|
|
|
|
// TestHandleScopeAuditSurfacesAmbiguousHops is FIX 1's handler-level case:
|
|
// two repeaters both declare "be" and share a 2-byte pubkey prefix. The one
|
|
// hop seen in the window can't be attributed to either, so both rows must
|
|
// still show "be" as notObserved (an ambiguous hop invents no attribution,
|
|
// so it cannot silently satisfy the declared region) AND both rows must
|
|
// carry ambiguousHops=1, the caveat that the notObserved finding might be a
|
|
// prefix collision rather than a real gap.
|
|
func TestHandleScopeAuditSurfacesAmbiguousHops(t *testing.T) {
|
|
srv, router := setupScopeAuditServer(t)
|
|
pkA := "1a2b" + strings.Repeat("11", 30)
|
|
pkB := "1a2b" + strings.Repeat("22", 30)
|
|
hop := pkA[:4]
|
|
insertDeclared(t, srv, pkA, time.Now().UTC().Format(time.RFC3339), "be", 0)
|
|
insertDeclared(t, srv, pkB, time.Now().UTC().Format(time.RFC3339), "be", 0)
|
|
recent := time.Now().UTC().Add(-time.Minute).Format(time.RFC3339)
|
|
seedTransmissionRouteAt(t, srv.store, hop, scopeMatched("#be"), RouteFlood, recent)
|
|
|
|
got := getScopeAudit(t, router, "")
|
|
if len(got.Repeaters) != 2 {
|
|
t.Fatalf("repeaters = %+v, want 2", got.Repeaters)
|
|
}
|
|
for _, row := range got.Repeaters {
|
|
if row.AmbiguousHops != 1 {
|
|
t.Errorf("%s: ambiguousHops = %d, want 1", row.PublicKey, row.AmbiguousHops)
|
|
}
|
|
if len(row.NotObserved) != 1 || row.NotObserved[0] != "be" {
|
|
t.Errorf("%s: notObserved = %v, want [\"be\"] — an ambiguous hop must not silently satisfy the declared region", row.PublicKey, row.NotObserved)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestHandleScopeAuditSortsMissingRegionsFirst: the repeater with a declared
|
|
// region it is not forwarding must rank above a repeater in full agreement —
|
|
// that's the headline this endpoint exists to surface, not the boring majority.
|
|
func TestHandleScopeAuditSortsMissingRegionsFirst(t *testing.T) {
|
|
srv, router := setupScopeAuditServer(t)
|
|
agreePk := testFullPubkeyA
|
|
missingPk := testFullPubkeyB
|
|
agreeHop := agreePk[:4]
|
|
missingHop := missingPk[:4]
|
|
insertDeclared(t, srv, agreePk, time.Now().UTC().Format(time.RFC3339), "be", 0)
|
|
insertDeclared(t, srv, missingPk, time.Now().UTC().Format(time.RFC3339), "be,be-vlg", 0)
|
|
recent := time.Now().UTC().Add(-time.Minute).Format(time.RFC3339)
|
|
seedTransmissionRouteAt(t, srv.store, agreeHop, scopeMatched("#be"), RouteFlood, recent)
|
|
seedTransmissionRouteAt(t, srv.store, missingHop, scopeMatched("#be"), RouteFlood, recent)
|
|
// missingPk never forwards be-vlg -> 1 missing declared region; agreePk has 0.
|
|
|
|
got := getScopeAudit(t, router, "")
|
|
if len(got.Repeaters) != 2 {
|
|
t.Fatalf("repeaters = %+v, want 2", got.Repeaters)
|
|
}
|
|
if got.Repeaters[0].PublicKey != missingPk {
|
|
t.Errorf("repeaters[0] = %s, want %s (the row missing a declared region) ranked first", got.Repeaters[0].PublicKey, missingPk)
|
|
}
|
|
}
|
|
|
|
// TestHandleScopeAuditWindowVocabulary confirms this endpoint matches the
|
|
// vocabulary used by the sibling /api/scope-stats and /api/nodes/{pubkey}/scopes
|
|
// endpoints (1h, 24h, 7d), not the broader ParseTimeWindow alias set.
|
|
func TestHandleScopeAuditWindowVocabulary(t *testing.T) {
|
|
_, router := setupScopeAuditServer(t)
|
|
req := httptest.NewRequest("GET", "/api/scope-audit?window=30d", nil)
|
|
w := httptest.NewRecorder()
|
|
router.ServeHTTP(w, req)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("window=30d: status = %d, want 400 (not part of this endpoint's vocabulary)", w.Code)
|
|
}
|
|
}
|
|
|
|
// TestHandleScopeAuditFiltersBlacklistedNode confirms a blacklisted repeater
|
|
// is dropped from the audit even though it has declared a region list,
|
|
// mirroring the blacklist filtering applied by other multi-node endpoints.
|
|
func TestHandleScopeAuditFiltersBlacklistedNode(t *testing.T) {
|
|
srv, router := setupScopeAuditServer(t)
|
|
pk := testFullPubkeyA
|
|
insertDeclared(t, srv, pk, time.Now().UTC().Format(time.RFC3339), "be", 0)
|
|
srv.cfg.NodeBlacklist = []string{pk}
|
|
|
|
got := getScopeAudit(t, router, "")
|
|
if len(got.Repeaters) != 0 {
|
|
t.Errorf("repeaters = %+v, want empty — blacklisted node must be excluded", got.Repeaters)
|
|
}
|
|
}
|
|
|
|
// TestHandleScopeAuditFiltersHiddenNamePrefix is the scope-audit twin of
|
|
// TestHandleScopeAuditFiltersBlacklistedNode, covering FIX 5: a repeater
|
|
// whose known name matches an operator-configured hidden-name prefix is
|
|
// excluded. It also pins the subtler half of the `id.Name != nil` guard in
|
|
// handleScopeAudit — a declared target this instance holds NO nodes row for
|
|
// has no name to test a hidden-prefix rule against, so it must NOT be
|
|
// filtered merely for lacking a name; only a KNOWN, matching name is ever
|
|
// grounds for hiding.
|
|
func TestHandleScopeAuditFiltersHiddenNamePrefix(t *testing.T) {
|
|
srv, router := setupScopeAuditServer(t)
|
|
hiddenPk := testFullPubkeyA
|
|
unknownPk := testFullPubkeyB
|
|
insertDeclared(t, srv, hiddenPk, time.Now().UTC().Format(time.RFC3339), "be", 0)
|
|
insertDeclared(t, srv, unknownPk, time.Now().UTC().Format(time.RFC3339), "be", 0)
|
|
// Upsert, not insert: upstream the declared list lives ON the nodes row
|
|
// (configured_scope), so insertDeclared has already created it. The fork
|
|
// kept them in separate tables and a plain INSERT was safe there.
|
|
if _, err := srv.db.conn.Exec(
|
|
`INSERT INTO nodes (public_key, name, role) VALUES (?, ?, 'repeater')
|
|
ON CONFLICT(public_key) DO UPDATE SET name = excluded.name, role = excluded.role`,
|
|
hiddenPk, "🚫 ban me"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// unknownPk deliberately gets NO name. On the fork that meant no nodes row
|
|
// at all; here the row exists (configured_scope lives on it) but name stays
|
|
// NULL. The property under test is unchanged: a node whose name we do not
|
|
// know must never be hidden by a name-prefix rule.
|
|
srv.cfg.SetHiddenNamePrefixes([]string{"🚫"})
|
|
|
|
got := getScopeAudit(t, router, "")
|
|
if len(got.Repeaters) != 1 || got.Repeaters[0].PublicKey != unknownPk {
|
|
t.Fatalf("repeaters = %+v, want exactly the unknown-name target — the hidden-name-prefixed repeater must be excluded, and the nameless one must NOT be excluded merely for having no name", got.Repeaters)
|
|
}
|
|
}
|
|
|
|
// TestHandleScopeAuditNoDeclaredRegionsTable covers the missing-table
|
|
// degrade path (mirrors TestHandleNodeScopesNoDeclaredRegionsTable): an
|
|
// older database predating the configured_scope column must not fail the
|
|
// request: the audit simply has no declared side to report.
|
|
func TestHandleScopeAuditWithoutConfiguredScopeColumn(t *testing.T) {
|
|
// setupScopeConformanceDB creates no nodes table, so detectSchema leaves
|
|
// hasConfiguredScope false: exactly an instance that has not yet ingested a
|
|
// /neighbors report or is on a schema predating #1971.
|
|
db := setupScopeConformanceDB(t)
|
|
cfg := &Config{Port: 3000}
|
|
hub := NewHub()
|
|
srv := NewServer(db, cfg, hub)
|
|
srv.store = newTestStoreWithDB(t, db, cfg)
|
|
router := mux.NewRouter()
|
|
srv.RegisterRoutes(router)
|
|
|
|
got := getScopeAudit(t, router, "")
|
|
if len(got.Repeaters) != 0 {
|
|
t.Errorf("repeaters = %+v, want empty when configured_scope does not exist", got.Repeaters)
|
|
}
|
|
}
|
|
|
|
// --- Scope audit config-state classification ---
|
|
|
|
// TestScopeAuditConfigStateClassification is a table-driven test of the pure
|
|
// classification function scopeAuditConfigState — the derivation is a plain
|
|
// switch over (namedRegions, wildcard), so each of the four cells is worth
|
|
// pinning directly rather than only indirectly via the handler.
|
|
func TestScopeAuditConfigStateClassification(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
namedRegions []string
|
|
wildcard bool
|
|
want string
|
|
}{
|
|
{"named and wildcard", []string{"be"}, true, ScopeConfigFull},
|
|
{"wildcard only", nil, true, ScopeConfigNoScopes},
|
|
{"named only", []string{"be"}, false, ScopeConfigNoUnscoped},
|
|
{"neither", nil, false, ScopeConfigNoFlood},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := scopeAuditConfigState(tt.namedRegions, tt.wildcard)
|
|
if got != tt.want {
|
|
t.Errorf("scopeAuditConfigState(%v, %v) = %q, want %q", tt.namedRegions, tt.wildcard, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestHandleScopeAuditConfigStateAllFourShapes seeds one repeater per shape
|
|
// of the (declaredRegions, declaredWildcard) pair — including the fourth,
|
|
// "answered but empty" shape (no named regions AND no '*') that sits outside
|
|
// the three named states — and confirms both that each row's ConfigState
|
|
// lands correctly AND that tallying ConfigState across the response (the
|
|
// same arithmetic the frontend summary line performs over d.repeaters)
|
|
// reproduces the exact per-state counts seeded here. That second check is
|
|
// what pins "the counts in the summary match the rows": if
|
|
// scopeAuditConfigState mis-tags even one row, the tally computed from the
|
|
// response no longer matches what was seeded here, and the test fails.
|
|
func TestHandleScopeAuditConfigStateAllFourShapes(t *testing.T) {
|
|
srv, router := setupScopeAuditServer(t)
|
|
now := time.Now().UTC().Format(time.RFC3339)
|
|
|
|
fullPk := strings.Repeat("11", 32)
|
|
noScopesPk := strings.Repeat("22", 32)
|
|
noUnscopedPk := strings.Repeat("33", 32)
|
|
noFloodPk := strings.Repeat("44", 32)
|
|
|
|
insertDeclared(t, srv, fullPk, now, "*,be", 0)
|
|
insertDeclared(t, srv, noScopesPk, now, "*", 0)
|
|
insertDeclared(t, srv, noUnscopedPk, now, "be", 0)
|
|
insertDeclared(t, srv, noFloodPk, now, "", 0)
|
|
|
|
got := getScopeAudit(t, router, "")
|
|
if len(got.Repeaters) != 4 {
|
|
t.Fatalf("repeaters = %+v, want exactly 4", got.Repeaters)
|
|
}
|
|
|
|
want := map[string]string{
|
|
fullPk: ScopeConfigFull,
|
|
noScopesPk: ScopeConfigNoScopes,
|
|
noUnscopedPk: ScopeConfigNoUnscoped,
|
|
noFloodPk: ScopeConfigNoFlood,
|
|
}
|
|
for pk, wantState := range want {
|
|
row := findScopeAuditRow(t, got.Repeaters, pk)
|
|
if row.ConfigState != wantState {
|
|
t.Errorf("pk %s: configState = %q, want %q", pk, row.ConfigState, wantState)
|
|
}
|
|
}
|
|
|
|
counts := map[string]int{}
|
|
for _, row := range got.Repeaters {
|
|
counts[row.ConfigState]++
|
|
}
|
|
for _, state := range []string{ScopeConfigFull, ScopeConfigNoScopes, ScopeConfigNoUnscoped, ScopeConfigNoFlood} {
|
|
if counts[state] != 1 {
|
|
t.Errorf("count of state %q across repeaters = %d, want 1 (summary tally must match the seeded rows)", state, counts[state])
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestHandleNodeScopesDifferentWindowIsSeparateCacheEntry confirms the cache
|
|
// key includes window: a request for a different window must recompute
|
|
// rather than reuse another window's cached entry.
|
|
|
|
// --- Merging confirmed-scope sources (#1975) ---
|
|
|
|
// createDeclaredRegionsTable adds the optional second source and re-probes the
|
|
// schema, so hasDeclaredRegionsTable flips. Without the re-probe the merge
|
|
// would skip the table and every assertion below would pass vacuously.
|
|
func createDeclaredRegionsTable(t *testing.T, srv *Server) {
|
|
t.Helper()
|
|
if _, err := srv.db.conn.Exec(`
|
|
CREATE TABLE node_declared_regions (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
target TEXT NOT NULL,
|
|
rx_pubkey TEXT NOT NULL,
|
|
observed_at TEXT NOT NULL,
|
|
ingested_at TEXT NOT NULL,
|
|
regions_csv TEXT NOT NULL,
|
|
truncated INTEGER NOT NULL DEFAULT 0,
|
|
UNIQUE(target, rx_pubkey, observed_at)
|
|
)`); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := srv.db.detectSchema(context.Background(), srv.db.conn); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !srv.db.hasDeclaredRegionsTable {
|
|
t.Fatal("hasDeclaredRegionsTable is false after creating the table: the fixture would test nothing")
|
|
}
|
|
}
|
|
|
|
func seedSecondSource(t *testing.T, srv *Server, target, observedAt, regionsCSV string, truncated int) {
|
|
t.Helper()
|
|
if _, err := srv.db.conn.Exec(
|
|
`INSERT INTO node_declared_regions (target, rx_pubkey, observed_at, ingested_at, regions_csv, truncated)
|
|
VALUES (?, 'rxpubkeyhex', ?, ?, ?, ?)`,
|
|
target, observedAt, observedAt, regionsCSV, truncated); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func declaredFor(t *testing.T, srv *Server, target string) (DeclaredRegionsRow, bool) {
|
|
t.Helper()
|
|
rows, err := srv.db.AllCurrentDeclaredRegions()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, r := range rows {
|
|
if r.Target == target {
|
|
return r, true
|
|
}
|
|
}
|
|
return DeclaredRegionsRow{}, false
|
|
}
|
|
|
|
func TestDeclaredRegionsMergeUsesConfiguredScopeAlone(t *testing.T) {
|
|
srv, _ := setupScopeAuditServer(t)
|
|
insertDeclared(t, srv, testFullPubkeyA, "2026-09-01T00:00:00Z", "#be,#eu", 0)
|
|
got, ok := declaredFor(t, srv, testFullPubkeyA)
|
|
if !ok || got.RegionsCSV != "#be,#eu" {
|
|
t.Fatalf("got %+v (found=%v), want the configured_scope answer", got, ok)
|
|
}
|
|
}
|
|
|
|
func TestDeclaredRegionsMergeUsesSecondSourceAlone(t *testing.T) {
|
|
// The case that made this merge necessary: an instance collecting confirmed
|
|
// scopes by another route, with no configured_scope written yet. Reading
|
|
// only configured_scope would render an empty page on real data.
|
|
srv, _ := setupScopeAuditServer(t)
|
|
createDeclaredRegionsTable(t, srv)
|
|
seedSecondSource(t, srv, testFullPubkeyA, "2026-09-01T00:00:00Z", "be,eu", 0)
|
|
got, ok := declaredFor(t, srv, testFullPubkeyA)
|
|
if !ok || got.RegionsCSV != "be,eu" {
|
|
t.Fatalf("got %+v (found=%v), want the second source's answer", got, ok)
|
|
}
|
|
}
|
|
|
|
func TestDeclaredRegionsMergeNewestAnswerWins(t *testing.T) {
|
|
srv, _ := setupScopeAuditServer(t)
|
|
createDeclaredRegionsTable(t, srv)
|
|
insertDeclared(t, srv, testFullPubkeyA, "2026-09-01T00:00:00Z", "#old", 0)
|
|
seedSecondSource(t, srv, testFullPubkeyA, "2026-09-02T00:00:00Z", "newer", 0)
|
|
got, _ := declaredFor(t, srv, testFullPubkeyA)
|
|
if got.RegionsCSV != "newer" {
|
|
t.Errorf("regions = %q, want the later answer regardless of which source it came from", got.RegionsCSV)
|
|
}
|
|
|
|
// And the other way round, so the rule is "newest wins" and not "one source
|
|
// always beats the other".
|
|
insertDeclared(t, srv, testFullPubkeyB, "2026-09-03T00:00:00Z", "#newer", 0)
|
|
seedSecondSource(t, srv, testFullPubkeyB, "2026-09-02T00:00:00Z", "old", 0)
|
|
gotB, _ := declaredFor(t, srv, testFullPubkeyB)
|
|
if gotB.RegionsCSV != "#newer" {
|
|
t.Errorf("regions = %q, want the later answer from the other source", gotB.RegionsCSV)
|
|
}
|
|
}
|
|
|
|
func TestDeclaredRegionsMergeAnswerWithAnInstantBeatsOneWithout(t *testing.T) {
|
|
// An empty instant means "we do not know when this was answered". It must
|
|
// not outrank a dated answer, but it must still be used when it is all we
|
|
// have.
|
|
srv, _ := setupScopeAuditServer(t)
|
|
createDeclaredRegionsTable(t, srv)
|
|
insertDeclared(t, srv, testFullPubkeyA, "", "#undated", 0)
|
|
seedSecondSource(t, srv, testFullPubkeyA, "2026-09-01T00:00:00Z", "dated", 0)
|
|
got, _ := declaredFor(t, srv, testFullPubkeyA)
|
|
if got.RegionsCSV != "dated" {
|
|
t.Errorf("regions = %q, want the dated answer to win", got.RegionsCSV)
|
|
}
|
|
|
|
insertDeclared(t, srv, testFullPubkeyB, "", "#undated", 0)
|
|
gotB, ok := declaredFor(t, srv, testFullPubkeyB)
|
|
if !ok || gotB.RegionsCSV != "#undated" {
|
|
t.Errorf("got %+v (found=%v), want an undated answer to be used when it is the only one", gotB, ok)
|
|
}
|
|
}
|
|
|
|
func TestDeclaredRegionsMergeCarriesTruncatedFromTheSourceThatHasIt(t *testing.T) {
|
|
srv, _ := setupScopeAuditServer(t)
|
|
createDeclaredRegionsTable(t, srv)
|
|
seedSecondSource(t, srv, testFullPubkeyA, "2026-09-01T00:00:00Z", "be", 1)
|
|
got, _ := declaredFor(t, srv, testFullPubkeyA)
|
|
if !got.Truncated {
|
|
t.Error("truncated must survive the merge from a source that records it")
|
|
}
|
|
}
|
|
|
|
func TestDeclaredRegionsMergeWithNoSourcesIsEmptyNotAnError(t *testing.T) {
|
|
db := setupScopeConformanceDB(t)
|
|
rows, err := db.AllCurrentDeclaredRegions()
|
|
if err != nil {
|
|
t.Fatalf("no sources must not be an error: %v", err)
|
|
}
|
|
if len(rows) != 0 {
|
|
t.Errorf("rows = %+v, want empty", rows)
|
|
}
|
|
}
|
|
|
|
// --- Every-hop forwarder attribution and the scan that pays for it ---
|
|
//
|
|
// These cover the change from crediting path[last] to crediting every hop of a
|
|
// flood-family route, and the two-column scan plus per-window TTL that keeps
|
|
// the wider scan affordable.
|
|
|
|
// seedTransmissionPathAt seeds one transmission whose single observation
|
|
// carries a MULTI-hop path. A one-hop seed cannot tell the two reasons a node
|
|
// gets attributed apart — it is simultaneously path[0] and path[last] — so the
|
|
// mid-path cases below need a path with something after the target on it.
|
|
//
|
|
// Hops are upper-cased for the same reason seedTransmissionRoute does it: the
|
|
// decoder writes them that way (packetpath.DecodePathFromRawHex), and the join
|
|
// has to cope with that rather than with a lowercase convenience fiction.
|
|
func seedTransmissionPathAt(t *testing.T, s *PacketStore, hops []string, seed scopeSeed, routeType int, firstSeen string) {
|
|
t.Helper()
|
|
scopeSeedCounter++
|
|
hash := fmt.Sprintf("scopehash%d", scopeSeedCounter)
|
|
|
|
res, err := s.db.conn.Exec(
|
|
`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, code1, code2, scope_name)
|
|
VALUES ('AA', ?, ?, ?, 1, ?, '00', ?)`,
|
|
hash, firstSeen, routeType, seed.code1, seed.scopeName,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("seed transmission: %v", err)
|
|
}
|
|
txID, err := res.LastInsertId()
|
|
if err != nil {
|
|
t.Fatalf("seed transmission id: %v", err)
|
|
}
|
|
|
|
quoted := make([]string, len(hops))
|
|
for i, h := range hops {
|
|
quoted[i] = `"` + strings.ToUpper(h) + `"`
|
|
}
|
|
pathJSON := "[" + strings.Join(quoted, ",") + "]"
|
|
if _, err := s.db.conn.Exec(
|
|
`INSERT INTO observations (transmission_id, path_json, timestamp) VALUES (?, ?, ?)`,
|
|
txID, pathJSON, time.Now().Unix(),
|
|
); err != nil {
|
|
t.Fatalf("seed observation: %v", err)
|
|
}
|
|
}
|
|
|
|
// seedTransmission seeds a FLOOD packet (route_type=1) — path[last] is the
|
|
// actual transmitter, so forwarder is attributable.
|
|
|
|
// TestScopeAuditForwardingAttributesMidPathHop is the fleet-wide half of the
|
|
// mid-path attribution fix. The audit runs a different query from
|
|
// ScopeConformance — one full-window scan instead of one EXISTS per pubkey — so
|
|
// the two share the rule but not the code, and both need pinning.
|
|
//
|
|
// This is the case behind the audit's 65% blind spot: a declared target that
|
|
// forwards steadily but is never the hop an observer hears directly had every
|
|
// region it declares reported as notObserved.
|
|
func TestScopeAuditForwardingAttributesMidPathHop(t *testing.T) {
|
|
s := newScopeTestStore(t)
|
|
recent := time.Now().UTC().Add(-time.Minute).Format(time.RFC3339)
|
|
seedTransmissionPathAt(t, s, []string{testFullPubkeyA[:4], "AAAA", "BBBB"}, scopeMatched("#be"), RouteFlood, recent)
|
|
|
|
got, err := s.ScopeAuditForwarding("2026-01-01T00:00:00Z", []string{testFullPubkeyA})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
agg := got[testFullPubkeyA]
|
|
if agg == nil || agg.scopes["be"] == nil || agg.scopes["be"].Packets != 1 {
|
|
t.Fatalf("want the mid-path hop attributed to its sole matching target, got %+v", got)
|
|
}
|
|
if agg.ambiguousHops != 0 {
|
|
t.Errorf("ambiguousHops = %d, want 0 — one target matches this hop", agg.ambiguousHops)
|
|
}
|
|
}
|
|
|
|
// TestScopeAuditForwardingIgnoresDirectRoutes pins the route-type filter on the
|
|
// audit's own query. With the last-hop restriction gone it is the only guard
|
|
// against crediting a DIRECT route's remaining path plan as forwarding — and a
|
|
// DIRECT packet's hops are frequently the declared targets this audit judges.
|
|
|
|
// TestScopeAuditForwardingIgnoresDirectRoutes pins the route-type filter on the
|
|
// audit's own query. With the last-hop restriction gone it is the only guard
|
|
// against crediting a DIRECT route's remaining path plan as forwarding — and a
|
|
// DIRECT packet's hops are frequently the declared targets this audit judges.
|
|
func TestScopeAuditForwardingIgnoresDirectRoutes(t *testing.T) {
|
|
s := newScopeTestStore(t)
|
|
recent := time.Now().UTC().Add(-time.Minute).Format(time.RFC3339)
|
|
seedTransmissionPathAt(t, s, []string{"AAAA", testFullPubkeyA[:4], "BBBB"}, scopeMatched("#be"), RouteDirect, recent)
|
|
seedTransmissionPathAt(t, s, []string{"AAAA", "BBBB", testFullPubkeyA[:4]}, scopeMatched("#be"), RouteTransportDirect, recent)
|
|
|
|
got, err := s.ScopeAuditForwarding("2026-01-01T00:00:00Z", []string{testFullPubkeyA})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if agg := got[testFullPubkeyA]; agg != nil && (len(agg.scopes) != 0 || agg.unscopedPackets != 0 || agg.ambiguousHops != 0) {
|
|
t.Errorf("agg = %+v, want no attribution from DIRECT routes", agg)
|
|
}
|
|
}
|
|
|
|
// TestScopeAuditForwardingCountsOneTransmissionOncePerTarget pins that the
|
|
// existing "<target>|<txID>" de-duplication also absorbs the same target
|
|
// matching several hops of ONE path — which could not happen while only
|
|
// path[last] was read, and now can (a routing loop, or two hops colliding on the
|
|
// same truncated prefix). Without it a looping packet would inflate a target's
|
|
// packet count and quietly make a quiet region look busy.
|
|
|
|
// TestScopeAuditForwardingCountsOneTransmissionOncePerTarget pins that the
|
|
// existing "<target>|<txID>" de-duplication also absorbs the same target
|
|
// matching several hops of ONE path — which could not happen while only
|
|
// path[last] was read, and now can (a routing loop, or two hops colliding on the
|
|
// same truncated prefix). Without it a looping packet would inflate a target's
|
|
// packet count and quietly make a quiet region look busy.
|
|
func TestScopeAuditForwardingCountsOneTransmissionOncePerTarget(t *testing.T) {
|
|
s := newScopeTestStore(t)
|
|
recent := time.Now().UTC().Add(-time.Minute).Format(time.RFC3339)
|
|
seedTransmissionPathAt(t, s, []string{testFullPubkeyA[:4], "AAAA", testFullPubkeyA[:4]}, scopeMatched("#be"), RouteFlood, recent)
|
|
|
|
got, err := s.ScopeAuditForwarding("2026-01-01T00:00:00Z", []string{testFullPubkeyA})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
agg := got[testFullPubkeyA]
|
|
if agg == nil || agg.scopes["be"] == nil {
|
|
t.Fatalf("want #be attributed, got %+v", got)
|
|
}
|
|
if agg.scopes["be"].Packets != 1 {
|
|
t.Errorf("Packets = %d, want 1 — one transmission, matched on two of its hops", agg.scopes["be"].Packets)
|
|
}
|
|
}
|
|
|
|
// TestScopeAuditForwardingAttributesLongerHopByItsOwnLength pins the
|
|
// length-indexed half of scopeAuditPrefixIndex, which every other test in this
|
|
// file leaves untested: they all seed 4-char hops, so a lookup that ignored hop
|
|
// length entirely would still pass them.
|
|
//
|
|
// pkOther shares the first 4 hex chars with testFullPubkeyA and diverges after
|
|
// that, so an 8-char hop has exactly one candidate while a 4-char hop would
|
|
// have two. Attribution must therefore key on the hop's OWN length: at 8 chars
|
|
// this is an unambiguous attribution, not an ambiguousHops row.
|
|
|
|
// TestScopeAuditForwardingAttributesLongerHopByItsOwnLength pins the
|
|
// length-indexed half of scopeAuditPrefixIndex, which every other test in this
|
|
// file leaves untested: they all seed 4-char hops, so a lookup that ignored hop
|
|
// length entirely would still pass them.
|
|
//
|
|
// pkOther shares the first 4 hex chars with testFullPubkeyA and diverges after
|
|
// that, so an 8-char hop has exactly one candidate while a 4-char hop would
|
|
// have two. Attribution must therefore key on the hop's OWN length: at 8 chars
|
|
// this is an unambiguous attribution, not an ambiguousHops row.
|
|
func TestScopeAuditForwardingAttributesLongerHopByItsOwnLength(t *testing.T) {
|
|
s := newScopeTestStore(t)
|
|
pkOther := testFullPubkeyA[:4] + strings.Repeat("33", 30)
|
|
hop := testFullPubkeyA[:8]
|
|
recent := time.Now().UTC().Add(-time.Minute).Format(time.RFC3339)
|
|
seedTransmissionPathAt(t, s, []string{hop, "AAAA"}, scopeMatched("#be"), RouteFlood, recent)
|
|
|
|
got, err := s.ScopeAuditForwarding("2026-01-01T00:00:00Z", []string{testFullPubkeyA, pkOther})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
agg := got[testFullPubkeyA]
|
|
if agg == nil || agg.scopes["be"] == nil || agg.scopes["be"].Packets != 1 {
|
|
t.Fatalf("want the 8-char hop attributed to its sole matching target, got %+v", got)
|
|
}
|
|
if agg.ambiguousHops != 0 {
|
|
t.Errorf("ambiguousHops = %d, want 0 — the two targets diverge before hop length 8", agg.ambiguousHops)
|
|
}
|
|
if other := got[pkOther]; other != nil && (len(other.scopes) != 0 || other.ambiguousHops != 0) {
|
|
t.Errorf("pkOther = %+v, want no attribution and no ambiguity — the hop is not its prefix", other)
|
|
}
|
|
}
|
|
|
|
// TestScopeAuditForwardingCountsUnmatchedPackets: a transport-scoped packet
|
|
// whose code1 matched no configured region key is stored with scope_name = ""
|
|
// (scopeNameForDB's "transport-scoped but unnameable" state). It is not a
|
|
// named scope, so it must not enter agg.scopes, and it is not an unscoped
|
|
// plain flood either, so it must not enter unscopedPackets. It is its own
|
|
// fact: this instance saw the target forward traffic it holds no key for.
|
|
//
|
|
// Without this counter the audit reports the declared region as "not
|
|
// observed", which reads as a finding about the repeater when it is really a
|
|
// gap in this instance's own hashRegions.
|
|
|
|
// TestScopeAuditTTLForSevenDayWindow pins the per-window TTL. The 7d window
|
|
// costs a different order of magnitude than the others (16.7s against 4.0s and
|
|
// 0.15s, measured on the live-shaped staging database on 2026-09-07), so it is
|
|
// deliberately not on the 30s the other two share. A future edit that collapses
|
|
// this back to one constant should have to delete a test that says why.
|
|
func TestScopeAuditTTLForSevenDayWindow(t *testing.T) {
|
|
if got := scopeAuditTTLFor("7d"); got != 5*time.Minute {
|
|
t.Errorf("scopeAuditTTLFor(7d) = %s, want 5m", got)
|
|
}
|
|
for _, w := range []string{"1h", "24h", ""} {
|
|
if got := scopeAuditTTLFor(w); got != 30*time.Second {
|
|
t.Errorf("scopeAuditTTLFor(%q) = %s, want 30s", w, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestHandleScopeAuditServesSecondRequestFromCache pins the cache path itself,
|
|
// which the singleflight rewrite moved out of the handler and into
|
|
// scopeAuditCached/scopeAuditStore. A declared row inserted between two
|
|
// requests inside the TTL must NOT appear in the second response: if it does,
|
|
// the response was recomputed and the cache is not being consulted.
|
|
|
|
// TestHandleScopeAuditServesSecondRequestFromCache pins the cache path itself,
|
|
// which the singleflight rewrite moved out of the handler and into
|
|
// scopeAuditCached/scopeAuditStore. A declared row inserted between two
|
|
// requests inside the TTL must NOT appear in the second response: if it does,
|
|
// the response was recomputed and the cache is not being consulted.
|
|
func TestHandleScopeAuditServesSecondRequestFromCache(t *testing.T) {
|
|
srv, router := setupScopeAuditServer(t)
|
|
now := time.Now().UTC().Format(time.RFC3339)
|
|
insertDeclared(t, srv, testFullPubkeyA, now, "be", 0)
|
|
|
|
first := getScopeAudit(t, router, "")
|
|
if len(first.Repeaters) != 1 {
|
|
t.Fatalf("first call repeaters = %d, want 1", len(first.Repeaters))
|
|
}
|
|
|
|
insertDeclared(t, srv, testFullPubkeyB, now, "be", 0)
|
|
second := getScopeAudit(t, router, "")
|
|
if len(second.Repeaters) != 1 {
|
|
t.Errorf("second call repeaters = %d, want 1 — the row added after the first call proves the cache was bypassed", len(second.Repeaters))
|
|
}
|
|
}
|
|
|
|
// TestHandleScopeAuditNormalisesHashPrefix pins trap 1: transmissions.scope_name
|
|
// keeps the '#' (hashRegions config), regions_csv arrives from the firmware
|
|
// with it already stripped. Declared "be-van" and observed "#be-van" must be
|
|
// recognised as the same scope, not reported as both missing and undeclared.
|
|
|
|
// TestScopeAuditForwardingCountsUnmatchedPackets: a transport-scoped packet
|
|
// whose code1 matched no configured region key is stored with scope_name = ""
|
|
// (scopeNameForDB's "transport-scoped but unnameable" state). It is not a
|
|
// named scope, so it must not enter agg.scopes, and it is not an unscoped
|
|
// plain flood either, so it must not enter unscopedPackets. It is its own
|
|
// fact: this instance saw the target forward traffic it holds no key for.
|
|
//
|
|
// Without this counter the audit reports the declared region as "not
|
|
// observed", which reads as a finding about the repeater when it is really a
|
|
// gap in this instance's own hashRegions.
|
|
func TestScopeAuditForwardingCountsUnmatchedPackets(t *testing.T) {
|
|
s := newScopeTestStore(t)
|
|
hop := testFullPubkeyA[:4]
|
|
recent := time.Now().UTC().Add(-time.Minute).Format(time.RFC3339)
|
|
seedTransmissionRouteAt(t, s, hop, scopeUnmatched(), RouteFlood, recent)
|
|
|
|
got, err := s.ScopeAuditForwarding("2026-01-01T00:00:00Z", []string{testFullPubkeyA})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
agg := got[testFullPubkeyA]
|
|
if agg == nil {
|
|
t.Fatalf("want an agg for the target, got none (result = %+v)", got)
|
|
}
|
|
if agg.unmatchedPackets != 1 {
|
|
t.Errorf("unmatchedPackets = %d, want 1", agg.unmatchedPackets)
|
|
}
|
|
if len(agg.scopes) != 0 {
|
|
t.Errorf("scopes = %+v, want empty — an unmatched packet names no region", agg.scopes)
|
|
}
|
|
if agg.unscopedPackets != 0 {
|
|
t.Errorf("unscopedPackets = %d, want 0 — unmatched is not the same as unscoped", agg.unscopedPackets)
|
|
}
|
|
}
|
|
|
|
// TestScopeAuditForwardingCountsUnmatchedOnMidPathHop is the post-M0 case that
|
|
// carries almost all of this counter's real volume: before M0 only a last hop
|
|
// was attributed, so a repeater deep in a flood path contributed nothing at
|
|
// all. Now every hop counts, and the same de-duplication that protects the
|
|
// named-scope tally must protect this one — a target appearing twice in one
|
|
// path is still one packet, not two.
|
|
func TestScopeAuditForwardingCountsUnmatchedOnMidPathHop(t *testing.T) {
|
|
s := newScopeTestStore(t)
|
|
hop := testFullPubkeyA[:4]
|
|
recent := time.Now().UTC().Add(-time.Minute).Format(time.RFC3339)
|
|
seedTransmissionPathAt(t, s, []string{"AAAA", hop, "BBBB", hop}, scopeUnmatched(), RouteFlood, recent)
|
|
|
|
got, err := s.ScopeAuditForwarding("2026-01-01T00:00:00Z", []string{testFullPubkeyA})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
agg := got[testFullPubkeyA]
|
|
if agg == nil {
|
|
t.Fatalf("want an agg for the mid-path target, got none (result = %+v)", got)
|
|
}
|
|
if agg.unmatchedPackets != 1 {
|
|
t.Errorf("unmatchedPackets = %d, want 1 — one transmission, matched on two of its hops", agg.unmatchedPackets)
|
|
}
|
|
}
|
|
|
|
// TestHandleScopeAuditSurfacesUnmatchedPackets: a repeater declares "behss",
|
|
// and this instance sees it forward transport-scoped traffic it cannot name.
|
|
// The row must still list "behss" as notObserved — an unmatched packet names
|
|
// no region, so it cannot satisfy the declaration — but it must also carry
|
|
// observedUnmatchedPackets, so a client can say the finding might be a missing
|
|
// region key rather than a silent repeater.
|
|
func TestHandleScopeAuditSurfacesUnmatchedPackets(t *testing.T) {
|
|
srv, router := setupScopeAuditServer(t)
|
|
pk := testFullPubkeyA
|
|
insertDeclared(t, srv, pk, time.Now().UTC().Format(time.RFC3339), "behss", 0)
|
|
recent := time.Now().UTC().Add(-time.Minute).Format(time.RFC3339)
|
|
seedTransmissionRouteAt(t, srv.store, pk[:4], scopeUnmatched(), RouteFlood, recent)
|
|
|
|
got := getScopeAudit(t, router, "")
|
|
if len(got.Repeaters) != 1 {
|
|
t.Fatalf("repeaters = %+v, want 1", got.Repeaters)
|
|
}
|
|
row := got.Repeaters[0]
|
|
if row.ObservedUnmatchedPackets != 1 {
|
|
t.Errorf("observedUnmatchedPackets = %d, want 1", row.ObservedUnmatchedPackets)
|
|
}
|
|
if len(row.NotObserved) != 1 || row.NotObserved[0] != "behss" {
|
|
t.Errorf("notObserved = %v, want [\"behss\"] — an unmatched packet names no region and cannot satisfy a declaration", row.NotObserved)
|
|
}
|
|
if row.WildcardContradiction {
|
|
t.Error("wildcardContradiction = true, want false — unmatched traffic is scoped, so it says nothing about '*'")
|
|
}
|
|
}
|
|
|
|
// TestHandleScopeAuditVerifiesDeclaredRegion is the case this milestone exists
|
|
// for, built from the real packet that started the investigation. A repeater
|
|
// declares "fm-112"; this instance holds no key for it, so both packets it
|
|
// forwarded are stored unmatched. Verification derives the key from the
|
|
// repeater's own declaration, finds two corroborating packets, and the region
|
|
// must leave notObserved with its evidence count reported.
|
|
// transportFloodPacketFor builds a TRANSPORT_FLOOD packet whose code1 is the
|
|
// code `region` derives over this payload, so the verifier will match it. The
|
|
// shape mirrors realTransportFloodPacket: header 0x14 (route 0, payload type
|
|
// 5), the two transport codes, path byte 0x41 (hash size 2, one hop), the hop,
|
|
// then the payload.
|
|
//
|
|
// It exists because corroboration has to come from DIFFERENT payloads. Two
|
|
// copies of one packet derive the same code by construction, so they are one
|
|
// observation counted twice, not the two independent matches the threshold
|
|
// argument rests on.
|
|
func transportFloodPacketFor(region string, payload []byte) string {
|
|
code1 := regionCode(region, 5, payload)
|
|
return "14" + code1 + "0000" + "41" + "E3D3" + strings.ToUpper(hex.EncodeToString(payload))
|
|
}
|
|
|
|
func TestHandleScopeAuditVerifiesDeclaredRegion(t *testing.T) {
|
|
srv, router := setupScopeAuditServer(t)
|
|
pk := testFullPubkeyA
|
|
insertDeclared(t, srv, pk, time.Now().UTC().Format(time.RFC3339), "fm-112,behss", 0)
|
|
recent := time.Now().UTC().Add(-time.Minute).Format(time.RFC3339)
|
|
// Two DIFFERENT payloads, each deriving to #fm-112 on its own. The first is
|
|
// the real packet captured from a live instance; the second is built for
|
|
// this test. Seeding the same packet twice would prove only that the
|
|
// verifier counts rows.
|
|
seedUnmatchedRawAt(t, srv.store, pk[:4], realTransportFloodPacket, RouteTransportFlood, recent)
|
|
seedUnmatchedRawAt(t, srv.store, pk[:4], transportFloodPacketFor("fm-112", []byte{0x51, 0x52, 0x53, 0x54, 0x55}), RouteTransportFlood, recent)
|
|
|
|
got := getScopeAudit(t, router, "")
|
|
if len(got.Repeaters) != 1 {
|
|
t.Fatalf("repeaters = %+v, want 1", got.Repeaters)
|
|
}
|
|
row := got.Repeaters[0]
|
|
if row.RegionEvidence["fm-112"] != 2 {
|
|
t.Errorf("regionEvidence = %v, want fm-112:2", row.RegionEvidence)
|
|
}
|
|
for _, n := range row.NotObserved {
|
|
if n == "fm-112" {
|
|
t.Errorf("notObserved = %v, must not contain fm-112 - two corroborating packets prove it is forwarded", row.NotObserved)
|
|
}
|
|
}
|
|
if len(row.NotObserved) != 1 || row.NotObserved[0] != "behss" {
|
|
t.Errorf("notObserved = %v, want [behss] - that region has no corroborating traffic here", row.NotObserved)
|
|
}
|
|
}
|
|
|
|
// TestHandleScopeAuditReportsTheVerificationSampleSize pins the field a client
|
|
// needs to subtract RegionEvidence from ObservedUnmatchedPackets honestly.
|
|
// RegionEvidence can only ever count packets inside the sample, so a client
|
|
// that subtracts it from an uncapped total overstates what is unexplained. The
|
|
// two are equal here, which is the case that says "this subtraction is exact".
|
|
|
|
// TestHandleScopeAuditDoesNotVerifyOnOnePacket: a single match is 1-in-65536
|
|
// and must leave the region in notObserved, with its count still reported so a
|
|
// client can say "one hit, not enough".
|
|
func TestHandleScopeAuditDoesNotVerifyOnOnePacket(t *testing.T) {
|
|
srv, router := setupScopeAuditServer(t)
|
|
pk := testFullPubkeyA
|
|
insertDeclared(t, srv, pk, time.Now().UTC().Format(time.RFC3339), "fm-112", 0)
|
|
recent := time.Now().UTC().Add(-time.Minute).Format(time.RFC3339)
|
|
seedUnmatchedRawAt(t, srv.store, pk[:4], realTransportFloodPacket, RouteTransportFlood, recent)
|
|
|
|
got := getScopeAudit(t, router, "")
|
|
row := got.Repeaters[0]
|
|
if row.RegionEvidence["fm-112"] != 1 {
|
|
t.Errorf("regionEvidence = %v, want fm-112:1", row.RegionEvidence)
|
|
}
|
|
if len(row.NotObserved) != 1 || row.NotObserved[0] != "fm-112" {
|
|
t.Errorf("notObserved = %v, want [fm-112] - one corroborating packet is not evidence", row.NotObserved)
|
|
}
|
|
}
|
|
|
|
// TestHandleScopeAuditLeavesCleanRowsAlone: a repeater whose declared regions
|
|
// are all observed by name, with no unmatched traffic at all, must be untouched
|
|
// by verification - no evidence, no change to notObserved, and an empty (not
|
|
// null) regionEvidence so a client can iterate it without a guard.
|
|
|
|
// TestHandleScopeAuditReportsTheVerificationSampleSize pins the field a client
|
|
// needs to subtract RegionEvidence from ObservedUnmatchedPackets honestly.
|
|
// RegionEvidence can only ever count packets inside the sample, so a client
|
|
// that subtracts it from an uncapped total overstates what is unexplained. The
|
|
// two are equal here, which is the case that says "this subtraction is exact".
|
|
func TestHandleScopeAuditReportsTheVerificationSampleSize(t *testing.T) {
|
|
srv, router := setupScopeAuditServer(t)
|
|
pk := testFullPubkeyA
|
|
insertDeclared(t, srv, pk, time.Now().UTC().Format(time.RFC3339), "fm-112", 0)
|
|
recent := time.Now().UTC().Add(-time.Minute).Format(time.RFC3339)
|
|
seedUnmatchedRawAt(t, srv.store, pk[:4], realTransportFloodPacket, RouteTransportFlood, recent)
|
|
seedUnmatchedRawAt(t, srv.store, pk[:4], realTransportFloodPacket, RouteTransportFlood, recent)
|
|
|
|
row := getScopeAudit(t, router, "").Repeaters[0]
|
|
if row.ObservedUnmatchedPackets != 2 {
|
|
t.Fatalf("observedUnmatchedPackets = %d, want 2", row.ObservedUnmatchedPackets)
|
|
}
|
|
if row.ObservedUnmatchedSampled != row.ObservedUnmatchedPackets {
|
|
t.Errorf("observedUnmatchedSampled = %d, want %d — nothing was capped away at this size, and a client can only tell from this field",
|
|
row.ObservedUnmatchedSampled, row.ObservedUnmatchedPackets)
|
|
}
|
|
}
|
|
|
|
// TestHandleScopeAuditDoesNotVerifyOnOnePacket: a single match is 1-in-65536
|
|
// and must leave the region in notObserved, with its count still reported so a
|
|
// client can say "one hit, not enough".
|
|
|
|
// seedUnmatchedRawAt seeds one unmatched transmission carrying a real raw_hex,
|
|
// attributed to forwarder. Distinct from seedTransmissionRouteAt, which seeds
|
|
// raw_hex 'AA' - fine for tests that never parse it, useless here.
|
|
func seedUnmatchedRawAt(t *testing.T, s *PacketStore, forwarder, rawHex string, routeType int, firstSeen string) {
|
|
t.Helper()
|
|
scopeSeedCounter++
|
|
hash := fmt.Sprintf("scoperaw%d", scopeSeedCounter)
|
|
res, err := s.db.conn.Exec(
|
|
`INSERT INTO transmissions (raw_hex, hash, first_seen, route_type, payload_type, code1, code2, scope_name)
|
|
VALUES (?, ?, ?, ?, 5, '9209', '0000', '')`,
|
|
rawHex, hash, firstSeen, routeType)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
txID, err := res.LastInsertId()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := s.db.conn.Exec(
|
|
`INSERT INTO observations (transmission_id, path_json, timestamp) VALUES (?, ?, 0)`,
|
|
txID, `["`+strings.ToUpper(forwarder)+`"]`); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
// TestHandleNodeScopesDifferentWindowIsSeparateCacheEntry confirms the cache
|
|
// key includes window: a request for a different window must recompute
|
|
// rather than reuse another window's cached entry.
|
|
|
|
// TestHandleScopeAuditNamedRegionNeedsNoVerification pins the interaction
|
|
// between naming a packet at ingest and verifying it at read time, which are
|
|
// built separately and had no test together.
|
|
//
|
|
// Deriving region keys from what nodes declare means a packet that used to be
|
|
// stored unnameable now arrives with a name. The audit must then report that
|
|
// region as observed by the ordinary route: present in agg.scopes, absent from
|
|
// notObserved, and NOT claimed by regionEvidence, which exists to explain
|
|
// regions that could only be established by verification.
|
|
//
|
|
// Getting this wrong is not loud. A region would still be green, so the page
|
|
// looks right while the reason underneath it is wrong, and a reader chasing
|
|
// "how do we know this" is told the wrong story.
|
|
func TestHandleScopeAuditNamedRegionNeedsNoVerification(t *testing.T) {
|
|
srv, router := setupScopeAuditServer(t)
|
|
pk := testFullPubkeyA
|
|
insertDeclared(t, srv, pk, time.Now().UTC().Format(time.RFC3339), "fm-112", 0)
|
|
recent := time.Now().UTC().Add(-time.Minute).Format(time.RFC3339)
|
|
// scopeMatched is the state the ingestor writes once it holds a key for
|
|
// the region, whether that key was configured by hand or derived.
|
|
seedTransmissionRouteAt(t, srv.store, pk[:4], scopeMatched("#fm-112"), RouteTransportFlood, recent)
|
|
|
|
row := getScopeAudit(t, router, "").Repeaters[0]
|
|
for _, rgn := range row.NotObserved {
|
|
if rgn == "fm-112" {
|
|
t.Errorf("notObserved = %v, must not contain fm-112 — it was observed under its own name", row.NotObserved)
|
|
}
|
|
}
|
|
if n, ok := row.RegionEvidence["fm-112"]; ok {
|
|
t.Errorf("regionEvidence[fm-112] = %d, want absent — verification explains regions that could not be named, and this one was", n)
|
|
}
|
|
if row.ObservedUnmatchedPackets != 0 {
|
|
t.Errorf("observedUnmatchedPackets = %d, want 0 — a named packet is not unnameable traffic", row.ObservedUnmatchedPackets)
|
|
}
|
|
}
|