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("sqlite", ":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 "|" 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 "|" 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) } }