feat: add sqlite-backed admin management
Some checks failed
Go / build (.exe, 386, windows, windows-386) (push) Has been cancelled
Go / build (.exe, amd64, windows, windows-amd64) (push) Has been cancelled
Go / build (.exe, arm64, windows, windows-arm64) (push) Has been cancelled
Go / build (386, freebsd, freebsd-386) (push) Has been cancelled
Go / build (386, linux, linux-386) (push) Has been cancelled
Go / build (386, netbsd, netbsd-386) (push) Has been cancelled
Go / build (386, openbsd, openbsd-386) (push) Has been cancelled
Go / build (386, plan9, plan9-386) (push) Has been cancelled
Go / build (amd64, darwin, darwin-amd64) (push) Has been cancelled
Go / build (amd64, dragonfly, dragonfly-amd64) (push) Has been cancelled
Go / build (amd64, freebsd, freebsd-amd64) (push) Has been cancelled
Go / build (amd64, illumos, illumos-amd64) (push) Has been cancelled
Go / build (amd64, linux, linux-amd64) (push) Has been cancelled
Go / build (amd64, netbsd, netbsd-amd64) (push) Has been cancelled
Go / build (amd64, openbsd, openbsd-amd64) (push) Has been cancelled
Go / build (amd64, plan9, plan9-amd64) (push) Has been cancelled
Go / build (amd64, solaris, solaris-amd64) (push) Has been cancelled
Go / build (arm, 6, linux, linux-armv6) (push) Has been cancelled
Go / build (arm, 7, linux, linux-armv7) (push) Has been cancelled
Go / build (arm, freebsd, freebsd-arm) (push) Has been cancelled
Go / build (arm, netbsd, netbsd-arm) (push) Has been cancelled
Go / build (arm, openbsd, openbsd-arm) (push) Has been cancelled
Go / build (arm, plan9, plan9-arm) (push) Has been cancelled
Go / build (arm64, darwin, darwin-arm64) (push) Has been cancelled
Go / build (arm64, freebsd, freebsd-arm64) (push) Has been cancelled
Go / build (arm64, linux, linux-arm64) (push) Has been cancelled
Go / build (arm64, netbsd, netbsd-arm64) (push) Has been cancelled
Go / build (arm64, openbsd, openbsd-arm64) (push) Has been cancelled
Go / build (loong64, linux, linux-loong64) (push) Has been cancelled
Go / build (mips, linux, linux-mips) (push) Has been cancelled
Go / build (mips64, linux, linux-mips64) (push) Has been cancelled
Go / build (mips64le, linux, linux-mips64le) (push) Has been cancelled
Go / build (mipsle, linux, linux-mipsle) (push) Has been cancelled
Go / build (ppc64, aix, aix-ppc64) (push) Has been cancelled
Go / build (ppc64, linux, linux-ppc64) (push) Has been cancelled
Go / build (ppc64, openbsd, openbsd-ppc64) (push) Has been cancelled
Go / build (ppc64le, linux, linux-ppc64le) (push) Has been cancelled
Go / build (riscv64, freebsd, freebsd-riscv64) (push) Has been cancelled
Go / build (riscv64, linux, linux-riscv64) (push) Has been cancelled
Go / build (riscv64, openbsd, openbsd-riscv64) (push) Has been cancelled
Go / build (s390x, linux, linux-s390x) (push) Has been cancelled
Go / merge-artifacts (push) Has been cancelled

This commit is contained in:
2026-06-25 08:53:44 +08:00
parent ada051341d
commit 13ab1f6964
73 changed files with 6926 additions and 782 deletions

92
internal/adminhttp/api.go Normal file
View File

@@ -0,0 +1,92 @@
package adminhttp
import (
"net/http"
"strings"
)
type SegmentHandlerFunc func(http.ResponseWriter, *http.Request, string)
type APIHandlers struct {
SetupStatus http.HandlerFunc
Setup http.HandlerFunc
Login http.HandlerFunc
Logout http.HandlerFunc
Me http.HandlerFunc
Status http.HandlerFunc
RoutesList http.HandlerFunc
RouteItem SegmentHandlerFunc
ServicesList http.HandlerFunc
ServiceItem SegmentHandlerFunc
Metrics http.HandlerFunc
UsersList http.HandlerFunc
UsersCreate http.HandlerFunc
UserItem SegmentHandlerFunc
AuditLogs http.HandlerFunc
}
func NewAPIHandler(prefix string, handlers APIHandlers) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store")
path := strings.TrimPrefix(r.URL.Path, prefix)
if path == "" {
path = "/"
}
switch {
case path == "/setup" && r.Method == http.MethodGet:
callHandler(w, r, handlers.SetupStatus)
case path == "/setup" && r.Method == http.MethodPost:
callHandler(w, r, handlers.Setup)
case path == "/auth/login" && r.Method == http.MethodPost:
callHandler(w, r, handlers.Login)
case path == "/auth/logout" && r.Method == http.MethodPost:
callHandler(w, r, handlers.Logout)
case path == "/me" && r.Method == http.MethodGet:
callHandler(w, r, handlers.Me)
case path == "/status" && r.Method == http.MethodGet:
callHandler(w, r, handlers.Status)
case path == "/routes" && r.Method == http.MethodGet:
callHandler(w, r, handlers.RoutesList)
case strings.HasPrefix(path, "/routes/"):
callSegmentHandler(w, r, handlers.RouteItem, strings.TrimPrefix(path, "/routes/"))
case path == "/services" && r.Method == http.MethodGet:
callHandler(w, r, handlers.ServicesList)
case strings.HasPrefix(path, "/services/"):
callSegmentHandler(w, r, handlers.ServiceItem, strings.TrimPrefix(path, "/services/"))
case path == "/metrics" && r.Method == http.MethodGet:
callHandler(w, r, handlers.Metrics)
case path == "/users" && r.Method == http.MethodGet:
callHandler(w, r, handlers.UsersList)
case path == "/users" && r.Method == http.MethodPost:
callHandler(w, r, handlers.UsersCreate)
case strings.HasPrefix(path, "/users/"):
callSegmentHandler(w, r, handlers.UserItem, strings.TrimPrefix(path, "/users/"))
case path == "/audit-logs" && r.Method == http.MethodGet:
callHandler(w, r, handlers.AuditLogs)
default:
WriteAPIError(w, http.StatusNotFound, "not found")
}
}
}
func callHandler(w http.ResponseWriter, r *http.Request, handler http.HandlerFunc) {
if handler == nil {
WriteAPIError(w, http.StatusNotFound, "not found")
return
}
handler(w, r)
}
func callSegmentHandler(w http.ResponseWriter, r *http.Request, handler SegmentHandlerFunc, segment string) {
if handler == nil {
WriteAPIError(w, http.StatusNotFound, "not found")
return
}
handler(w, r, segment)
}

View File

@@ -0,0 +1,114 @@
package adminhttp
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestNewAPIHandlerRoutesRequests(t *testing.T) {
tests := []struct {
name string
method string
path string
wantCall string
wantSegment string
}{
{name: "setup status", method: http.MethodGet, path: "/admin/api/setup", wantCall: "setup_status"},
{name: "setup", method: http.MethodPost, path: "/admin/api/setup", wantCall: "setup"},
{name: "login", method: http.MethodPost, path: "/admin/api/auth/login", wantCall: "login"},
{name: "logout", method: http.MethodPost, path: "/admin/api/auth/logout", wantCall: "logout"},
{name: "me", method: http.MethodGet, path: "/admin/api/me", wantCall: "me"},
{name: "status", method: http.MethodGet, path: "/admin/api/status", wantCall: "status"},
{name: "routes list", method: http.MethodGet, path: "/admin/api/routes", wantCall: "routes_list"},
{name: "route item", method: http.MethodPut, path: "/admin/api/routes/play.example", wantCall: "route_item", wantSegment: "play.example"},
{name: "services list", method: http.MethodGet, path: "/admin/api/services", wantCall: "services_list"},
{name: "service restart", method: http.MethodPost, path: "/admin/api/services/kcp/restart", wantCall: "service_item", wantSegment: "kcp/restart"},
{name: "metrics", method: http.MethodGet, path: "/admin/api/metrics", wantCall: "metrics"},
{name: "users list", method: http.MethodGet, path: "/admin/api/users", wantCall: "users_list"},
{name: "users create", method: http.MethodPost, path: "/admin/api/users", wantCall: "users_create"},
{name: "user item", method: http.MethodPatch, path: "/admin/api/users/member", wantCall: "user_item", wantSegment: "member"},
{name: "audit logs", method: http.MethodGet, path: "/admin/api/audit-logs", wantCall: "audit_logs"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var gotCall, gotSegment string
handler := NewAPIHandler("/admin/api", APIHandlers{
SetupStatus: recordCall(&gotCall, "setup_status"),
Setup: recordCall(&gotCall, "setup"),
Login: recordCall(&gotCall, "login"),
Logout: recordCall(&gotCall, "logout"),
Me: recordCall(&gotCall, "me"),
Status: recordCall(&gotCall, "status"),
RoutesList: recordCall(&gotCall, "routes_list"),
RouteItem: recordSegmentCall(&gotCall, &gotSegment, "route_item"),
ServicesList: recordCall(&gotCall, "services_list"),
ServiceItem: recordSegmentCall(&gotCall, &gotSegment, "service_item"),
Metrics: recordCall(&gotCall, "metrics"),
UsersList: recordCall(&gotCall, "users_list"),
UsersCreate: recordCall(&gotCall, "users_create"),
UserItem: recordSegmentCall(&gotCall, &gotSegment, "user_item"),
AuditLogs: recordCall(&gotCall, "audit_logs"),
})
resp := httptest.NewRecorder()
req := httptest.NewRequest(tt.method, tt.path, nil)
handler.ServeHTTP(resp, req)
if resp.Code != http.StatusNoContent {
t.Fatalf("status = %d, want %d; body=%s", resp.Code, http.StatusNoContent, resp.Body.String())
}
if gotCall != tt.wantCall {
t.Fatalf("call = %q, want %q", gotCall, tt.wantCall)
}
if gotSegment != tt.wantSegment {
t.Fatalf("segment = %q, want %q", gotSegment, tt.wantSegment)
}
if got := resp.Header().Get("Cache-Control"); got != "no-store" {
t.Fatalf("Cache-Control = %q, want no-store", got)
}
})
}
}
func TestNewAPIHandlerWritesNotFound(t *testing.T) {
handler := NewAPIHandler("/admin/api", APIHandlers{})
resp := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/admin/api/auth/login", nil)
handler.ServeHTTP(resp, req)
if resp.Code != http.StatusNotFound {
t.Fatalf("wrong method status = %d, want %d", resp.Code, http.StatusNotFound)
}
if strings.TrimSpace(resp.Body.String()) != `{"error":"not found"}` {
t.Fatalf("wrong method body = %q", resp.Body.String())
}
resp = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodGet, "/admin/api/missing", nil)
handler.ServeHTTP(resp, req)
if resp.Code != http.StatusNotFound {
t.Fatalf("missing status = %d, want %d", resp.Code, http.StatusNotFound)
}
}
func recordCall(got *string, call string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
*got = call
w.WriteHeader(http.StatusNoContent)
}
}
func recordSegmentCall(gotCall, gotSegment *string, call string) SegmentHandlerFunc {
return func(w http.ResponseWriter, r *http.Request, segment string) {
*gotCall = call
*gotSegment = segment
w.WriteHeader(http.StatusNoContent)
}
}

View File

@@ -0,0 +1,107 @@
package adminhttp
import (
"io/fs"
"net/http"
"strings"
)
const (
staticIndexFile = "admin_static/index.html"
staticCSSFile = "admin_static/app.css"
staticJSFile = "admin_static/app.js"
)
type GatewayHandlerOptions struct {
AdminPath string
AdminAPIPrefix string
Assets fs.FS
APIHandler http.HandlerFunc
WebSocketEnabled bool
WebSocketPath string
WebSocketHandler http.HandlerFunc
}
func NewGatewayHandler(opts GatewayHandlerOptions) http.Handler {
mux := http.NewServeMux()
registerAdminHandlers(mux, opts)
if opts.WebSocketEnabled &&
opts.WebSocketHandler != nil &&
!WebSocketPathConflictsWithAdmin(opts.WebSocketPath, opts.AdminPath, opts.AdminAPIPrefix) {
mux.HandleFunc(opts.WebSocketPath, opts.WebSocketHandler)
}
return mux
}
func WebSocketPathConflictsWithAdmin(webSocketPath, adminPath, adminAPIPrefix string) bool {
adminRoot := strings.TrimRight(adminPath, "/")
if webSocketPath == adminPath || webSocketPath == adminRoot {
return true
}
if webSocketPath == adminAPIPrefix || strings.HasPrefix(adminAPIPrefix+"/", webSocketPath+"/") {
return webSocketPath != "/"
}
return false
}
func registerAdminHandlers(mux *http.ServeMux, opts GatewayHandlerOptions) {
adminRoot := strings.TrimRight(opts.AdminPath, "/")
mux.HandleFunc(opts.AdminAPIPrefix, opts.APIHandler)
mux.HandleFunc(opts.AdminAPIPrefix+"/", opts.APIHandler)
if adminRoot != "" {
mux.HandleFunc(adminRoot, func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, opts.AdminPath, http.StatusMovedPermanently)
})
}
mux.HandleFunc(opts.AdminPath, func(w http.ResponseWriter, r *http.Request) {
serveAdminStatic(w, r, opts)
})
}
func serveAdminStatic(w http.ResponseWriter, r *http.Request, opts GatewayHandlerOptions) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
WriteAPIError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
if r.URL.Path == opts.AdminPath {
serveAdminIndex(w, opts)
return
}
rel := strings.TrimPrefix(r.URL.Path, opts.AdminPath)
switch rel {
case "app.css":
serveAdminFile(w, r, opts.Assets, staticCSSFile, "text/css; charset=utf-8")
case "app.js":
serveAdminFile(w, r, opts.Assets, staticJSFile, "application/javascript; charset=utf-8")
default:
http.NotFound(w, r)
}
}
func serveAdminIndex(w http.ResponseWriter, opts GatewayHandlerOptions) {
data, err := fs.ReadFile(opts.Assets, staticIndexFile)
if err != nil {
WriteAPIError(w, http.StatusInternalServerError, err.Error())
return
}
html := strings.ReplaceAll(string(data), "__ADMIN_API_PREFIX__", opts.AdminAPIPrefix)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
_, _ = w.Write([]byte(html))
}
func serveAdminFile(w http.ResponseWriter, r *http.Request, assets fs.FS, name, contentType string) {
data, err := fs.ReadFile(assets, name)
if err != nil {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", contentType)
w.Header().Set("Cache-Control", "no-store")
_, _ = w.Write(data)
}

View File

@@ -0,0 +1,58 @@
package adminhttp
import (
"encoding/json"
"errors"
"net"
"net/http"
"net/url"
"strings"
)
func DecodeJSONRequest(w http.ResponseWriter, r *http.Request, dst any) bool {
defer r.Body.Close()
decoder := json.NewDecoder(r.Body)
decoder.UseNumber()
if err := decoder.Decode(dst); err != nil {
WriteAPIError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
return false
}
return true
}
func WriteJSON(w http.ResponseWriter, status int, value any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(value)
}
func WriteAPIError(w http.ResponseWriter, status int, message string) {
WriteJSON(w, status, map[string]any{
"error": message,
})
}
func PathSegment(raw string) (string, error) {
if raw == "" || strings.Contains(raw, "/") {
return "", errors.New("invalid path segment")
}
value, err := url.PathUnescape(raw)
if err != nil {
return "", err
}
if value == "" || strings.Contains(value, "/") {
return "", errors.New("invalid path segment")
}
return value, nil
}
func RequestSourceIP(r *http.Request) string {
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err == nil {
return host
}
if r.RemoteAddr != "" {
return r.RemoteAddr
}
return "unknown"
}

View File

@@ -0,0 +1,230 @@
package adminhttp
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"testing/fstest"
)
func TestDecodeJSONRequest(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/api", strings.NewReader(`{"port":25565}`))
resp := httptest.NewRecorder()
var body map[string]any
if !DecodeJSONRequest(resp, req, &body) {
t.Fatal("DecodeJSONRequest() = false, want true")
}
port, ok := body["port"].(json.Number)
if !ok {
t.Fatalf("port = %#v, want json.Number", body["port"])
}
if port.String() != "25565" {
t.Fatalf("port = %q, want 25565", port.String())
}
}
func TestDecodeJSONRequestWritesError(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/api", strings.NewReader(`{`))
resp := httptest.NewRecorder()
var body map[string]any
if DecodeJSONRequest(resp, req, &body) {
t.Fatal("DecodeJSONRequest(invalid) = true, want false")
}
if resp.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d", resp.Code, http.StatusBadRequest)
}
var errorBody map[string]string
if err := json.Unmarshal(resp.Body.Bytes(), &errorBody); err != nil {
t.Fatalf("Unmarshal(%q) error = %v", resp.Body.String(), err)
}
if !strings.HasPrefix(errorBody["error"], "invalid JSON: ") {
t.Fatalf("error = %q, want invalid JSON prefix", errorBody["error"])
}
}
func TestWriteJSONAndAPIError(t *testing.T) {
resp := httptest.NewRecorder()
WriteJSON(resp, http.StatusCreated, map[string]any{"ok": true})
if resp.Code != http.StatusCreated {
t.Fatalf("status = %d, want %d", resp.Code, http.StatusCreated)
}
if got := resp.Header().Get("Content-Type"); got != "application/json; charset=utf-8" {
t.Fatalf("Content-Type = %q", got)
}
if strings.TrimSpace(resp.Body.String()) != `{"ok":true}` {
t.Fatalf("body = %q", resp.Body.String())
}
resp = httptest.NewRecorder()
WriteAPIError(resp, http.StatusForbidden, "permission denied")
if resp.Code != http.StatusForbidden {
t.Fatalf("error status = %d, want %d", resp.Code, http.StatusForbidden)
}
if strings.TrimSpace(resp.Body.String()) != `{"error":"permission denied"}` {
t.Fatalf("error body = %q", resp.Body.String())
}
}
func TestPathSegment(t *testing.T) {
tests := map[string]string{
"play.example": "play.example",
"play%2Etest": "play.test",
}
for raw, want := range tests {
t.Run(raw, func(t *testing.T) {
got, err := PathSegment(raw)
if err != nil {
t.Fatalf("PathSegment(%q) error = %v", raw, err)
}
if got != want {
t.Fatalf("PathSegment(%q) = %q, want %q", raw, got, want)
}
})
}
for _, raw := range []string{"", "a/b", "%2F", "%zz"} {
t.Run("invalid "+raw, func(t *testing.T) {
if _, err := PathSegment(raw); err == nil {
t.Fatalf("PathSegment(%q) error = nil, want error", raw)
}
})
}
}
func TestRequestSourceIP(t *testing.T) {
tests := []struct {
remoteAddr string
want string
}{
{"127.0.0.1:1234", "127.0.0.1"},
{"[::1]:1234", "::1"},
{"unix-socket", "unix-socket"},
{"", "unknown"},
}
for _, tt := range tests {
t.Run(tt.remoteAddr, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.RemoteAddr = tt.remoteAddr
if got := RequestSourceIP(req); got != tt.want {
t.Fatalf("RequestSourceIP(%q) = %q, want %q", tt.remoteAddr, got, tt.want)
}
})
}
}
func TestNewGatewayHandlerServesAdminAndAPI(t *testing.T) {
assets := fstest.MapFS{
staticIndexFile: {Data: []byte(`<html data-api-prefix="__ADMIN_API_PREFIX__"></html>`)},
staticCSSFile: {Data: []byte(`body{color:red}`)},
staticJSFile: {Data: []byte(`console.log("admin")`)},
}
apiCalled := false
handler := NewGatewayHandler(GatewayHandlerOptions{
AdminPath: "/ops/",
AdminAPIPrefix: "/ops/api",
Assets: assets,
APIHandler: func(w http.ResponseWriter, r *http.Request) {
apiCalled = true
w.WriteHeader(http.StatusNoContent)
},
})
resp := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/ops", nil)
handler.ServeHTTP(resp, req)
if resp.Code != http.StatusMovedPermanently {
t.Fatalf("redirect status = %d, want %d", resp.Code, http.StatusMovedPermanently)
}
if got := resp.Header().Get("Location"); got != "/ops/" {
t.Fatalf("redirect location = %q, want /ops/", got)
}
resp = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodGet, "/ops/", nil)
handler.ServeHTTP(resp, req)
if resp.Code != http.StatusOK {
t.Fatalf("admin page status = %d, body=%s", resp.Code, resp.Body.String())
}
if !strings.Contains(resp.Body.String(), `data-api-prefix="/ops/api"`) {
t.Fatalf("admin page = %q, want API prefix", resp.Body.String())
}
if got := resp.Header().Get("Cache-Control"); got != "no-store" {
t.Fatalf("admin page Cache-Control = %q, want no-store", got)
}
resp = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodGet, "/ops/app.css", nil)
handler.ServeHTTP(resp, req)
if resp.Code != http.StatusOK || strings.TrimSpace(resp.Body.String()) != `body{color:red}` {
t.Fatalf("css response status=%d body=%q", resp.Code, resp.Body.String())
}
resp = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodPost, "/ops/", nil)
handler.ServeHTTP(resp, req)
if resp.Code != http.StatusMethodNotAllowed {
t.Fatalf("POST admin status = %d, want %d", resp.Code, http.StatusMethodNotAllowed)
}
resp = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodGet, "/ops/api/setup", nil)
handler.ServeHTTP(resp, req)
if resp.Code != http.StatusNoContent {
t.Fatalf("api status = %d, want %d", resp.Code, http.StatusNoContent)
}
if !apiCalled {
t.Fatal("API handler was not called")
}
}
func TestNewGatewayHandlerRegistersWebSocketWhenPathDoesNotConflict(t *testing.T) {
assets := fstest.MapFS{
staticIndexFile: {Data: []byte(``)},
}
handler := NewGatewayHandler(GatewayHandlerOptions{
AdminPath: "/admin/",
AdminAPIPrefix: "/admin/api",
Assets: assets,
APIHandler: func(w http.ResponseWriter, r *http.Request) {},
WebSocketEnabled: true,
WebSocketPath: "/ws",
WebSocketHandler: func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusAccepted)
},
})
resp := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/ws", nil)
handler.ServeHTTP(resp, req)
if resp.Code != http.StatusAccepted {
t.Fatalf("websocket path status = %d, want %d", resp.Code, http.StatusAccepted)
}
}
func TestWebSocketPathConflictsWithAdmin(t *testing.T) {
tests := []struct {
name string
webSocketPath string
want bool
}{
{name: "admin path", webSocketPath: "/admin/", want: true},
{name: "admin root", webSocketPath: "/admin", want: true},
{name: "api prefix", webSocketPath: "/admin/api", want: true},
{name: "api child", webSocketPath: "/admin/api/ws", want: false},
{name: "root", webSocketPath: "/", want: false},
{name: "separate path", webSocketPath: "/ws", want: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := WebSocketPathConflictsWithAdmin(tt.webSocketPath, "/admin/", "/admin/api")
if got != tt.want {
t.Fatalf("WebSocketPathConflictsWithAdmin(%q) = %v, want %v", tt.webSocketPath, got, tt.want)
}
})
}
}

View File

@@ -0,0 +1,36 @@
package adminhttp
type LoginRequest struct {
Username string `json:"username"`
Password string `json:"password"`
}
type SetupRequest struct {
Username string `json:"username"`
Password string `json:"password"`
}
type RouteRequest struct {
Upstream string `json:"upstream"`
Enabled *bool `json:"enabled"`
Note string `json:"note"`
}
type ServiceRequest struct {
Enabled *bool `json:"enabled"`
Port int `json:"port"`
Options map[string]any `json:"options"`
}
type CreateUserRequest struct {
Username string `json:"username"`
Role string `json:"role"`
Password string `json:"password"`
Disabled bool `json:"disabled"`
}
type PatchUserRequest struct {
Role *string `json:"role"`
Password *string `json:"password"`
Disabled *bool `json:"disabled"`
}

View File

@@ -0,0 +1,55 @@
package adminhttp
import (
"encoding/json"
"testing"
)
func TestRouteRequestKeepsOptionalEnabled(t *testing.T) {
var req RouteRequest
if err := json.Unmarshal([]byte(`{"upstream":"127.0.0.1:25565","note":"primary"}`), &req); err != nil {
t.Fatalf("Unmarshal() error = %v", err)
}
if req.Enabled != nil {
t.Fatalf("Enabled = %v, want nil when omitted", *req.Enabled)
}
if err := json.Unmarshal([]byte(`{"enabled":false}`), &req); err != nil {
t.Fatalf("Unmarshal(enabled) error = %v", err)
}
if req.Enabled == nil || *req.Enabled {
t.Fatalf("Enabled = %v, want false pointer", req.Enabled)
}
}
func TestPatchUserRequestKeepsOmittedFieldsNil(t *testing.T) {
var req PatchUserRequest
if err := json.Unmarshal([]byte(`{"role":"guest"}`), &req); err != nil {
t.Fatalf("Unmarshal() error = %v", err)
}
if req.Role == nil || *req.Role != "guest" {
t.Fatalf("Role = %v, want guest pointer", req.Role)
}
if req.Password != nil {
t.Fatal("Password pointer should be nil when omitted")
}
if req.Disabled != nil {
t.Fatal("Disabled pointer should be nil when omitted")
}
}
func TestServiceRequestDecodesOptions(t *testing.T) {
var req ServiceRequest
if err := json.Unmarshal([]byte(`{"enabled":true,"port":25570,"options":{"path":"/ws"}}`), &req); err != nil {
t.Fatalf("Unmarshal() error = %v", err)
}
if req.Enabled == nil || !*req.Enabled {
t.Fatalf("Enabled = %v, want true pointer", req.Enabled)
}
if req.Port != 25570 {
t.Fatalf("Port = %d, want 25570", req.Port)
}
if req.Options["path"] != "/ws" {
t.Fatalf("path option = %#v, want /ws", req.Options["path"])
}
}