mirror of
https://github.com/MeshCore-Beacon/beacon-server.git
synced 2026-10-16 20:20:07 +00:00
* feat(admin): update runtime CORS origins consistently * test(admin): preserve rate-limit headers after CORS updates * test(admin): preserve router connection budget after rebase
111 lines
4.4 KiB
Go
111 lines
4.4 KiB
Go
// Copyright 2026 Beacon Contributors
|
||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||
|
||
package middleware
|
||
|
||
import (
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"reflect"
|
||
"strings"
|
||
"sync"
|
||
"testing"
|
||
|
||
"github.com/MeshCore-Beacon/beacon-server/internal/api"
|
||
)
|
||
|
||
func TestValidateOrigins(t *testing.T) {
|
||
for _, tc := range []struct {
|
||
input []string
|
||
valid bool
|
||
}{
|
||
{[]string{"*"}, true}, {[]string{" HTTPS://Example.test ", "https://*.example.test", "http://127.0.0.1:8080", "http://[::1]:8080"}, true},
|
||
{nil, false}, {[]string{}, false}, {make([]string, 33), false}, {[]string{""}, false}, {[]string{"null"}, false},
|
||
{[]string{"*", "https://example.test"}, false}, {[]string{"ftp://example.test"}, false}, {[]string{"https://"}, false},
|
||
{[]string{"https://example.test/"}, false}, {[]string{"https://example.test?"}, false}, {[]string{"https://example.test#"}, false},
|
||
{[]string{"https://user:secret@example.test"}, false}, {[]string{"https://example.test:65536"}, false}, {[]string{"https://example.test:"}, false},
|
||
{[]string{"https://*.*.test"}, false}, {[]string{"https://example.test\n"}, false}, {[]string{"https://é.test"}, false}, {[]string{"https://K.test"}, false}, {[]string{strings.Repeat("x", 513)}, false},
|
||
} {
|
||
_, err := validateOrigins(tc.input)
|
||
if (err == nil) != tc.valid {
|
||
t.Fatalf("origins=%q valid=%v error=%v", tc.input, tc.valid, err)
|
||
}
|
||
}
|
||
input := []string{" HTTPS://Example.test "}
|
||
got, err := validateOrigins(input)
|
||
if err != nil || got[0] != "https://example.test" || input[0] != " HTTPS://Example.test " {
|
||
t.Fatal("normalization changed caller state")
|
||
}
|
||
}
|
||
|
||
func TestRuntimePolicyAndConcurrency(t *testing.T) {
|
||
initial := api.AdminConfig{Auth: api.AdminAuthConfig{Configured: true}, Ingest: api.AdminIngestConfig{BrokerCount: 2},
|
||
CORS: api.AdminCORSConfig{AllowedOrigins: []string{"https://before.test"}, AllowedMethods: []string{"GET", "PUT"}, AllowedHeaders: []string{"Authorization"}, AllowCredentials: true, MaxAge: 600}}
|
||
state := NewRuntimeConfig(initial, []string{"Retry-After"})
|
||
initial.CORS.AllowedOrigins[0] = "https://caller-change.test"
|
||
handler := state.CORS(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(204) }))
|
||
preflight := func(origin string) *httptest.ResponseRecorder {
|
||
r := httptest.NewRequest("OPTIONS", "/api/v1/brokers", nil)
|
||
r.Header.Set("Origin", origin)
|
||
r.Header.Set("Access-Control-Request-Method", "PUT")
|
||
r.Header.Set("Access-Control-Request-Headers", "Authorization")
|
||
w := httptest.NewRecorder()
|
||
handler.ServeHTTP(w, r)
|
||
return w
|
||
}
|
||
if preflight("https://before.test").Header().Get("Access-Control-Allow-Origin") == "" {
|
||
t.Fatal("startup policy changed with caller slice")
|
||
}
|
||
origins := []string{"https://*.after.test"}
|
||
updated, err := state.UpdateOrigins(origins)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
origins[0] = "https://caller-change.test"
|
||
updated.CORS.AllowedOrigins[0] = "https://response-change.test"
|
||
if preflight("https://before.test").Header().Get("Access-Control-Allow-Origin") != "" {
|
||
t.Fatal("old policy remains active")
|
||
}
|
||
w := preflight("https://app.after.test")
|
||
if w.Code != 200 || w.Header().Get("Access-Control-Allow-Origin") != "https://app.after.test" || w.Header().Get("Access-Control-Max-Age") != "600" || w.Header().Get("Access-Control-Allow-Credentials") != "true" {
|
||
t.Fatal("new policy/options not enforced")
|
||
}
|
||
r := httptest.NewRequest("GET", "/", nil)
|
||
r.Header.Set("Origin", "https://app.after.test")
|
||
w = httptest.NewRecorder()
|
||
handler.ServeHTTP(w, r)
|
||
if w.Header().Get("Access-Control-Expose-Headers") != "Retry-After" {
|
||
t.Fatal("exposed headers lost")
|
||
}
|
||
before := state.Snapshot()
|
||
if _, err := state.UpdateOrigins(nil); err == nil || !reflect.DeepEqual(state.Snapshot(), before) {
|
||
t.Fatal("invalid update changed config")
|
||
}
|
||
var group sync.WaitGroup
|
||
for i := 0; i < 8; i++ {
|
||
group.Add(1)
|
||
go func(i int) {
|
||
defer group.Done()
|
||
for j := 0; j < 40; j++ {
|
||
origin := "https://a.test"
|
||
if i%2 != 0 {
|
||
origin = "https://b.test"
|
||
}
|
||
if _, err := state.UpdateOrigins([]string{origin}); err != nil {
|
||
t.Error(err)
|
||
}
|
||
snapshot := state.Snapshot()
|
||
if len(snapshot.CORS.AllowedOrigins) != 1 || !snapshot.Auth.Configured || snapshot.Ingest.BrokerCount != 2 {
|
||
t.Error("torn config")
|
||
}
|
||
preflight(origin)
|
||
}
|
||
}(i)
|
||
}
|
||
group.Wait()
|
||
final := state.Snapshot()
|
||
if preflight(final.CORS.AllowedOrigins[0]).Header().Get("Access-Control-Allow-Origin") == "" {
|
||
t.Fatal("reported/enforced final policy differs")
|
||
}
|
||
}
|