Files
meshcore-analyzer/cmd/server/rx_dashboard_test.go
T
efitenandClaude Opus 5.5 b2cda21a27 feat(server): companion linking routes, device tokens and bearer auth (#2175)
Part 2 of 3 for #2173 (companion linking): the server routes, bearer
auth and the link handshake. Builds on #2174 (merged); `upstream/master`
is merged in, so the diff shows only this part. Everything here is
behind `userManagement.enabled` and answers 404 with it off.

## Changes in this PR

**Device tokens and bearer auth**
- `POST /api/auth/device-token`: email + password returns a bearer
token, stored as a `kind = 'device'` session with a label. Same failure
answers and the same rate limit as login.
- Server-enforced scope for a bearer token: `/api/auth/me`,
`/api/auth/logout`, `/api/account/companions*`, `/api/account/settings`.
Everything else answers 403.
- A bearer header only counts when there is no session cookie, so an
auth proxy that adds its own `Authorization` header does not hide a
logged-in browser. Cookies never accept a device token.
- The sessions list on the account API carries `kind` and `label`;
logout with a bearer token revokes that device.

**Linking a companion**
- `POST /api/account/companions/challenge` `{pubkey}` returns
`{challenge, expiresAt, host}`: 32 random bytes, single use, 5 minutes,
bound to the caller and the pubkey.
- `POST /api/account/companions` `{pubkey, challenge, signature, name}`:
the signature is the companion's Ed25519 signature over
`corescope-link:<host>:<challenge>`
(`internal/sigvalidate.VerifyMessage`). The challenge is consumed in
every case.
- A companion linked to another account moves to the caller (the newest
proof wins). Both users get a `companion.transfer` audit row and the
previous owner a mail. That mail is a security notice: it is sent
whatever the node notification settings are.
- `GET /api/account/companions` (with `lastSeenAt` from
`client_receptions`) and `DELETE /api/account/companions/{pubkey}`.
- Linking does not touch the synced `meshcore-my-nodes`: a companion is
not a node to monitor.
- Admin user detail lists linked companions; the account data export
includes companions and device sessions.

**Coverage and config**
- `GET /api/rx-coverage?mine=1`: only coverage of the caller's linked
companions (401 when logged out, 403 for a device token). With `gaps=1`
(#2172) the reply carries no `gaps` member under `mine=1`, because the
track query takes one companion and cannot narrow to a set yet
(`TestRxCoverageMineOmitsGaps`).
- `clientRxCoverage.requireLinkedCompanion` (default off) is read and
reported in `/api/config/client`, which also announces
`userManagement.companionLinking`, so CoreDrive RX shows its login only
where linking exists. The ingestor side is part 3.
- CORS: for origins in `corsAllowedOrigins`, the device-token routes
alone also allow POST/PUT/DELETE and the `Authorization` and
`Content-Type` headers. No credentialed CORS. Documented in
`config.example.json`.
- OpenAPI and `docs/api-spec.md` cover the new routes.

## Verification

- 26 new tests in `cmd/server` and `internal/sigvalidate`, among them:
`TestCompanionLinkFlow`, `TestCompanionLinkRejections` (signature by
another key, malformed signature, challenge bound to another pubkey,
unknown challenge; reuse and expiry are covered by the store tests in
#2174), `TestCompanionTransfer`,
`TestCompanionTransferMailIgnoresNotificationSettings`,
`TestBearerScopedRoutes`, `TestBearerIgnoredWithCookie`,
`TestDeviceTokenSharesLoginRateLimit`, `TestCORSBearerRoutes`,
`TestCompanionRoutesAbsentWhenOff`, `TestRxCoverageMine`,
`TestRxCoverageMineOmitsGaps` (fails without the `mine == nil` guard),
`TestVerifyMessage`.
- `cmd/server`, `internal/sigvalidate` and `internal/users`: `go vet`
and `go test ./...` green, `gofmt` clean.
- The linking flow runs on analyzer.on8ar.eu since 9 October 2026 with
CoreDrive RX v1.19.0 (a real companion linked through the signed
challenge).

## Not in this PR

- The ingestor's linked-only filter, the account page (Devices,
Companions), the "My coverage" toggle and the user docs: part 3.
- Per-user broker credentials and per-user API keys (non-goals in the
spec).

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 18:20:49 +02:00

276 lines
11 KiB
Go

package main
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"time"
)
// TestRequireClientRxCoverageNilSafe verifies the #4 fix: coverage routes are
// registered unconditionally, so a nil server cfg (or nil *Config receiver)
// must 404 rather than panic.
func TestRequireClientRxCoverageNilSafe(t *testing.T) {
var nilCfg *Config
if nilCfg.ClientRxCoverageEnabled() {
t.Fatal("nil *Config must report disabled")
}
req := func(srv *Server) int {
rr := httptest.NewRecorder()
srv.handleRxCoverage(rr, httptest.NewRequest("GET", "/api/rx-coverage?bbox=50,3,52,4", nil))
return rr.Code
}
if code := req(&Server{}); code != http.StatusNotFound { // cfg nil → would panic without the guard
t.Fatalf("nil cfg: want 404, got %d", code)
}
if code := req(&Server{cfg: &Config{}}); code != http.StatusNotFound { // feature disabled
t.Fatalf("disabled: want 404, got %d", code)
}
}
func insRx(t *testing.T, db *DB, rx, hk, at string, lat, lon float64) {
mustExecDB(t, db, fmt.Sprintf(
`INSERT INTO client_receptions (rx_pubkey,heard_key,heard_keylen,snr,lat,lon,rx_at,ingested_at,src) VALUES ('%s','%s',3,-6,%f,%f,'%s','x','rxlog')`,
rx, hk, lat, lon, at))
}
func TestQueryCoverageFiltered(t *testing.T) {
db := seedCoverageDB(t)
now := time.Now().UTC()
recent := now.Format(time.RFC3339)
old := now.AddDate(0, 0, -40).Format(time.RFC3339)
insRx(t, db, "compa", "aabbcc", recent, 51.05, 3.72)
insRx(t, db, "compb", "ffeedd", recent, 51.06, 3.73)
insRx(t, db, "compa", "aabbcc", old, 51.05, 3.72)
srv := &Server{db: db}
bb := bbox{MinLat: 50, MinLon: 3, MaxLat: 52, MaxLon: 4}
if rows, _ := srv.queryCoverageFiltered("", "", nil, 7, bb); len(rows) != 2 {
t.Fatalf("global 7d: want 2, got %d", len(rows))
}
if rows, _ := srv.queryCoverageFiltered("", "compa", nil, 7, bb); len(rows) != 1 {
t.Fatalf("observer compa 7d: want 1, got %d", len(rows))
}
if rows, _ := srv.queryCoverageFiltered("", "", nil, 0, bb); len(rows) != 3 {
t.Fatalf("global all-time: want 3, got %d", len(rows))
}
}
func TestRxLeaderboard(t *testing.T) {
db := seedCoverageDB(t)
recent := time.Now().UTC().Format(time.RFC3339)
mustExecDB(t, db, `INSERT INTO nodes (public_key, name, role, last_seen, first_seen, advert_count) VALUES ('compa','MyCompanion','companion','t','t',1)`)
// compc is NOT in nodes, but reported its name via client_observers (fallback).
mustExecDB(t, db, `INSERT INTO client_observers (pubkey, name, last_seen) VALUES ('compc','MobOnly','t')`)
for i := 0; i < 3; i++ {
insRx(t, db, "compa", fmt.Sprintf("aabb%02d", i), recent, 51.05, 3.72)
}
insRx(t, db, "compc", "ddee00", recent, 51.05, 3.72)
insRx(t, db, "compc", "ddee01", recent, 51.05, 3.72)
insRx(t, db, "compb", "aabbcc", recent, 51.05, 3.72) // no name anywhere
srv := &Server{db: db}
obs, err := srv.rxLeaderboard(context.Background(), 7, 10)
if err != nil {
t.Fatal(err)
}
byPk := map[string]LeaderObserver{}
for _, o := range obs {
byPk[o.Pubkey] = o
}
if byPk["compa"].Name != "MyCompanion" || byPk["compa"].Receptions != 3 {
t.Fatalf("compa (nodes name): %+v", byPk["compa"])
}
if byPk["compc"].Name != "MobOnly" || byPk["compc"].Receptions != 2 {
t.Fatalf("compc (client_observers fallback): %+v", byPk["compc"])
}
if byPk["compb"].Name != "" {
t.Fatalf("compb should have no name: %+v", byPk["compb"])
}
}
// TestBatchResolveHeardKeys verifies the N+1 fix: many heard_keys resolve in one
// batched call with the same unique/ambiguous/unknown/hidden semantics as the
// single-key path.
func TestBatchResolveHeardKeys(t *testing.T) {
db := setupTestDBv2(t)
mustExecDB(t, db, `INSERT INTO nodes (public_key,name,role) VALUES ('aabbccdd11223344','Alice','repeater')`)
mustExecDB(t, db, `INSERT INTO nodes (public_key,name,role) VALUES ('aabbcc99887766aa','Bob','repeater')`)
mustExecDB(t, db, `INSERT INTO nodes (public_key,name,role) VALUES ('ddee110000000000','🚫Hidden','repeater')`)
srv := &Server{db: db, cfg: &Config{HiddenNamePrefixes: []string{"🚫"}}}
got := srv.batchResolveHeardKeys([]string{"aabbccdd", "aabbcc", "ffff", "ddee11", "aabbccdd"})
cases := map[string][2]string{
"aabbccdd": {"aabbccdd11223344", "Alice"}, // unique
"aabbcc": {"aabbcc", ""}, // ambiguous (Alice + Bob)
"ffff": {"ffff", ""}, // unknown
"ddee11": {"ddee11", ""}, // unique but hidden-prefix → not surfaced
}
for k, want := range cases {
if got[k] != want {
t.Errorf("batchResolveHeardKeys[%q] = %v, want %v", k, got[k], want)
}
}
}
// TestRxLeaderboardHidesBlacklistedAndHidden verifies #1727 r2 must-fix #2: the
// leaderboard must drop observer-blacklisted contributors and blank the name of
// node-blacklisted or hidden-prefix identities (pre-PR / post-blacklist rows).
func TestRxLeaderboardHidesBlacklistedAndHidden(t *testing.T) {
db := seedCoverageDB(t)
recent := time.Now().UTC().Format(time.RFC3339)
mustExecDB(t, db, `INSERT INTO nodes (public_key, name, role, last_seen, first_seen, advert_count) VALUES ('aa01','GoodGuy','companion','t','t',1)`)
mustExecDB(t, db, `INSERT INTO nodes (public_key, name, role, last_seen, first_seen, advert_count) VALUES ('cc03','BadNode','companion','t','t',1)`)
mustExecDB(t, db, `INSERT INTO nodes (public_key, name, role, last_seen, first_seen, advert_count) VALUES ('dd04','🚫Hidden','companion','t','t',1)`)
insRx(t, db, "aa01", "aabb01", recent, 51.05, 3.72) // normal → kept with name
insRx(t, db, "bb02", "aabb02", recent, 51.05, 3.72) // observer-blacklisted → dropped
insRx(t, db, "cc03", "aabb03", recent, 51.05, 3.72) // node-blacklisted → name blanked
insRx(t, db, "dd04", "aabb04", recent, 51.05, 3.72) // hidden prefix → name blanked
srv := &Server{db: db, cfg: &Config{
ObserverBlacklist: []string{"bb02"},
NodeBlacklist: []string{"cc03"},
HiddenNamePrefixes: []string{"🚫"},
}}
obs, err := srv.rxLeaderboard(context.Background(), 7, 100)
if err != nil {
t.Fatal(err)
}
byPk := map[string]LeaderObserver{}
for _, o := range obs {
byPk[o.Pubkey] = o
}
if _, ok := byPk["bb02"]; ok {
t.Fatalf("observer-blacklisted contributor must be dropped, got %+v", byPk["bb02"])
}
if byPk["aa01"].Name != "GoodGuy" {
t.Fatalf("normal contributor name should be kept: %+v", byPk["aa01"])
}
if _, ok := byPk["cc03"]; !ok || byPk["cc03"].Name != "" {
t.Fatalf("node-blacklisted contributor should remain with a blanked name: %+v", byPk["cc03"])
}
if _, ok := byPk["dd04"]; !ok || byPk["dd04"].Name != "" {
t.Fatalf("hidden-prefix contributor should remain with a blanked name: %+v", byPk["dd04"])
}
}
// TestRxLeaderboardLimitSurvivesBlacklistDrop verifies #1727 r2 must-fix #2: the
// SQL LIMIT runs before the Go-side observer-blacklist drop, so the leaderboard
// must over-fetch and still return `limit` non-blacklisted rows even when the
// top contributors are blacklisted (not limit-minus-dropped).
func TestRxLeaderboardLimitSurvivesBlacklistDrop(t *testing.T) {
db := seedCoverageDB(t)
recent := time.Now().UTC().Format(time.RFC3339)
// Reception counts strictly descending so ORDER BY COUNT(*) DESC is deterministic:
// the two blacklisted observers are the top two, then five good ones.
counts := []struct {
pk string
n int
}{
{"bk1", 10}, {"bk2", 9}, // observer-blacklisted (top of the board)
{"g1", 8}, {"g2", 7}, {"g3", 6}, {"g4", 5}, {"g5", 4},
}
for _, c := range counts {
for i := 0; i < c.n; i++ {
insRx(t, db, c.pk, fmt.Sprintf("%s%04d", c.pk, i), recent, 51.05, 3.72)
}
}
srv := &Server{db: db, cfg: &Config{ObserverBlacklist: []string{"bk1", "bk2"}}}
obs, err := srv.rxLeaderboard(context.Background(), 7, 3)
if err != nil {
t.Fatal(err)
}
if len(obs) != 3 {
t.Fatalf("expected exactly 3 rows after dropping 2 blacklisted from the top, got %d: %+v", len(obs), obs)
}
want := []string{"g1", "g2", "g3"}
for i, o := range obs {
if o.Pubkey == "bk1" || o.Pubkey == "bk2" {
t.Fatalf("blacklisted observer %q leaked into the leaderboard", o.Pubkey)
}
if o.Pubkey != want[i] {
t.Fatalf("row %d = %q, want %q (top-3 non-blacklisted by count)", i, o.Pubkey, want[i])
}
}
}
// TestRxLeaderboardFrontierScore verifies the leaderboard ranks by frontier-
// weighted cell coverage, not raw reception count: a roaming observer with FEWER
// receptions but MORE distinct cells outranks a stationary spammer, a cell only
// one observer covers weighs 1.0, and a cell shared by N observers weighs 1/N.
func TestRxLeaderboardFrontierScore(t *testing.T) {
db := seedCoverageDB(t)
recent := time.Now().UTC().Format(time.RFC3339)
// "park": 5 receptions all at ONE spot → 1 cell (stationary spammer).
for i := 0; i < 5; i++ {
insRx(t, db, "park", fmt.Sprintf("pk%04d", i), recent, 51.05, 3.72)
}
// "roam": 3 receptions at 3 far-apart spots → 3 distinct cells. The first
// coincides with park's cell, so that cell is shared by 2 observers.
insRx(t, db, "roam", "rm0001", recent, 51.05, 3.72) // shared with park
insRx(t, db, "roam", "rm0002", recent, 51.06, 3.72) // unique to roam
insRx(t, db, "roam", "rm0003", recent, 51.07, 3.72) // unique to roam
srv := &Server{db: db}
obs, err := srv.rxLeaderboard(context.Background(), 7, 10)
if err != nil {
t.Fatal(err)
}
if len(obs) != 2 {
t.Fatalf("want 2 observers, got %d: %+v", len(obs), obs)
}
// Roamer outranks the parked spammer despite fewer receptions.
if obs[0].Pubkey != "roam" || obs[1].Pubkey != "park" {
t.Fatalf("ranking: want [roam park], got [%s %s]", obs[0].Pubkey, obs[1].Pubkey)
}
byPk := map[string]LeaderObserver{}
for _, o := range obs {
byPk[o.Pubkey] = o
}
// park: 1 cell shared with roam → score 0.5; 5 receptions retained.
if byPk["park"].Cells != 1 || byPk["park"].Receptions != 5 {
t.Fatalf("park: %+v", byPk["park"])
}
if d := byPk["park"].Score - 0.5; d > 1e-9 || d < -1e-9 {
t.Fatalf("park score: want 0.5, got %v", byPk["park"].Score)
}
// roam: shared cell (0.5) + 2 unique cells (1.0 each) = 2.5; 3 cells.
if byPk["roam"].Cells != 3 {
t.Fatalf("roam cells: want 3, got %d", byPk["roam"].Cells)
}
if d := byPk["roam"].Score - 2.5; d > 1e-9 || d < -1e-9 {
t.Fatalf("roam score: want 2.5, got %v", byPk["roam"].Score)
}
}
// TestRxLeaderboardScoreNotDilutedByBlacklisted verifies the #review-r2 fix: a
// blacklisted observer sharing a cell must NOT dilute a legitimate observer's
// frontier score. Without excluding blacklisted pubkeys from the per-cell count,
// the legit observer's only cell would weigh 1/2 = 0.5 instead of 1.0.
func TestRxLeaderboardScoreNotDilutedByBlacklisted(t *testing.T) {
db := seedCoverageDB(t)
recent := time.Now().UTC().Format(time.RFC3339)
// Legit "good" and blacklisted "bad" both cover the same ~150 m cell.
insRx(t, db, "good", "aabb01", recent, 51.05, 3.72)
insRx(t, db, "bad", "aabb02", recent, 51.05, 3.72)
srv := &Server{db: db, cfg: &Config{ObserverBlacklist: []string{"bad"}}}
obs, err := srv.rxLeaderboard(context.Background(), 7, 100)
if err != nil {
t.Fatal(err)
}
byPk := map[string]LeaderObserver{}
for _, o := range obs {
byPk[o.Pubkey] = o
}
if _, leaked := byPk["bad"]; leaked {
t.Fatalf("blacklisted observer must not appear: %+v", byPk["bad"])
}
if d := byPk["good"].Score - 1.0; d > 1e-9 || d < -1e-9 {
t.Fatalf("blacklisted observer diluted the score: got %v, want 1.0", byPk["good"].Score)
}
}