package server import ( "context" "io" "net/http" "net/http/httptest" "strings" "testing" "galaxy/backend/internal/adminconsole" "galaxy/backend/internal/opsstatus" "galaxy/backend/internal/server/middleware/basicauth" "github.com/gin-gonic/gin" "go.uber.org/zap" ) // fakeMonitor is a static opsstatus.Reader for dashboard rendering tests. type fakeMonitor struct { snapshot opsstatus.Snapshot } func (f fakeMonitor) Collect(context.Context) opsstatus.Snapshot { return f.snapshot } func newConsoleTestRouter(t *testing.T) http.Handler { t.Helper() handler, err := NewRouter(RouterDependencies{ Logger: zap.NewNop(), AdminVerifier: basicauth.NewStaticVerifier("secret"), AdminConsole: NewAdminConsoleHandlers(AdminConsoleDeps{CSRF: adminconsole.NewCSRF([]byte("test-key"))}), }) if err != nil { t.Fatalf("NewRouter: %v", err) } return handler } func TestAdminConsoleRequiresAuth(t *testing.T) { router := newConsoleTestRouter(t) req := httptest.NewRequest(http.MethodGet, "/_gm/", nil) rec := httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusUnauthorized { t.Fatalf("status = %d, want 401", rec.Code) } if got := rec.Header().Get("WWW-Authenticate"); !strings.Contains(got, "Basic") { t.Fatalf("WWW-Authenticate = %q, want a Basic challenge", got) } } func TestAdminConsoleDashboardRenders(t *testing.T) { router := newConsoleTestRouter(t) for _, path := range []string{"/_gm", "/_gm/"} { req := httptest.NewRequest(http.MethodGet, path, nil) req.SetBasicAuth("ops", "secret") rec := httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("GET %s status = %d, want 200; body=%s", path, rec.Code, rec.Body.String()) } if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/html") { t.Errorf("GET %s content-type = %q, want text/html", path, ct) } body := rec.Body.String() if !strings.Contains(body, "Dashboard") { t.Errorf("GET %s body missing the dashboard heading", path) } if !strings.Contains(body, "ops") { t.Errorf("GET %s body missing the operator name", path) } } } func TestAdminConsoleDashboardShowsMonitoring(t *testing.T) { monitor := fakeMonitor{snapshot: opsstatus.Snapshot{ PostgresHealthy: true, Runtimes: []opsstatus.StatusCount{{Status: "running", Count: 3}, {Status: "stopped", Count: 1}}, MailDeliveries: []opsstatus.StatusCount{{Status: "pending", Count: 2}}, NotificationRoutes: []opsstatus.StatusCount{{Status: "published", Count: 9}}, NotificationMalformed: 4, Errors: []string{"notification route counts: boom"}, }} handler, err := NewRouter(RouterDependencies{ Logger: zap.NewNop(), AdminVerifier: basicauth.NewStaticVerifier("secret"), AdminConsole: NewAdminConsoleHandlers(AdminConsoleDeps{ CSRF: adminconsole.NewCSRF([]byte("test-key")), Monitor: monitor, Ready: func() bool { return true }, }), }) if err != nil { t.Fatalf("NewRouter: %v", err) } req := httptest.NewRequest(http.MethodGet, "/_gm/", nil) req.SetBasicAuth("ops", "secret") rec := httptest.NewRecorder() handler.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) } body := rec.Body.String() for _, want := range []string{ "Game runtimes", "running", "stopped", "Mail deliveries", "pending", "Notification routes", "published", "Malformed notifications", "notification route counts: boom", "healthy", } { if !strings.Contains(body, want) { t.Errorf("dashboard body missing %q", want) } } } func TestAdminConsoleDashboardWithoutMonitor(t *testing.T) { router := newConsoleTestRouter(t) // no monitor wired req := httptest.NewRequest(http.MethodGet, "/_gm/", nil) req.SetBasicAuth("ops", "secret") rec := httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want 200", rec.Code) } if !strings.Contains(rec.Body.String(), "Monitoring is not wired") { t.Error("dashboard without a monitor should note that monitoring is unavailable") } } func TestAdminConsoleServesAsset(t *testing.T) { router := newConsoleTestRouter(t) req := httptest.NewRequest(http.MethodGet, "/_gm/assets/console.css", nil) req.SetBasicAuth("ops", "secret") rec := httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("asset status = %d, want 200", rec.Code) } if ct := rec.Header().Get("Content-Type"); !strings.Contains(ct, "text/css") { t.Errorf("asset content-type = %q, want text/css", ct) } } func TestAdminConsoleRequireCSRF(t *testing.T) { gin.SetMode(gin.TestMode) csrf := adminconsole.NewCSRF([]byte("test-key")) console := NewAdminConsoleHandlers(AdminConsoleDeps{CSRF: csrf}) engine := gin.New() engine.Use(func(c *gin.Context) { c.Request = c.Request.WithContext(basicauth.WithUsername(c.Request.Context(), "ops")) c.Next() }) engine.Use(console.RequireCSRF()) engine.GET("/x", func(c *gin.Context) { c.Status(http.StatusOK) }) engine.POST("/x", func(c *gin.Context) { c.Status(http.StatusOK) }) token := csrf.Token("ops") cases := []struct { name string method string form string origin string host string want int }{ {"get is a safe method", http.MethodGet, "", "", "galaxy.lan", http.StatusOK}, {"valid token, same origin", http.MethodPost, "_csrf=" + token, "https://galaxy.lan", "galaxy.lan", http.StatusOK}, {"valid token, no origin header", http.MethodPost, "_csrf=" + token, "", "galaxy.lan", http.StatusOK}, {"missing token", http.MethodPost, "", "https://galaxy.lan", "galaxy.lan", http.StatusForbidden}, {"wrong token", http.MethodPost, "_csrf=bogus", "https://galaxy.lan", "galaxy.lan", http.StatusForbidden}, {"cross-origin", http.MethodPost, "_csrf=" + token, "https://evil.example", "galaxy.lan", http.StatusForbidden}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { var body io.Reader if tc.form != "" { body = strings.NewReader(tc.form) } req := httptest.NewRequest(tc.method, "/x", body) if tc.form != "" { req.Header.Set("Content-Type", "application/x-www-form-urlencoded") } if tc.origin != "" { req.Header.Set("Origin", tc.origin) } req.Host = tc.host rec := httptest.NewRecorder() engine.ServeHTTP(rec, req) if rec.Code != tc.want { t.Fatalf("status = %d, want %d (body=%s)", rec.Code, tc.want, rec.Body.String()) } }) } }