Files
echolot/server/internal/adminui/adbendpoints_test.go
T
mrambossekandClaude Opus 5 ae63bd7c7f server: relay a test device's adb endpoint, because mDNS does not cross subnets
The beacon this replaces was a separate service wildcard-bound to
0.0.0.0:443 - it silently occupied port 443 on the reserved measurement
addresses, voiding the IPv4 interception proof for as long as it ran, and
it accepted a port report from anyone who could reach it. So this lives
where the repo's own post-mortem said it belongs: POST on the control
plane authenticated by the device credential, GET on the admin UI behind
the existing apiAdmin helper. No new listener, no new port, no wildcard.

Entries expire after 24h (ECHOLOT_ADB_ENDPOINT_RETENTION_H) on both write
and read - a LAN address is a breadcrumb for driving a test device, not
measurement data worth keeping.

Also records the BLE peer-comparison design: the case for it is that BLE
is out-of-band, which is what makes client isolation measurable at all -
silence over IP cannot distinguish an isolating AP from an absent peer,
and a peer confirming out-of-band that it was listening turns that
silence into proof.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 14:31:01 +02:00

149 lines
4.6 KiB
Go

// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package adminui
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"echo-lot.app/server/internal/store"
)
func adbFixture(t *testing.T) *Server {
t.Helper()
s := tokenFixture(t)
s.Store.SetADBEndpointRetention(24 * time.Hour)
now := time.Now().UTC()
for _, e := range []store.ADBEndpoint{
{Device: "dev-a", Host: "10.13.102.128", Port: 45305, DeviceName: "TB330FU",
SourceIP: "10.13.102.128", ReportedAt: now.Add(-10 * time.Minute)},
{Device: "dev-b", Host: "10.13.102.55", Port: 5555,
SourceIP: "10.13.102.55", ReportedAt: now.Add(-time.Minute)},
} {
if err := s.Store.PutADBEndpoint(e); err != nil {
t.Fatal(err)
}
}
return s
}
// A LAN address and a live debug port are exactly what must not be readable by anyone who can
// reach the listener — which is how the beacon receiver this replaces worked.
func TestADBEndpointsReadRequiresAuth(t *testing.T) {
h := adbFixture(t).Handler()
req := httptest.NewRequest("GET", "/admin/adb-endpoints", nil)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized || rec.Header().Get("WWW-Authenticate") == "" {
t.Fatalf("unauthenticated: code=%d, want 401 with a challenge", rec.Code)
}
if rec.Body.Len() > 0 && json.Valid(rec.Body.Bytes()) {
t.Fatalf("a refusal returned a JSON body: %s", rec.Body.String())
}
req = httptest.NewRequest("GET", "/admin/adb-endpoints", nil)
req.SetBasicAuth("admin", "wrong")
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("bad password: code=%d, want 401", rec.Code)
}
}
func TestADBEndpointsReadNewestFirst(t *testing.T) {
h := adbFixture(t).Handler()
req := httptest.NewRequest("GET", "/admin/adb-endpoints", nil)
req.SetBasicAuth("admin", "a-long-test-password")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("code=%d body=%s", rec.Code, rec.Body.String())
}
var rows []adbRow
if err := json.Unmarshal(rec.Body.Bytes(), &rows); err != nil {
t.Fatal(err)
}
if len(rows) != 2 {
t.Fatalf("got %d rows, want 2: %s", len(rows), rec.Body.String())
}
if rows[0].Device != "dev-b" {
t.Fatalf("rows are not newest first: %+v", rows)
}
if rows[0].Host != "10.13.102.55" || rows[0].Port != 5555 || rows[0].SourceIP == "" {
t.Fatalf("row is missing what a developer came for: %+v", rows[0])
}
// age_s is the field that says whether the port is worth trying at all.
if rows[0].AgeS < 50 || rows[0].AgeS > 120 {
t.Fatalf("age_s = %d, want roughly 60", rows[0].AgeS)
}
if rows[1].AgeS <= rows[0].AgeS {
t.Fatalf("ages do not follow the ordering: %+v", rows)
}
}
// The card exists so the relay is usable without curl. Rendered here because a template error is
// only found when the page is executed, not when it is parsed.
func TestDashboardShowsTheRelayToAnAdmin(t *testing.T) {
s := adbFixture(t)
h := s.Handler()
req := httptest.NewRequest("GET", "/", nil)
req.AddCookie(&http.Cookie{
Name: sessionCookie, Value: s.Sessions.Issue("local:admin", "admin", true),
})
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("dashboard: code=%d", rec.Code)
}
for _, want := range []string{"Dev relay", "10.13.102.55:5555", "min ago"} {
if !strings.Contains(rec.Body.String(), want) {
t.Errorf("the dashboard card does not show %q", want)
}
}
// A plain user gets no rows at all — not an empty card, no card.
req = httptest.NewRequest("GET", "/", nil)
req.AddCookie(&http.Cookie{
Name: sessionCookie, Value: s.Sessions.Issue("oidc#someone", "Someone", false),
})
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if strings.Contains(rec.Body.String(), "10.13.102.55") {
t.Fatal("a non-admin session was shown a LAN address")
}
}
// The read is a GET, so a signed-in browser session must not be asked for a CSRF token it has no
// form to carry — but a session that is not an administrator is still refused.
func TestADBEndpointsReadFromABrowserSession(t *testing.T) {
s := adbFixture(t)
h := s.Handler()
for _, tc := range []struct {
name string
admin bool
wantCode int
}{
{"admin", true, http.StatusOK},
{"plain user", false, http.StatusForbidden},
} {
req := httptest.NewRequest("GET", "/admin/adb-endpoints", nil)
req.AddCookie(&http.Cookie{
Name: sessionCookie,
Value: s.Sessions.Issue("local:admin", "admin", tc.admin),
})
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != tc.wantCode {
t.Errorf("%s: code=%d, want %d (%s)", tc.name, rec.Code, tc.wantCode, rec.Body.String())
}
}
}