Files
CartoLite-Server/backend/internal/httpapi/server_test.go
n30nex 2dbe31dfe9 Port recent CartoLite updates and add worldwide Netgraph (#6)
* Bring CartoLite visuals and recovery to the worldwide server with Netgraph

* Remove remaining country-overlay cleanup references

* Align the CI container identity with the integration checks

* Exercise worldwide grid areas in the large-topology benchmark
2026-09-05 21:38:55 -04:00

292 lines
11 KiB
Go

package httpapi
import (
"bytes"
"context"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"testing/fstest"
"time"
"github.com/n30nex/cartolite-server/backend/internal/engine"
)
func testHandler(t *testing.T, ready bool) http.Handler {
t.Helper()
state, err := engine.New(engine.Options{Checkpoint: filepath.Join(t.TempDir(), "state-v1.json"), QueueSize: 64, Version: "test", GitSHA: "abc"})
if err != nil {
t.Fatal(err)
}
server, err := New(state, NewHub(state.BootID()), func() bool { return ready }, "test", "abc")
if err != nil {
t.Fatal(err)
}
return server.Handler()
}
func TestStaticCachePolicy(t *testing.T) {
server := &Server{static: fstest.MapFS{
"index.html": {Data: []byte("<title>CartoLite Server</title>")},
"assets/main-abc.js": {Data: []byte("export {}")},
}}
handler := server.Handler()
response := httptest.NewRecorder()
handler.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/", nil))
if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), "CartoLite Server") {
t.Fatalf("root did not serve the map entry: status=%d body=%q", response.Code, response.Body.String())
}
if cache := response.Header().Get("Cache-Control"); cache != "no-cache" {
t.Fatalf("HTML cache policy = %q", cache)
}
asset := httptest.NewRecorder()
handler.ServeHTTP(asset, httptest.NewRequest(http.MethodGet, "/assets/main-abc.js", nil))
if asset.Code != http.StatusOK || asset.Header().Get("Cache-Control") != "public, max-age=31536000, immutable" {
t.Fatalf("asset cache response = status %d, cache %q", asset.Code, asset.Header().Get("Cache-Control"))
}
missing := httptest.NewRecorder()
handler.ServeHTTP(missing, httptest.NewRequest(http.MethodGet, "/unknown", nil))
if missing.Code != http.StatusNotFound {
t.Fatalf("unknown path returned %d", missing.Code)
}
}
func TestPublicRoutesAndPrivateBoundaries(t *testing.T) {
handler := testHandler(t, true)
for _, path := range []string{"/healthz", "/readyz", "/api/state", "/"} {
response := httptest.NewRecorder()
handler.ServeHTTP(response, httptest.NewRequest(http.MethodGet, path, nil))
if response.Code != http.StatusOK {
t.Fatalf("%s returned %d", path, response.Code)
}
}
for _, path := range []string{"/api/v1/live/state", "/api/v1/debug/state", "/api/v1/nodes", "/api/v1/packets", "/ws"} {
response := httptest.NewRecorder()
handler.ServeHTTP(response, httptest.NewRequest(http.MethodGet, path, nil))
if response.Code != http.StatusNotFound {
t.Fatalf("private path %s returned %d", path, response.Code)
}
}
}
func TestSecurityHeadersExcludeExternalGlyphOrigins(t *testing.T) {
response := httptest.NewRecorder()
testHandler(t, true).ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/healthz", nil))
csp := response.Header().Get("Content-Security-Policy")
if !strings.Contains(csp, "https://*.basemaps.cartocdn.com") {
t.Fatalf("CSP does not allow the configured vector map origin: %q", csp)
}
if !strings.Contains(csp, "https://tiles.mapterhorn.com") {
t.Fatalf("CSP does not allow the configured terrain origin: %q", csp)
}
for _, origin := range []string{"https://demotiles.maplibre.org", "https://fonts.openmaptiles.org"} {
if strings.Contains(csp, origin) {
t.Fatalf("CSP still allows external glyph origin %q: %q", origin, csp)
}
}
if policy := response.Header().Get("Permissions-Policy"); !strings.Contains(policy, "geolocation=()") || !strings.Contains(policy, "usb=()") {
t.Fatalf("Permissions-Policy does not deny unused device access: %q", policy)
}
if hsts := response.Header().Get("Strict-Transport-Security"); hsts != "max-age=31536000" {
t.Fatalf("unexpected HSTS policy: %q", hsts)
}
}
func TestMissingFrontendAssetReturnsNotFound(t *testing.T) {
response := httptest.NewRecorder()
testHandler(t, true).ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/missing-asset.svg", nil))
if response.Code != http.StatusNotFound {
t.Fatalf("missing frontend asset returned %d", response.Code)
}
}
func TestWebManifestUsesInstallableContentType(t *testing.T) {
if contentType := staticContentType("site.webmanifest"); contentType != "application/manifest+json" {
t.Fatalf("unexpected web manifest content type: %q", contentType)
}
}
func TestNetgraphPageAndCanonicalURL(t *testing.T) {
server := &Server{static: fstest.MapFS{
"netgraph/index.html": {Data: []byte("<title>Worldwide Netgraph</title>")},
}}
handler := server.Handler()
response := httptest.NewRecorder()
handler.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/netgraph?view=test", nil))
if response.Code != http.StatusPermanentRedirect || response.Header().Get("Location") != "/netgraph/?view=test" {
t.Fatalf("unexpected Netgraph redirect: %d %q", response.Code, response.Header().Get("Location"))
}
response = httptest.NewRecorder()
handler.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/netgraph/", nil))
if response.Code != http.StatusOK || response.Header().Get("Cache-Control") != "no-cache" {
t.Fatalf("unexpected Netgraph response: %d", response.Code)
}
response = httptest.NewRecorder()
handler.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/labs/", nil))
if response.Code != http.StatusNotFound {
t.Fatalf("unexpected Labs route: %d", response.Code)
}
}
func TestGeoJSONUsesGeographicContentType(t *testing.T) {
if contentType := staticContentType("assets/operator-overlay.abc123.geojson"); contentType != "application/geo+json" {
t.Fatalf("unexpected region GeoJSON content type: %q", contentType)
}
}
func TestSSERejectsCrossOrigin(t *testing.T) {
handler := testHandler(t, true)
request := httptest.NewRequest(http.MethodGet, "/api/events", nil)
request.Host = "cartolite.example"
request.Header.Set("Origin", "https://evil.example")
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
if response.Code != http.StatusForbidden {
t.Fatalf("cross-origin SSE returned %d", response.Code)
}
}
func TestReadinessFailsClosed(t *testing.T) {
handler := testHandler(t, false)
response := httptest.NewRecorder()
handler.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/readyz", nil))
if response.Code != http.StatusServiceUnavailable {
t.Fatalf("disconnected MQTT readiness returned %d", response.Code)
}
}
func TestSSESubscribesBeforeHelloFlush(t *testing.T) {
state, err := engine.New(engine.Options{Checkpoint: filepath.Join(t.TempDir(), "state-v1.json"), QueueSize: 64})
if err != nil {
t.Fatal(err)
}
hub := NewHub(state.BootID())
server, err := New(state, hub, func() bool { return true }, "test", "abc")
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
writer := &sseRaceWriter{header: make(http.Header)}
writer.flush = func(count int) {
if count == 1 {
hub.Publish(engine.Event{Name: "packet", Seq: 1, Data: map[string]any{"seq": 1, "mode": "observer"}})
time.AfterFunc(100*time.Millisecond, cancel)
return
}
cancel()
}
request := httptest.NewRequest(http.MethodGet, "/api/events", nil).WithContext(ctx)
server.events(writer, request)
body := writer.body.String()
if hello, packet := strings.Index(body, "event: hello"), strings.Index(body, "event: packet"); hello < 0 || packet <= hello {
t.Fatalf("event published during hello flush was missed or reordered: %q", body)
}
}
func TestSSESubscriberCapFailsClosed(t *testing.T) {
hub := NewHub("test-boot")
cancels := make([]func(), 0, maxSSEClients)
for index := 0; index < maxSSEClients; index++ {
subscription := hub.Subscribe("test-boot", 0, false)
if !subscription.accepted {
t.Fatalf("subscriber %d was rejected below cap", index)
}
cancels = append(cancels, subscription.cancel)
}
defer func() {
for _, cancel := range cancels {
cancel()
}
}()
if subscription := hub.Subscribe("test-boot", 0, false); subscription.accepted {
t.Fatal("subscriber above cap was accepted")
}
}
func TestSSEHelloHasNoIDAndReplayIsOrdered(t *testing.T) {
state, err := engine.New(engine.Options{Checkpoint: filepath.Join(t.TempDir(), "state-v1.json"), QueueSize: 64})
if err != nil {
t.Fatal(err)
}
hub := NewHub(state.BootID())
hub.Publish(engine.Event{Name: "node", Seq: 1, Data: map[string]any{"seq": 1}})
hub.Publish(engine.Event{Name: "packet", Seq: 2, Data: map[string]any{"seq": 2}})
server, err := New(state, hub, func() bool { return true }, "test", "abc")
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
writer := &sseRaceWriter{header: make(http.Header)}
writer.flush = func(count int) {
if count >= 3 {
cancel()
}
}
request := httptest.NewRequest(http.MethodGet, "/api/events?bootId="+state.BootID()+"&after=0", nil).WithContext(ctx)
server.events(writer, request)
body := writer.body.String()
if !strings.HasPrefix(body, "event: hello\n") {
t.Fatalf("hello unexpectedly carried an SSE id: %q", body)
}
first, second := strings.Index(body, "id: 1\n"), strings.Index(body, "id: 2\n")
if first < 0 || second <= first {
t.Fatalf("replay was not ordered after hello: %q", body)
}
}
func TestSSEBootMismatchResetsAndCloses(t *testing.T) {
state, err := engine.New(engine.Options{Checkpoint: filepath.Join(t.TempDir(), "state-v1.json"), QueueSize: 64})
if err != nil {
t.Fatal(err)
}
hub := NewHub(state.BootID())
hub.Publish(engine.Event{Name: "status", Seq: 1, Data: map[string]any{"seq": 1}})
server, err := New(state, hub, func() bool { return true }, "test", "abc")
if err != nil {
t.Fatal(err)
}
response := httptest.NewRecorder()
server.events(response, httptest.NewRequest(http.MethodGet, "/api/events?bootId=old-boot&after=1", nil))
if !strings.Contains(response.Body.String(), "event: reset") || strings.Contains(response.Body.String(), "event: hello") {
t.Fatalf("boot mismatch response = %q", response.Body.String())
}
}
func TestEventCursorUsesNewestValidCursor(t *testing.T) {
request := httptest.NewRequest(http.MethodGet, "/api/events?bootId=boot&after=7", nil)
request.Header.Set("Last-Event-ID", "9")
bootID, after, set, err := eventCursor(request)
if err != nil || bootID != "boot" || !set || after != 9 {
t.Fatalf("cursor = %q, %d, %v, %v", bootID, after, set, err)
}
request.Header.Set("Last-Event-ID", "invalid")
_, after, _, err = eventCursor(request)
if err != nil || after != 7 {
t.Fatalf("invalid Last-Event-ID should leave query cursor, got %d, %v", after, err)
}
}
type sseRaceWriter struct {
header http.Header
body bytes.Buffer
flush func(int)
count int
}
func (w *sseRaceWriter) Header() http.Header { return w.header }
func (w *sseRaceWriter) WriteHeader(_ int) {}
func (w *sseRaceWriter) Write(body []byte) (int, error) { return w.body.Write(body) }
func (w *sseRaceWriter) Flush() {
w.count++
if w.flush != nil {
w.flush(w.count)
}
}