Types 0x03/0x04/0x05 land with a bounded columnar train buffer (head kept, truncation declared) and grant-free multi-part reports - a report row is smaller than the packet it answers, so $3.4 holds without a grant. The read loop now collects TTL/TOS cmsgs on Linux, replacing the 0xFF stubs in the observation block with what the kernel saw; downtrain gained a dscp parameter, so DSCP survival is measurable in both directions. Rate limiting ($2.5) exists now: per-credential AND per-source buckets, 429 on the control plane, silent drop on the data plane after the HMAC gate and before the replay window. UDP ceilings default above the largest legitimate run - a limit that clips a real measurement produces a confidently wrong number. Every granted packet carries its action_id at payload[8:16]; overlapping actions were unattributable before. Canary DNS logs now honor the stated 24h privacy default. /admin/enroll-tokens answers the spec's JSON shape. protocol_version 1.0.1 (additive). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
89 lines
2.8 KiB
Go
89 lines
2.8 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/adminauth"
|
|
)
|
|
|
|
// tokenFixture wires just enough of the Server for the mint endpoint: a break-glass admin and
|
|
// a stand-in EnrollLink (the real one belongs to the control server, injected the same way).
|
|
func tokenFixture(t *testing.T) *Server {
|
|
t.Helper()
|
|
s, _, _, _ := fixture(t)
|
|
secret, err := s.Store.SessionSecret()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
s.Sessions = adminauth.NewSessions(secret, time.Hour)
|
|
s.Throttle = adminauth.NewThrottle()
|
|
cred, err := adminauth.NewCredential("admin", "a-long-test-password")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := s.Store.SetLocalAdmin(cred); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
s.EnrollLink = func(tok string) string { return "echolot://enroll?v=1&t=" + tok }
|
|
return s
|
|
}
|
|
|
|
func TestEnrollTokensAPISpecShape(t *testing.T) {
|
|
h := tokenFixture(t).Handler()
|
|
|
|
// No credentials → 401 with a challenge, never a token.
|
|
req := httptest.NewRequest("POST", "/admin/enroll-tokens?note=phone", nil)
|
|
rec := httptest.NewRecorder()
|
|
h.ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusUnauthorized || rec.Header().Get("WWW-Authenticate") == "" {
|
|
t.Fatalf("unauthenticated: code=%d", rec.Code)
|
|
}
|
|
|
|
// Wrong password → still 401.
|
|
req = httptest.NewRequest("POST", "/admin/enroll-tokens", 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)
|
|
}
|
|
|
|
// Basic + Accept: application/json → the §2.1 shape.
|
|
req = httptest.NewRequest("POST", "/admin/enroll-tokens?note=phone", nil)
|
|
req.SetBasicAuth("admin", "a-long-test-password")
|
|
req.Header.Set("Accept", "application/json")
|
|
rec = httptest.NewRecorder()
|
|
h.ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("mint: code=%d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
var body struct {
|
|
Token string `json:"token"`
|
|
ExpiresS int `json:"expires_in_s"`
|
|
EnrollURI string `json:"enroll_uri"`
|
|
}
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if body.Token == "" || body.ExpiresS != 86400 || !strings.HasPrefix(body.EnrollURI, "echolot://enroll?") {
|
|
t.Fatalf("spec shape violated: %+v", body)
|
|
}
|
|
|
|
// Without Accept: the browser flow — redirect to the QR page, link in the query.
|
|
req = httptest.NewRequest("POST", "/admin/enroll-tokens", nil)
|
|
req.SetBasicAuth("admin", "a-long-test-password")
|
|
rec = httptest.NewRecorder()
|
|
h.ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusSeeOther || !strings.HasPrefix(rec.Header().Get("Location"), "/devices?link=") {
|
|
t.Fatalf("html flow: code=%d location=%q", rec.Code, rec.Header().Get("Location"))
|
|
}
|
|
}
|