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)),