Files
n30nex 775aedfd2a feat(admin): update runtime CORS origins consistently (#150)
* feat(admin): update runtime CORS origins consistently

* test(admin): preserve rate-limit headers after CORS updates

* test(admin): preserve router connection budget after rebase
2026-09-19 15:27:25 -04:00

111 lines
4.4 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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")
}
}