feat(admin-console): Stage 1 — pipe + skeleton behind the gateway
Tests · Go / test (push) Successful in 2m0s
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.
This commit is contained in:
@@ -0,0 +1,67 @@
|
||||
package adminconsole
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io/fs"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRendererRendersDashboard(t *testing.T) {
|
||||
renderer, err := NewRenderer()
|
||||
if err != nil {
|
||||
t.Fatalf("NewRenderer: %v", err)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
err = renderer.Render(&buf, "dashboard", PageData{
|
||||
Title: "Dashboard",
|
||||
Username: "ops-bob",
|
||||
ActiveNav: "dashboard",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Render: %v", err)
|
||||
}
|
||||
|
||||
out := buf.String()
|
||||
for _, want := range []string{
|
||||
"<!DOCTYPE html>",
|
||||
"Dashboard",
|
||||
"ops-bob",
|
||||
`href="/_gm/users"`,
|
||||
"/_gm/assets/console.css",
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("rendered page missing %q\n--- page ---\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRendererUnknownPage(t *testing.T) {
|
||||
renderer := MustNewRenderer()
|
||||
if err := renderer.Render(&bytes.Buffer{}, "does-not-exist", PageData{}); err == nil {
|
||||
t.Fatal("expected an error rendering an unknown page")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRendererEscapesUsername(t *testing.T) {
|
||||
renderer := MustNewRenderer()
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := renderer.Render(&buf, "dashboard", PageData{Username: "<script>evil</script>"}); err != nil {
|
||||
t.Fatalf("Render: %v", err)
|
||||
}
|
||||
if strings.Contains(buf.String(), "<script>evil</script>") {
|
||||
t.Error("username was not HTML-escaped in the rendered page")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssetsContainsStylesheet(t *testing.T) {
|
||||
fsys, err := Assets()
|
||||
if err != nil {
|
||||
t.Fatalf("Assets: %v", err)
|
||||
}
|
||||
if _, err := fs.Stat(fsys, "console.css"); err != nil {
|
||||
t.Fatalf("console.css missing from embedded assets: %v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user