From 4afbf2d266d326e5125e09ef7c7c5c3080846712 Mon Sep 17 00:00:00 2001 From: Veillax <126523099+Veillax@users.noreply.github.com> Date: Mon, 26 Oct 2026 23:58:26 -0600 Subject: [PATCH] Fix the Servers page failing when no servers are configured An empty server list was sent as null, so the page threw on .length and showed only the loading placeholder. The API now always returns a list, the page tolerates null, and tests cover the empty config and the token. Co-Authored-By: Claude Sonnet 5.5 --- internal/admin/admin.go | 2 +- internal/admin/admin_test.go | 72 ++++++++++++++++++++++++++++++++++++ internal/config/config.go | 3 ++ web/index.html | 4 +- 4 files changed, 78 insertions(+), 3 deletions(-) create mode 100644 internal/admin/admin_test.go diff --git a/internal/admin/admin.go b/internal/admin/admin.go index b86b449..8d270ff 100644 --- a/internal/admin/admin.go +++ b/internal/admin/admin.go @@ -71,7 +71,7 @@ func (a *Admin) overview(w http.ResponseWriter, r *http.Request) { for _, s := range snap.Servers { stats[s.Name] = s } - var servers []serverView + servers := []serverView{} for _, s := range a.Cfg.Current().Servers { v := serverView{Server: s} if h, ok := states[s.Name]; ok { diff --git a/internal/admin/admin_test.go b/internal/admin/admin_test.go new file mode 100644 index 0000000..fd07fc0 --- /dev/null +++ b/internal/admin/admin_test.go @@ -0,0 +1,72 @@ +package admin + +import ( + "io" + "log/slog" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "git.veillax.com/Veillax/spinner/internal/config" + "git.veillax.com/Veillax/spinner/internal/health" + "git.veillax.com/Veillax/spinner/internal/proxy" +) + +func newAdmin(t *testing.T, cfg string) http.Handler { + t.Helper() + log := slog.New(slog.NewTextHandler(io.Discard, nil)) + path := filepath.Join(t.TempDir(), "spinner.json") + if err := os.WriteFile(path, []byte(cfg), 0o600); err != nil { + t.Fatal(err) + } + m, err := config.NewManager(path, log) + if err != nil { + t.Fatal(err) + } + h := health.New(log) + return (&Admin{Cfg: m, Health: h, Proxy: proxy.New(m, h, log), Token: "secret"}).Handler() +} + +func get(h http.Handler, path, token string) *httptest.ResponseRecorder { + req := httptest.NewRequest("GET", path, nil) + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + return rec +} + +// The page reads .length on these lists, so an empty config must give [] and never null. +func TestEmptyConfigGivesEmptyLists(t *testing.T) { + for _, cfg := range []string{`{}`, `{"servers":[]}`} { + h := newAdmin(t, cfg) + if body := get(h, "/api/overview", "secret").Body.String(); !strings.Contains(body, `"servers":[]`) { + t.Errorf("%s: overview servers not an empty list: %s", cfg, body) + } + if body := get(h, "/api/config", "secret").Body.String(); !strings.Contains(body, `"servers":[]`) { + t.Errorf("%s: config servers not an empty list: %s", cfg, body) + } + } +} + +func TestAuthRequired(t *testing.T) { + h := newAdmin(t, `{}`) + for _, p := range []string{"/api/overview", "/api/config", "/metrics"} { + if c := get(h, p, "").Code; c != http.StatusUnauthorized { + t.Errorf("%s without token: %d", p, c) + } + if c := get(h, p, "wrong").Code; c != http.StatusUnauthorized { + t.Errorf("%s with wrong token: %d", p, c) + } + if c := get(h, p, "secret").Code; c != http.StatusOK { + t.Errorf("%s with token: %d", p, c) + } + } + if c := get(h, "/", "").Code; c != http.StatusOK { + t.Errorf("page should load without a token: %d", c) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 39134fd..5eb8592 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -69,6 +69,9 @@ func Parse(data []byte) (*Config, error) { if c.UnknownMOTD == "" { c.UnknownMOTD = "§cUnknown server" } + if c.Servers == nil { + c.Servers = []Server{} + } return &c, c.validate() } diff --git a/web/index.html b/web/index.html index beb8b3c..e1ba1c9 100644 --- a/web/index.html +++ b/web/index.html @@ -450,9 +450,9 @@ views.servers = async () => { S.headerAction = ['Add server', () => serverDialog()]; const t = ov.totals; const totals = h('div', {class:'grid'}, stat('Connected now', t.active), stat('Connections since start', t.total), stat('Turned away', t.rejected), stat('Unknown address', t.unknown_host), stat('Running for', uptime(t.uptime_secs))); - if (!ov.servers.length) return h('div', {}, totals, emptyState('servers', 'No servers yet', 'Add a server to start routing players. Each one needs an address players type and a backend to send them to.', + if (!(ov.servers || []).length) return h('div', {}, totals, emptyState('servers', 'No servers yet', 'Add a server to start routing players. Each one needs an address players type and a backend to send them to.', h('button', {class:'btn', onclick:() => serverDialog()}, 'Add server'))); - const rows = ov.servers.map(s => { + const rows = (ov.servers || []).map(s => { const hl = s.health, st = s.stats || {}; const status = !hl ? badge('Checking') : hl.up ? badge('Online', 'success') : badge('Offline', 'danger'); const backend = h('td', {}, s.addresses.map(a => h('div', {class:'mono'}, a)),