27916bbe61
Tests · Go / test (push) Successful in 2m0s
Add the server-rendered operator console at /_gm, exposed publicly through the gateway behind the existing admin_accounts Basic Auth. Backend: - new internal/adminconsole package (html/template Renderer, stateless HMAC CSRF signer, embedded stylesheet) - /_gm route group reusing basicauth.Middleware(admin.Service) + a CSRF guard (per-operator token + same-origin check); dashboard landing page - BACKEND_ADMIN_CONSOLE_CSRF_KEY config (per-process random fallback) Gateway: - new "admin" public route class (per-IP rate limit, body + GET/HEAD/POST method limits) classifying /_gm traffic - reverse proxy to the backend /_gm surface, preserving Host and relaying the backend 401 Basic Auth challenge; 502 when the backend is unreachable - GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_ADMIN_* config dev-deploy: - Caddy routes /_gm/* to the gateway - bootstrap admin + stable CSRF key; enable Prometheus /metrics exporters on backend and gateway (forward-compat for a future Prometheus/Grafana stack) Docs: ARCHITECTURE 14.1/16, FUNCTIONAL 10.2.1 (+ru mirror), backend and gateway READMEs, new backend/docs/admin-console.md. Tests: renderer + CSRF unit tests; backend router auth/render/asset/CSRF; gateway classifier, proxy forwarding/Host/401/405/413/429/502.
142 lines
4.1 KiB
Go
142 lines
4.1 KiB
Go
package server
|
|
|
|
import (
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
"galaxy/backend/internal/adminconsole"
|
|
"galaxy/backend/internal/server/middleware/basicauth"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"go.uber.org/zap"
|
|
)
|
|
|
|
func newConsoleTestRouter(t *testing.T) http.Handler {
|
|
t.Helper()
|
|
handler, err := NewRouter(RouterDependencies{
|
|
Logger: zap.NewNop(),
|
|
AdminVerifier: basicauth.NewStaticVerifier("secret"),
|
|
AdminConsole: NewAdminConsoleHandlers(nil, adminconsole.NewCSRF([]byte("test-key")), nil),
|
|
})
|
|
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 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(nil, csrf, nil)
|
|
|
|
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())
|
|
}
|
|
})
|
|
}
|
|
}
|