From 0b0b5d16d7ac5e1368b2b3e7bdfb11430443b90e Mon Sep 17 00:00:00 2001 From: henrygd Date: Sun, 22 Mar 2026 17:31:06 -0400 Subject: [PATCH] refactor(hub): move api related code from hub.go to api.go --- internal/hub/api.go | 359 +++++++++++++++++++ internal/hub/api_test.go | 729 +++++++++++++++++++++++++++++++++++++++ internal/hub/hub.go | 349 ------------------- internal/hub/hub_test.go | 720 -------------------------------------- 4 files changed, 1088 insertions(+), 1069 deletions(-) create mode 100644 internal/hub/api.go create mode 100644 internal/hub/api_test.go diff --git a/internal/hub/api.go b/internal/hub/api.go new file mode 100644 index 00000000..9b2ec1bb --- /dev/null +++ b/internal/hub/api.go @@ -0,0 +1,359 @@ +package hub + +import ( + "context" + "net/http" + "strings" + "time" + + "github.com/blang/semver" + "github.com/google/uuid" + "github.com/henrygd/beszel" + "github.com/henrygd/beszel/internal/alerts" + "github.com/henrygd/beszel/internal/ghupdate" + "github.com/henrygd/beszel/internal/hub/config" + "github.com/henrygd/beszel/internal/hub/systems" + "github.com/pocketbase/dbx" + "github.com/pocketbase/pocketbase/apis" + "github.com/pocketbase/pocketbase/core" +) + +type UpdateInfo struct { + lastCheck time.Time + Version string `json:"v"` + Url string `json:"url"` +} + +// registerMiddlewares registers custom middlewares +func (h *Hub) registerMiddlewares(se *core.ServeEvent) { + // authorizes request with user matching the provided email + authorizeRequestWithEmail := func(e *core.RequestEvent, email string) (err error) { + if e.Auth != nil || email == "" { + return e.Next() + } + isAuthRefresh := e.Request.URL.Path == "/api/collections/users/auth-refresh" && e.Request.Method == http.MethodPost + e.Auth, err = e.App.FindFirstRecordByData("users", "email", email) + if err != nil || !isAuthRefresh { + return e.Next() + } + // auth refresh endpoint, make sure token is set in header + token, _ := e.Auth.NewAuthToken() + e.Request.Header.Set("Authorization", token) + return e.Next() + } + // authenticate with trusted header + if autoLogin, _ := GetEnv("AUTO_LOGIN"); autoLogin != "" { + se.Router.BindFunc(func(e *core.RequestEvent) error { + return authorizeRequestWithEmail(e, autoLogin) + }) + } + // authenticate with trusted header + if trustedHeader, _ := GetEnv("TRUSTED_AUTH_HEADER"); trustedHeader != "" { + se.Router.BindFunc(func(e *core.RequestEvent) error { + return authorizeRequestWithEmail(e, e.Request.Header.Get(trustedHeader)) + }) + } +} + +// registerApiRoutes registers custom API routes +func (h *Hub) registerApiRoutes(se *core.ServeEvent) error { + // auth protected routes + apiAuth := se.Router.Group("/api/beszel") + apiAuth.Bind(apis.RequireAuth()) + // auth optional routes + apiNoAuth := se.Router.Group("/api/beszel") + + // create first user endpoint only needed if no users exist + if totalUsers, _ := se.App.CountRecords("users"); totalUsers == 0 { + apiNoAuth.POST("/create-user", h.um.CreateFirstUser) + } + // check if first time setup on login page + apiNoAuth.GET("/first-run", func(e *core.RequestEvent) error { + total, err := e.App.CountRecords("users") + return e.JSON(http.StatusOK, map[string]bool{"firstRun": err == nil && total == 0}) + }) + // get public key and version + apiAuth.GET("/info", h.getInfo) + apiAuth.GET("/getkey", h.getInfo) // deprecated - keep for compatibility w/ integrations + // check for updates + if optIn, _ := GetEnv("CHECK_UPDATES"); optIn == "true" { + var updateInfo UpdateInfo + apiAuth.GET("/update", updateInfo.getUpdate) + } + // send test notification + apiAuth.POST("/test-notification", h.SendTestNotification) + // heartbeat status and test + apiAuth.GET("/heartbeat-status", h.getHeartbeatStatus) + apiAuth.POST("/test-heartbeat", h.testHeartbeat) + // get config.yml content + apiAuth.GET("/config-yaml", config.GetYamlConfig) + // handle agent websocket connection + apiNoAuth.GET("/agent-connect", h.handleAgentConnect) + // get or create universal tokens + apiAuth.GET("/universal-token", h.getUniversalToken) + // update / delete user alerts + apiAuth.POST("/user-alerts", alerts.UpsertUserAlerts) + apiAuth.DELETE("/user-alerts", alerts.DeleteUserAlerts) + // refresh SMART devices for a system + apiAuth.POST("/smart/refresh", h.refreshSmartData) + // get systemd service details + apiAuth.GET("/systemd/info", h.getSystemdInfo) + // /containers routes + if enabled, _ := GetEnv("CONTAINER_DETAILS"); enabled != "false" { + // get container logs + apiAuth.GET("/containers/logs", h.getContainerLogs) + // get container info + apiAuth.GET("/containers/info", h.getContainerInfo) + } + return nil +} + +// getInfo returns data needed by authenticated users, such as the public key and current version +func (h *Hub) getInfo(e *core.RequestEvent) error { + type infoResponse struct { + Key string `json:"key"` + Version string `json:"v"` + CheckUpdate bool `json:"cu"` + } + info := infoResponse{ + Key: h.pubKey, + Version: beszel.Version, + } + if optIn, _ := GetEnv("CHECK_UPDATES"); optIn == "true" { + info.CheckUpdate = true + } + return e.JSON(http.StatusOK, info) +} + +func (info *UpdateInfo) getUpdate(e *core.RequestEvent) error { + if time.Since(info.lastCheck) < 6*time.Hour { + return e.JSON(http.StatusOK, info) + } + latestRelease, err := ghupdate.FetchLatestRelease(context.Background(), http.DefaultClient, "") + if err != nil { + return err + } + currentVersion, err := semver.Parse(strings.TrimPrefix(beszel.Version, "v")) + if err != nil { + return err + } + latestVersion, err := semver.Parse(strings.TrimPrefix(latestRelease.Tag, "v")) + if err != nil { + return err + } + info.lastCheck = time.Now() + if latestVersion.GT(currentVersion) { + info.Version = strings.TrimPrefix(latestRelease.Tag, "v") + info.Url = latestRelease.Url + } + return e.JSON(http.StatusOK, info) +} + +// GetUniversalToken handles the universal token API endpoint (create, read, delete) +func (h *Hub) getUniversalToken(e *core.RequestEvent) error { + tokenMap := universalTokenMap.GetMap() + userID := e.Auth.Id + query := e.Request.URL.Query() + token := query.Get("token") + enable := query.Get("enable") + permanent := query.Get("permanent") + + // helper for deleting any existing permanent token record for this user + deletePermanent := func() error { + rec, err := h.FindFirstRecordByFilter("universal_tokens", "user = {:user}", dbx.Params{"user": userID}) + if err != nil { + return nil // no record + } + return h.Delete(rec) + } + + // helper for upserting a permanent token record for this user + upsertPermanent := func(token string) error { + rec, err := h.FindFirstRecordByFilter("universal_tokens", "user = {:user}", dbx.Params{"user": userID}) + if err == nil { + rec.Set("token", token) + return h.Save(rec) + } + + col, err := h.FindCachedCollectionByNameOrId("universal_tokens") + if err != nil { + return err + } + newRec := core.NewRecord(col) + newRec.Set("user", userID) + newRec.Set("token", token) + return h.Save(newRec) + } + + // Disable universal tokens (both ephemeral and permanent) + if enable == "0" { + tokenMap.RemovebyValue(userID) + _ = deletePermanent() + return e.JSON(http.StatusOK, map[string]any{"token": token, "active": false, "permanent": false}) + } + + // Enable universal token (ephemeral or permanent) + if enable == "1" { + if token == "" { + token = uuid.New().String() + } + + if permanent == "1" { + // make token permanent (persist across restarts) + tokenMap.RemovebyValue(userID) + if err := upsertPermanent(token); err != nil { + return err + } + return e.JSON(http.StatusOK, map[string]any{"token": token, "active": true, "permanent": true}) + } + + // default: ephemeral mode (1 hour) + _ = deletePermanent() + tokenMap.Set(token, userID, time.Hour) + return e.JSON(http.StatusOK, map[string]any{"token": token, "active": true, "permanent": false}) + } + + // Read current state + // Prefer permanent token if it exists. + if rec, err := h.FindFirstRecordByFilter("universal_tokens", "user = {:user}", dbx.Params{"user": userID}); err == nil { + dbToken := rec.GetString("token") + // If no token was provided, or the caller is asking about their permanent token, return it. + if token == "" || token == dbToken { + return e.JSON(http.StatusOK, map[string]any{"token": dbToken, "active": true, "permanent": true}) + } + // Token doesn't match their permanent token (avoid leaking other info) + return e.JSON(http.StatusOK, map[string]any{"token": token, "active": false, "permanent": false}) + } + + // No permanent token; fall back to ephemeral token map. + if token == "" { + // return existing token if it exists + if token, _, ok := tokenMap.GetByValue(userID); ok { + return e.JSON(http.StatusOK, map[string]any{"token": token, "active": true, "permanent": false}) + } + // if no token is provided, generate a new one + token = uuid.New().String() + } + + // Token is considered active only if it belongs to the current user. + activeUser, ok := tokenMap.GetOk(token) + active := ok && activeUser == userID + response := map[string]any{"token": token, "active": active, "permanent": false} + return e.JSON(http.StatusOK, response) +} + +// getHeartbeatStatus returns current heartbeat configuration and whether it's enabled +func (h *Hub) getHeartbeatStatus(e *core.RequestEvent) error { + if e.Auth.GetString("role") != "admin" { + return e.ForbiddenError("Requires admin role", nil) + } + if h.hb == nil { + return e.JSON(http.StatusOK, map[string]any{ + "enabled": false, + "msg": "Set HEARTBEAT_URL to enable outbound heartbeat monitoring", + }) + } + cfg := h.hb.GetConfig() + return e.JSON(http.StatusOK, map[string]any{ + "enabled": true, + "url": cfg.URL, + "interval": cfg.Interval, + "method": cfg.Method, + }) +} + +// testHeartbeat triggers a single heartbeat ping and returns the result +func (h *Hub) testHeartbeat(e *core.RequestEvent) error { + if e.Auth.GetString("role") != "admin" { + return e.ForbiddenError("Requires admin role", nil) + } + if h.hb == nil { + return e.JSON(http.StatusOK, map[string]any{ + "err": "Heartbeat not configured. Set HEARTBEAT_URL environment variable.", + }) + } + if err := h.hb.Send(); err != nil { + return e.JSON(http.StatusOK, map[string]any{"err": err.Error()}) + } + return e.JSON(http.StatusOK, map[string]any{"err": false}) +} + +// containerRequestHandler handles both container logs and info requests +func (h *Hub) containerRequestHandler(e *core.RequestEvent, fetchFunc func(*systems.System, string) (string, error), responseKey string) error { + systemID := e.Request.URL.Query().Get("system") + containerID := e.Request.URL.Query().Get("container") + + if systemID == "" || containerID == "" { + return e.JSON(http.StatusBadRequest, map[string]string{"error": "system and container parameters are required"}) + } + if !containerIDPattern.MatchString(containerID) { + return e.JSON(http.StatusBadRequest, map[string]string{"error": "invalid container parameter"}) + } + + system, err := h.sm.GetSystem(systemID) + if err != nil { + return e.JSON(http.StatusNotFound, map[string]string{"error": "system not found"}) + } + + data, err := fetchFunc(system, containerID) + if err != nil { + return e.JSON(http.StatusNotFound, map[string]string{"error": err.Error()}) + } + + return e.JSON(http.StatusOK, map[string]string{responseKey: data}) +} + +// getContainerLogs handles GET /api/beszel/containers/logs requests +func (h *Hub) getContainerLogs(e *core.RequestEvent) error { + return h.containerRequestHandler(e, func(system *systems.System, containerID string) (string, error) { + return system.FetchContainerLogsFromAgent(containerID) + }, "logs") +} + +func (h *Hub) getContainerInfo(e *core.RequestEvent) error { + return h.containerRequestHandler(e, func(system *systems.System, containerID string) (string, error) { + return system.FetchContainerInfoFromAgent(containerID) + }, "info") +} + +// getSystemdInfo handles GET /api/beszel/systemd/info requests +func (h *Hub) getSystemdInfo(e *core.RequestEvent) error { + query := e.Request.URL.Query() + systemID := query.Get("system") + serviceName := query.Get("service") + + if systemID == "" || serviceName == "" { + return e.JSON(http.StatusBadRequest, map[string]string{"error": "system and service parameters are required"}) + } + system, err := h.sm.GetSystem(systemID) + if err != nil { + return e.JSON(http.StatusNotFound, map[string]string{"error": "system not found"}) + } + details, err := system.FetchSystemdInfoFromAgent(serviceName) + if err != nil { + return e.JSON(http.StatusNotFound, map[string]string{"error": err.Error()}) + } + e.Response.Header().Set("Cache-Control", "public, max-age=60") + return e.JSON(http.StatusOK, map[string]any{"details": details}) +} + +// refreshSmartData handles POST /api/beszel/smart/refresh requests +// Fetches fresh SMART data from the agent and updates the collection +func (h *Hub) refreshSmartData(e *core.RequestEvent) error { + systemID := e.Request.URL.Query().Get("system") + if systemID == "" { + return e.JSON(http.StatusBadRequest, map[string]string{"error": "system parameter is required"}) + } + + system, err := h.sm.GetSystem(systemID) + if err != nil { + return e.JSON(http.StatusNotFound, map[string]string{"error": "system not found"}) + } + + // Fetch and save SMART devices + if err := system.FetchAndSaveSmartDevices(); err != nil { + return e.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) + } + + return e.JSON(http.StatusOK, map[string]string{"status": "ok"}) +} diff --git a/internal/hub/api_test.go b/internal/hub/api_test.go new file mode 100644 index 00000000..59c83dc8 --- /dev/null +++ b/internal/hub/api_test.go @@ -0,0 +1,729 @@ +package hub_test + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "testing" + + beszelTests "github.com/henrygd/beszel/internal/tests" + + "github.com/henrygd/beszel/internal/migrations" + "github.com/pocketbase/pocketbase/core" + pbTests "github.com/pocketbase/pocketbase/tests" + "github.com/stretchr/testify/require" +) + +// marshal to json and return an io.Reader (for use in ApiScenario.Body) +func jsonReader(v any) io.Reader { + data, err := json.Marshal(v) + if err != nil { + panic(err) + } + return bytes.NewReader(data) +} + +func TestApiRoutesAuthentication(t *testing.T) { + hub, _ := beszelTests.NewTestHub(t.TempDir()) + defer hub.Cleanup() + + hub.StartHub() + + // Create test user and get auth token + user, err := beszelTests.CreateUser(hub, "testuser@example.com", "password123") + require.NoError(t, err, "Failed to create test user") + + adminUser, err := beszelTests.CreateRecord(hub, "users", map[string]any{ + "email": "admin@example.com", + "password": "password123", + "role": "admin", + }) + require.NoError(t, err, "Failed to create admin user") + adminUserToken, err := adminUser.NewAuthToken() + + // superUser, err := beszelTests.CreateRecord(hub, core.CollectionNameSuperusers, map[string]any{ + // "email": "superuser@example.com", + // "password": "password123", + // }) + // require.NoError(t, err, "Failed to create superuser") + + userToken, err := user.NewAuthToken() + require.NoError(t, err, "Failed to create auth token") + + // Create test system for user-alerts endpoints + system, err := beszelTests.CreateRecord(hub, "systems", map[string]any{ + "name": "test-system", + "users": []string{user.Id}, + "host": "127.0.0.1", + }) + require.NoError(t, err, "Failed to create test system") + + testAppFactory := func(t testing.TB) *pbTests.TestApp { + return hub.TestApp + } + + scenarios := []beszelTests.ApiScenario{ + // Auth Protected Routes - Should require authentication + { + Name: "POST /test-notification - no auth should fail", + Method: http.MethodPost, + URL: "/api/beszel/test-notification", + ExpectedStatus: 401, + ExpectedContent: []string{"requires valid"}, + TestAppFactory: testAppFactory, + Body: jsonReader(map[string]any{ + "url": "generic://127.0.0.1", + }), + }, + { + Name: "POST /test-notification - with auth should succeed", + Method: http.MethodPost, + URL: "/api/beszel/test-notification", + TestAppFactory: testAppFactory, + Headers: map[string]string{ + "Authorization": userToken, + }, + Body: jsonReader(map[string]any{ + "url": "generic://127.0.0.1", + }), + ExpectedStatus: 200, + ExpectedContent: []string{"sending message"}, + }, + { + Name: "GET /config-yaml - no auth should fail", + Method: http.MethodGet, + URL: "/api/beszel/config-yaml", + ExpectedStatus: 401, + ExpectedContent: []string{"requires valid"}, + TestAppFactory: testAppFactory, + }, + { + Name: "GET /config-yaml - with user auth should fail", + Method: http.MethodGet, + URL: "/api/beszel/config-yaml", + Headers: map[string]string{ + "Authorization": userToken, + }, + ExpectedStatus: 403, + ExpectedContent: []string{"Requires admin"}, + TestAppFactory: testAppFactory, + }, + { + Name: "GET /config-yaml - with admin auth should succeed", + Method: http.MethodGet, + URL: "/api/beszel/config-yaml", + Headers: map[string]string{ + "Authorization": adminUserToken, + }, + ExpectedStatus: 200, + ExpectedContent: []string{"test-system"}, + TestAppFactory: testAppFactory, + }, + { + Name: "GET /heartbeat-status - no auth should fail", + Method: http.MethodGet, + URL: "/api/beszel/heartbeat-status", + ExpectedStatus: 401, + ExpectedContent: []string{"requires valid"}, + TestAppFactory: testAppFactory, + }, + { + Name: "GET /heartbeat-status - with user auth should fail", + Method: http.MethodGet, + URL: "/api/beszel/heartbeat-status", + Headers: map[string]string{ + "Authorization": userToken, + }, + ExpectedStatus: 403, + ExpectedContent: []string{"Requires admin role"}, + TestAppFactory: testAppFactory, + }, + { + Name: "GET /heartbeat-status - with admin auth should succeed", + Method: http.MethodGet, + URL: "/api/beszel/heartbeat-status", + Headers: map[string]string{ + "Authorization": adminUserToken, + }, + ExpectedStatus: 200, + ExpectedContent: []string{`"enabled":false`}, + TestAppFactory: testAppFactory, + }, + { + Name: "POST /test-heartbeat - with user auth should fail", + Method: http.MethodPost, + URL: "/api/beszel/test-heartbeat", + Headers: map[string]string{ + "Authorization": userToken, + }, + ExpectedStatus: 403, + ExpectedContent: []string{"Requires admin role"}, + TestAppFactory: testAppFactory, + }, + { + Name: "POST /test-heartbeat - with admin auth should report disabled state", + Method: http.MethodPost, + URL: "/api/beszel/test-heartbeat", + Headers: map[string]string{ + "Authorization": adminUserToken, + }, + ExpectedStatus: 200, + ExpectedContent: []string{"Heartbeat not configured"}, + TestAppFactory: testAppFactory, + }, + { + Name: "GET /universal-token - no auth should fail", + Method: http.MethodGet, + URL: "/api/beszel/universal-token", + ExpectedStatus: 401, + ExpectedContent: []string{"requires valid"}, + TestAppFactory: testAppFactory, + }, + { + Name: "GET /universal-token - with auth should succeed", + Method: http.MethodGet, + URL: "/api/beszel/universal-token", + Headers: map[string]string{ + "Authorization": userToken, + }, + ExpectedStatus: 200, + ExpectedContent: []string{"active", "token", "permanent"}, + TestAppFactory: testAppFactory, + }, + { + Name: "GET /universal-token - enable permanent should succeed", + Method: http.MethodGet, + URL: "/api/beszel/universal-token?enable=1&permanent=1&token=permanent-token-123", + Headers: map[string]string{ + "Authorization": userToken, + }, + ExpectedStatus: 200, + ExpectedContent: []string{"\"permanent\":true", "permanent-token-123"}, + TestAppFactory: testAppFactory, + }, + { + Name: "POST /user-alerts - no auth should fail", + Method: http.MethodPost, + URL: "/api/beszel/user-alerts", + ExpectedStatus: 401, + ExpectedContent: []string{"requires valid"}, + TestAppFactory: testAppFactory, + Body: jsonReader(map[string]any{ + "name": "CPU", + "value": 80, + "min": 10, + "systems": []string{system.Id}, + }), + }, + { + Name: "POST /user-alerts - with auth should succeed", + Method: http.MethodPost, + URL: "/api/beszel/user-alerts", + Headers: map[string]string{ + "Authorization": userToken, + }, + ExpectedStatus: 200, + ExpectedContent: []string{"\"success\":true"}, + TestAppFactory: testAppFactory, + Body: jsonReader(map[string]any{ + "name": "CPU", + "value": 80, + "min": 10, + "systems": []string{system.Id}, + }), + }, + { + Name: "DELETE /user-alerts - no auth should fail", + Method: http.MethodDelete, + URL: "/api/beszel/user-alerts", + ExpectedStatus: 401, + ExpectedContent: []string{"requires valid"}, + TestAppFactory: testAppFactory, + Body: jsonReader(map[string]any{ + "name": "CPU", + "systems": []string{system.Id}, + }), + }, + { + Name: "DELETE /user-alerts - with auth should succeed", + Method: http.MethodDelete, + URL: "/api/beszel/user-alerts", + Headers: map[string]string{ + "Authorization": userToken, + }, + ExpectedStatus: 200, + ExpectedContent: []string{"\"success\":true"}, + TestAppFactory: testAppFactory, + Body: jsonReader(map[string]any{ + "name": "CPU", + "systems": []string{system.Id}, + }), + BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) { + // Create an alert to delete + beszelTests.CreateRecord(app, "alerts", map[string]any{ + "name": "CPU", + "system": system.Id, + "user": user.Id, + "value": 80, + "min": 10, + }) + }, + }, + { + Name: "GET /containers/logs - no auth should fail", + Method: http.MethodGet, + URL: "/api/beszel/containers/logs?system=test-system&container=test-container", + ExpectedStatus: 401, + ExpectedContent: []string{"requires valid"}, + TestAppFactory: testAppFactory, + }, + { + Name: "GET /containers/logs - with auth but missing system param should fail", + Method: http.MethodGet, + URL: "/api/beszel/containers/logs?container=test-container", + Headers: map[string]string{ + "Authorization": userToken, + }, + ExpectedStatus: 400, + ExpectedContent: []string{"system and container parameters are required"}, + TestAppFactory: testAppFactory, + }, + { + Name: "GET /containers/logs - with auth but missing container param should fail", + Method: http.MethodGet, + URL: "/api/beszel/containers/logs?system=test-system", + Headers: map[string]string{ + "Authorization": userToken, + }, + ExpectedStatus: 400, + ExpectedContent: []string{"system and container parameters are required"}, + TestAppFactory: testAppFactory, + }, + { + Name: "GET /containers/logs - with auth but invalid system should fail", + Method: http.MethodGet, + URL: "/api/beszel/containers/logs?system=invalid-system&container=0123456789ab", + Headers: map[string]string{ + "Authorization": userToken, + }, + ExpectedStatus: 404, + ExpectedContent: []string{"system not found"}, + TestAppFactory: testAppFactory, + }, + { + Name: "GET /containers/logs - traversal container should fail validation", + Method: http.MethodGet, + URL: "/api/beszel/containers/logs?system=" + system.Id + "&container=..%2F..%2Fversion", + Headers: map[string]string{ + "Authorization": userToken, + }, + ExpectedStatus: 400, + ExpectedContent: []string{"invalid container parameter"}, + TestAppFactory: testAppFactory, + }, + { + Name: "GET /containers/info - traversal container should fail validation", + Method: http.MethodGet, + URL: "/api/beszel/containers/info?system=" + system.Id + "&container=../../version?x=", + Headers: map[string]string{ + "Authorization": userToken, + }, + ExpectedStatus: 400, + ExpectedContent: []string{"invalid container parameter"}, + TestAppFactory: testAppFactory, + }, + { + Name: "GET /containers/info - non-hex container should fail validation", + Method: http.MethodGet, + URL: "/api/beszel/containers/info?system=" + system.Id + "&container=container_name", + Headers: map[string]string{ + "Authorization": userToken, + }, + ExpectedStatus: 400, + ExpectedContent: []string{"invalid container parameter"}, + TestAppFactory: testAppFactory, + }, + + // Auth Optional Routes - Should work without authentication + { + Name: "GET /getkey - no auth should fail", + Method: http.MethodGet, + URL: "/api/beszel/getkey", + ExpectedStatus: 401, + ExpectedContent: []string{"requires valid"}, + TestAppFactory: testAppFactory, + }, + { + Name: "GET /getkey - with auth should also succeed", + Method: http.MethodGet, + URL: "/api/beszel/getkey", + Headers: map[string]string{ + "Authorization": userToken, + }, + ExpectedStatus: 200, + ExpectedContent: []string{"\"key\":", "\"v\":"}, + TestAppFactory: testAppFactory, + }, + { + Name: "GET /info - should return the same as /getkey", + Method: http.MethodGet, + URL: "/api/beszel/info", + Headers: map[string]string{ + "Authorization": userToken, + }, + ExpectedStatus: 200, + ExpectedContent: []string{"\"key\":", "\"v\":"}, + TestAppFactory: testAppFactory, + }, + { + Name: "GET /first-run - no auth should succeed", + Method: http.MethodGet, + URL: "/api/beszel/first-run", + ExpectedStatus: 200, + ExpectedContent: []string{"\"firstRun\":false"}, + TestAppFactory: testAppFactory, + }, + { + Name: "GET /first-run - with auth should also succeed", + Method: http.MethodGet, + URL: "/api/beszel/first-run", + Headers: map[string]string{ + "Authorization": userToken, + }, + ExpectedStatus: 200, + ExpectedContent: []string{"\"firstRun\":false"}, + TestAppFactory: testAppFactory, + }, + { + Name: "GET /agent-connect - no auth should succeed (websocket upgrade fails but route is accessible)", + Method: http.MethodGet, + URL: "/api/beszel/agent-connect", + ExpectedStatus: 400, + ExpectedContent: []string{}, + TestAppFactory: testAppFactory, + }, + { + Name: "POST /test-notification - invalid auth token should fail", + Method: http.MethodPost, + URL: "/api/beszel/test-notification", + Body: jsonReader(map[string]any{ + "url": "generic://127.0.0.1", + }), + Headers: map[string]string{ + "Authorization": "invalid-token", + }, + ExpectedStatus: 401, + ExpectedContent: []string{"requires valid"}, + TestAppFactory: testAppFactory, + }, + { + Name: "POST /user-alerts - invalid auth token should fail", + Method: http.MethodPost, + URL: "/api/beszel/user-alerts", + Headers: map[string]string{ + "Authorization": "invalid-token", + }, + ExpectedStatus: 401, + ExpectedContent: []string{"requires valid"}, + TestAppFactory: testAppFactory, + Body: jsonReader(map[string]any{ + "name": "CPU", + "value": 80, + "min": 10, + "systems": []string{system.Id}, + }), + }, + } + + for _, scenario := range scenarios { + scenario.Test(t) + } +} + +func TestFirstUserCreation(t *testing.T) { + t.Run("CreateUserEndpoint available when no users exist", func(t *testing.T) { + hub, _ := beszelTests.NewTestHub(t.TempDir()) + defer hub.Cleanup() + + hub.StartHub() + + testAppFactoryExisting := func(t testing.TB) *pbTests.TestApp { + return hub.TestApp + } + + scenarios := []beszelTests.ApiScenario{ + { + Name: "POST /create-user - should be available when no users exist", + Method: http.MethodPost, + URL: "/api/beszel/create-user", + Body: jsonReader(map[string]any{ + "email": "firstuser@example.com", + "password": "password123", + }), + ExpectedStatus: 200, + ExpectedContent: []string{"User created"}, + TestAppFactory: testAppFactoryExisting, + BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) { + userCount, err := hub.CountRecords("users") + require.NoError(t, err) + require.Zero(t, userCount, "Should start with no users") + superusers, err := hub.FindAllRecords(core.CollectionNameSuperusers) + require.NoError(t, err) + require.EqualValues(t, 1, len(superusers), "Should start with one temporary superuser") + require.EqualValues(t, migrations.TempAdminEmail, superusers[0].GetString("email"), "Should have created one temporary superuser") + }, + AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) { + userCount, err := hub.CountRecords("users") + require.NoError(t, err) + require.EqualValues(t, 1, userCount, "Should have created one user") + superusers, err := hub.FindAllRecords(core.CollectionNameSuperusers) + require.NoError(t, err) + require.EqualValues(t, 1, len(superusers), "Should have created one superuser") + require.EqualValues(t, "firstuser@example.com", superusers[0].GetString("email"), "Should have created one superuser") + }, + }, + { + Name: "POST /create-user - should not be available when users exist", + Method: http.MethodPost, + URL: "/api/beszel/create-user", + Body: jsonReader(map[string]any{ + "email": "firstuser@example.com", + "password": "password123", + }), + ExpectedStatus: 404, + ExpectedContent: []string{"wasn't found"}, + TestAppFactory: testAppFactoryExisting, + }, + } + + for _, scenario := range scenarios { + scenario.Test(t) + } + }) + + t.Run("CreateUserEndpoint not available when USER_EMAIL, USER_PASSWORD are set", func(t *testing.T) { + t.Setenv("BESZEL_HUB_USER_EMAIL", "me@example.com") + t.Setenv("BESZEL_HUB_USER_PASSWORD", "password123") + + hub, _ := beszelTests.NewTestHub(t.TempDir()) + defer hub.Cleanup() + + hub.StartHub() + + testAppFactory := func(t testing.TB) *pbTests.TestApp { + return hub.TestApp + } + + scenario := beszelTests.ApiScenario{ + Name: "POST /create-user - should not be available when USER_EMAIL, USER_PASSWORD are set", + Method: http.MethodPost, + URL: "/api/beszel/create-user", + ExpectedStatus: 404, + ExpectedContent: []string{"wasn't found"}, + TestAppFactory: testAppFactory, + BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) { + users, err := hub.FindAllRecords("users") + require.NoError(t, err) + require.EqualValues(t, 1, len(users), "Should start with one user") + require.EqualValues(t, "me@example.com", users[0].GetString("email"), "Should have created one user") + superusers, err := hub.FindAllRecords(core.CollectionNameSuperusers) + require.NoError(t, err) + require.EqualValues(t, 1, len(superusers), "Should start with one superuser") + require.EqualValues(t, "me@example.com", superusers[0].GetString("email"), "Should have created one superuser") + }, + AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) { + users, err := hub.FindAllRecords("users") + require.NoError(t, err) + require.EqualValues(t, 1, len(users), "Should still have one user") + require.EqualValues(t, "me@example.com", users[0].GetString("email"), "Should have created one user") + superusers, err := hub.FindAllRecords(core.CollectionNameSuperusers) + require.NoError(t, err) + require.EqualValues(t, 1, len(superusers), "Should still have one superuser") + require.EqualValues(t, "me@example.com", superusers[0].GetString("email"), "Should have created one superuser") + }, + } + + scenario.Test(t) + }) +} + +func TestCreateUserEndpointAvailability(t *testing.T) { + t.Run("CreateUserEndpoint available when no users exist", func(t *testing.T) { + hub, _ := beszelTests.NewTestHub(t.TempDir()) + defer hub.Cleanup() + + // Ensure no users exist + userCount, err := hub.CountRecords("users") + require.NoError(t, err) + require.Zero(t, userCount, "Should start with no users") + + hub.StartHub() + + testAppFactory := func(t testing.TB) *pbTests.TestApp { + return hub.TestApp + } + + scenario := beszelTests.ApiScenario{ + Name: "POST /create-user - should be available when no users exist", + Method: http.MethodPost, + URL: "/api/beszel/create-user", + Body: jsonReader(map[string]any{ + "email": "firstuser@example.com", + "password": "password123", + }), + ExpectedStatus: 200, + ExpectedContent: []string{"User created"}, + TestAppFactory: testAppFactory, + } + + scenario.Test(t) + + // Verify user was created + userCount, err = hub.CountRecords("users") + require.NoError(t, err) + require.EqualValues(t, 1, userCount, "Should have created one user") + }) + + t.Run("CreateUserEndpoint not available when users exist", func(t *testing.T) { + hub, _ := beszelTests.NewTestHub(t.TempDir()) + defer hub.Cleanup() + + // Create a user first + _, err := beszelTests.CreateUser(hub, "existing@example.com", "password") + require.NoError(t, err) + + hub.StartHub() + + testAppFactory := func(t testing.TB) *pbTests.TestApp { + return hub.TestApp + } + + scenario := beszelTests.ApiScenario{ + Name: "POST /create-user - should not be available when users exist", + Method: http.MethodPost, + URL: "/api/beszel/create-user", + Body: jsonReader(map[string]any{ + "email": "another@example.com", + "password": "password123", + }), + ExpectedStatus: 404, + ExpectedContent: []string{"wasn't found"}, + TestAppFactory: testAppFactory, + } + + scenario.Test(t) + }) +} + +func TestAutoLoginMiddleware(t *testing.T) { + var hubs []*beszelTests.TestHub + + defer func() { + for _, hub := range hubs { + hub.Cleanup() + } + }() + + t.Setenv("AUTO_LOGIN", "user@test.com") + + testAppFactory := func(t testing.TB) *pbTests.TestApp { + hub, _ := beszelTests.NewTestHub(t.TempDir()) + hubs = append(hubs, hub) + hub.StartHub() + return hub.TestApp + } + + scenarios := []beszelTests.ApiScenario{ + { + Name: "GET /getkey - without auto login should fail", + Method: http.MethodGet, + URL: "/api/beszel/getkey", + ExpectedStatus: 401, + ExpectedContent: []string{"requires valid"}, + TestAppFactory: testAppFactory, + }, + { + Name: "GET /getkey - with auto login should fail if no matching user", + Method: http.MethodGet, + URL: "/api/beszel/getkey", + ExpectedStatus: 401, + ExpectedContent: []string{"requires valid"}, + TestAppFactory: testAppFactory, + }, + { + Name: "GET /getkey - with auto login should succeed", + Method: http.MethodGet, + URL: "/api/beszel/getkey", + ExpectedStatus: 200, + ExpectedContent: []string{"\"key\":", "\"v\":"}, + TestAppFactory: testAppFactory, + BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) { + beszelTests.CreateUser(app, "user@test.com", "password123") + }, + }, + } + + for _, scenario := range scenarios { + scenario.Test(t) + } +} + +func TestTrustedHeaderMiddleware(t *testing.T) { + var hubs []*beszelTests.TestHub + + defer func() { + for _, hub := range hubs { + hub.Cleanup() + } + }() + + t.Setenv("TRUSTED_AUTH_HEADER", "X-Beszel-Trusted") + + testAppFactory := func(t testing.TB) *pbTests.TestApp { + hub, _ := beszelTests.NewTestHub(t.TempDir()) + hubs = append(hubs, hub) + hub.StartHub() + return hub.TestApp + } + + scenarios := []beszelTests.ApiScenario{ + { + Name: "GET /getkey - without trusted header should fail", + Method: http.MethodGet, + URL: "/api/beszel/getkey", + ExpectedStatus: 401, + ExpectedContent: []string{"requires valid"}, + TestAppFactory: testAppFactory, + }, + { + Name: "GET /getkey - with trusted header should fail if no matching user", + Method: http.MethodGet, + URL: "/api/beszel/getkey", + Headers: map[string]string{ + "X-Beszel-Trusted": "user@test.com", + }, + ExpectedStatus: 401, + ExpectedContent: []string{"requires valid"}, + TestAppFactory: testAppFactory, + }, + { + Name: "GET /getkey - with trusted header should succeed", + Method: http.MethodGet, + URL: "/api/beszel/getkey", + Headers: map[string]string{ + "X-Beszel-Trusted": "user@test.com", + }, + ExpectedStatus: 200, + ExpectedContent: []string{"\"key\":", "\"v\":"}, + TestAppFactory: testAppFactory, + BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) { + beszelTests.CreateUser(app, "user@test.com", "password123") + }, + }, + } + + for _, scenario := range scenarios { + scenario.Test(t) + } +} diff --git a/internal/hub/hub.go b/internal/hub/hub.go index 5a9761da..0c5c222e 100644 --- a/internal/hub/hub.go +++ b/internal/hub/hub.go @@ -2,33 +2,24 @@ package hub import ( - "context" "crypto/ed25519" "encoding/pem" "errors" "fmt" - "net/http" "net/url" "os" "path" "regexp" "strings" - "time" - "github.com/blang/semver" - "github.com/henrygd/beszel" "github.com/henrygd/beszel/internal/alerts" - "github.com/henrygd/beszel/internal/ghupdate" "github.com/henrygd/beszel/internal/hub/config" "github.com/henrygd/beszel/internal/hub/heartbeat" "github.com/henrygd/beszel/internal/hub/systems" "github.com/henrygd/beszel/internal/records" "github.com/henrygd/beszel/internal/users" - "github.com/google/uuid" - "github.com/pocketbase/dbx" "github.com/pocketbase/pocketbase" - "github.com/pocketbase/pocketbase/apis" "github.com/pocketbase/pocketbase/core" "golang.org/x/crypto/ssh" ) @@ -163,346 +154,6 @@ func (h *Hub) registerCronJobs(_ *core.ServeEvent) error { return nil } -// registerMiddlewares registers custom middlewares -func (h *Hub) registerMiddlewares(se *core.ServeEvent) { - // authorizes request with user matching the provided email - authorizeRequestWithEmail := func(e *core.RequestEvent, email string) (err error) { - if e.Auth != nil || email == "" { - return e.Next() - } - isAuthRefresh := e.Request.URL.Path == "/api/collections/users/auth-refresh" && e.Request.Method == http.MethodPost - e.Auth, err = e.App.FindFirstRecordByData("users", "email", email) - if err != nil || !isAuthRefresh { - return e.Next() - } - // auth refresh endpoint, make sure token is set in header - token, _ := e.Auth.NewAuthToken() - e.Request.Header.Set("Authorization", token) - return e.Next() - } - // authenticate with trusted header - if autoLogin, _ := GetEnv("AUTO_LOGIN"); autoLogin != "" { - se.Router.BindFunc(func(e *core.RequestEvent) error { - return authorizeRequestWithEmail(e, autoLogin) - }) - } - // authenticate with trusted header - if trustedHeader, _ := GetEnv("TRUSTED_AUTH_HEADER"); trustedHeader != "" { - se.Router.BindFunc(func(e *core.RequestEvent) error { - return authorizeRequestWithEmail(e, e.Request.Header.Get(trustedHeader)) - }) - } -} - -type UpdateInfo struct { - lastCheck time.Time - Version string `json:"v"` - Url string `json:"url"` -} - -func (info *UpdateInfo) getUpdate(e *core.RequestEvent) error { - if time.Since(info.lastCheck) < 6*time.Hour { - return e.JSON(http.StatusOK, info) - } - latestRelease, err := ghupdate.FetchLatestRelease(context.Background(), http.DefaultClient, "") - if err != nil { - return err - } - currentVersion, err := semver.Parse(strings.TrimPrefix(beszel.Version, "v")) - if err != nil { - return err - } - latestVersion, err := semver.Parse(strings.TrimPrefix(latestRelease.Tag, "v")) - if err != nil { - return err - } - info.lastCheck = time.Now() - if latestVersion.GT(currentVersion) { - info.Version = strings.TrimPrefix(latestRelease.Tag, "v") - info.Url = latestRelease.Url - } - return e.JSON(http.StatusOK, info) -} - -// registerApiRoutes registers custom API routes -func (h *Hub) registerApiRoutes(se *core.ServeEvent) error { - // auth protected routes - apiAuth := se.Router.Group("/api/beszel") - apiAuth.Bind(apis.RequireAuth()) - // auth optional routes - apiNoAuth := se.Router.Group("/api/beszel") - - // create first user endpoint only needed if no users exist - if totalUsers, _ := se.App.CountRecords("users"); totalUsers == 0 { - apiNoAuth.POST("/create-user", h.um.CreateFirstUser) - } - // check if first time setup on login page - apiNoAuth.GET("/first-run", func(e *core.RequestEvent) error { - total, err := e.App.CountRecords("users") - return e.JSON(http.StatusOK, map[string]bool{"firstRun": err == nil && total == 0}) - }) - // get public key and version - apiAuth.GET("/info", h.getInfo) - apiAuth.GET("/getkey", h.getInfo) // deprecated - keep for compatibility w/ integrations - // check for updates - if optIn, _ := GetEnv("CHECK_UPDATES"); optIn == "true" { - var updateInfo UpdateInfo - apiAuth.GET("/update", updateInfo.getUpdate) - } - // send test notification - apiAuth.POST("/test-notification", h.SendTestNotification) - // heartbeat status and test - apiAuth.GET("/heartbeat-status", h.getHeartbeatStatus) - apiAuth.POST("/test-heartbeat", h.testHeartbeat) - // get config.yml content - apiAuth.GET("/config-yaml", config.GetYamlConfig) - // handle agent websocket connection - apiNoAuth.GET("/agent-connect", h.handleAgentConnect) - // get or create universal tokens - apiAuth.GET("/universal-token", h.getUniversalToken) - // update / delete user alerts - apiAuth.POST("/user-alerts", alerts.UpsertUserAlerts) - apiAuth.DELETE("/user-alerts", alerts.DeleteUserAlerts) - // refresh SMART devices for a system - apiAuth.POST("/smart/refresh", h.refreshSmartData) - // get systemd service details - apiAuth.GET("/systemd/info", h.getSystemdInfo) - // /containers routes - if enabled, _ := GetEnv("CONTAINER_DETAILS"); enabled != "false" { - // get container logs - apiAuth.GET("/containers/logs", h.getContainerLogs) - // get container info - apiAuth.GET("/containers/info", h.getContainerInfo) - } - return nil -} - -// getInfo returns data needed by authenticated users, such as the public key and current version -func (h *Hub) getInfo(e *core.RequestEvent) error { - type infoResponse struct { - Key string `json:"key"` - Version string `json:"v"` - CheckUpdate bool `json:"cu"` - } - info := infoResponse{ - Key: h.pubKey, - Version: beszel.Version, - } - if optIn, _ := GetEnv("CHECK_UPDATES"); optIn == "true" { - info.CheckUpdate = true - } - return e.JSON(http.StatusOK, info) -} - -// GetUniversalToken handles the universal token API endpoint (create, read, delete) -func (h *Hub) getUniversalToken(e *core.RequestEvent) error { - tokenMap := universalTokenMap.GetMap() - userID := e.Auth.Id - query := e.Request.URL.Query() - token := query.Get("token") - enable := query.Get("enable") - permanent := query.Get("permanent") - - // helper for deleting any existing permanent token record for this user - deletePermanent := func() error { - rec, err := h.FindFirstRecordByFilter("universal_tokens", "user = {:user}", dbx.Params{"user": userID}) - if err != nil { - return nil // no record - } - return h.Delete(rec) - } - - // helper for upserting a permanent token record for this user - upsertPermanent := func(token string) error { - rec, err := h.FindFirstRecordByFilter("universal_tokens", "user = {:user}", dbx.Params{"user": userID}) - if err == nil { - rec.Set("token", token) - return h.Save(rec) - } - - col, err := h.FindCachedCollectionByNameOrId("universal_tokens") - if err != nil { - return err - } - newRec := core.NewRecord(col) - newRec.Set("user", userID) - newRec.Set("token", token) - return h.Save(newRec) - } - - // Disable universal tokens (both ephemeral and permanent) - if enable == "0" { - tokenMap.RemovebyValue(userID) - _ = deletePermanent() - return e.JSON(http.StatusOK, map[string]any{"token": token, "active": false, "permanent": false}) - } - - // Enable universal token (ephemeral or permanent) - if enable == "1" { - if token == "" { - token = uuid.New().String() - } - - if permanent == "1" { - // make token permanent (persist across restarts) - tokenMap.RemovebyValue(userID) - if err := upsertPermanent(token); err != nil { - return err - } - return e.JSON(http.StatusOK, map[string]any{"token": token, "active": true, "permanent": true}) - } - - // default: ephemeral mode (1 hour) - _ = deletePermanent() - tokenMap.Set(token, userID, time.Hour) - return e.JSON(http.StatusOK, map[string]any{"token": token, "active": true, "permanent": false}) - } - - // Read current state - // Prefer permanent token if it exists. - if rec, err := h.FindFirstRecordByFilter("universal_tokens", "user = {:user}", dbx.Params{"user": userID}); err == nil { - dbToken := rec.GetString("token") - // If no token was provided, or the caller is asking about their permanent token, return it. - if token == "" || token == dbToken { - return e.JSON(http.StatusOK, map[string]any{"token": dbToken, "active": true, "permanent": true}) - } - // Token doesn't match their permanent token (avoid leaking other info) - return e.JSON(http.StatusOK, map[string]any{"token": token, "active": false, "permanent": false}) - } - - // No permanent token; fall back to ephemeral token map. - if token == "" { - // return existing token if it exists - if token, _, ok := tokenMap.GetByValue(userID); ok { - return e.JSON(http.StatusOK, map[string]any{"token": token, "active": true, "permanent": false}) - } - // if no token is provided, generate a new one - token = uuid.New().String() - } - - // Token is considered active only if it belongs to the current user. - activeUser, ok := tokenMap.GetOk(token) - active := ok && activeUser == userID - response := map[string]any{"token": token, "active": active, "permanent": false} - return e.JSON(http.StatusOK, response) -} - -// getHeartbeatStatus returns current heartbeat configuration and whether it's enabled -func (h *Hub) getHeartbeatStatus(e *core.RequestEvent) error { - if e.Auth.GetString("role") != "admin" { - return e.ForbiddenError("Requires admin role", nil) - } - if h.hb == nil { - return e.JSON(http.StatusOK, map[string]any{ - "enabled": false, - "msg": "Set HEARTBEAT_URL to enable outbound heartbeat monitoring", - }) - } - cfg := h.hb.GetConfig() - return e.JSON(http.StatusOK, map[string]any{ - "enabled": true, - "url": cfg.URL, - "interval": cfg.Interval, - "method": cfg.Method, - }) -} - -// testHeartbeat triggers a single heartbeat ping and returns the result -func (h *Hub) testHeartbeat(e *core.RequestEvent) error { - if e.Auth.GetString("role") != "admin" { - return e.ForbiddenError("Requires admin role", nil) - } - if h.hb == nil { - return e.JSON(http.StatusOK, map[string]any{ - "err": "Heartbeat not configured. Set HEARTBEAT_URL environment variable.", - }) - } - if err := h.hb.Send(); err != nil { - return e.JSON(http.StatusOK, map[string]any{"err": err.Error()}) - } - return e.JSON(http.StatusOK, map[string]any{"err": false}) -} - -// containerRequestHandler handles both container logs and info requests -func (h *Hub) containerRequestHandler(e *core.RequestEvent, fetchFunc func(*systems.System, string) (string, error), responseKey string) error { - systemID := e.Request.URL.Query().Get("system") - containerID := e.Request.URL.Query().Get("container") - - if systemID == "" || containerID == "" { - return e.JSON(http.StatusBadRequest, map[string]string{"error": "system and container parameters are required"}) - } - if !containerIDPattern.MatchString(containerID) { - return e.JSON(http.StatusBadRequest, map[string]string{"error": "invalid container parameter"}) - } - - system, err := h.sm.GetSystem(systemID) - if err != nil { - return e.JSON(http.StatusNotFound, map[string]string{"error": "system not found"}) - } - - data, err := fetchFunc(system, containerID) - if err != nil { - return e.JSON(http.StatusNotFound, map[string]string{"error": err.Error()}) - } - - return e.JSON(http.StatusOK, map[string]string{responseKey: data}) -} - -// getContainerLogs handles GET /api/beszel/containers/logs requests -func (h *Hub) getContainerLogs(e *core.RequestEvent) error { - return h.containerRequestHandler(e, func(system *systems.System, containerID string) (string, error) { - return system.FetchContainerLogsFromAgent(containerID) - }, "logs") -} - -func (h *Hub) getContainerInfo(e *core.RequestEvent) error { - return h.containerRequestHandler(e, func(system *systems.System, containerID string) (string, error) { - return system.FetchContainerInfoFromAgent(containerID) - }, "info") -} - -// getSystemdInfo handles GET /api/beszel/systemd/info requests -func (h *Hub) getSystemdInfo(e *core.RequestEvent) error { - query := e.Request.URL.Query() - systemID := query.Get("system") - serviceName := query.Get("service") - - if systemID == "" || serviceName == "" { - return e.JSON(http.StatusBadRequest, map[string]string{"error": "system and service parameters are required"}) - } - system, err := h.sm.GetSystem(systemID) - if err != nil { - return e.JSON(http.StatusNotFound, map[string]string{"error": "system not found"}) - } - details, err := system.FetchSystemdInfoFromAgent(serviceName) - if err != nil { - return e.JSON(http.StatusNotFound, map[string]string{"error": err.Error()}) - } - e.Response.Header().Set("Cache-Control", "public, max-age=60") - return e.JSON(http.StatusOK, map[string]any{"details": details}) -} - -// refreshSmartData handles POST /api/beszel/smart/refresh requests -// Fetches fresh SMART data from the agent and updates the collection -func (h *Hub) refreshSmartData(e *core.RequestEvent) error { - systemID := e.Request.URL.Query().Get("system") - if systemID == "" { - return e.JSON(http.StatusBadRequest, map[string]string{"error": "system parameter is required"}) - } - - system, err := h.sm.GetSystem(systemID) - if err != nil { - return e.JSON(http.StatusNotFound, map[string]string{"error": "system not found"}) - } - - // Fetch and save SMART devices - if err := system.FetchAndSaveSmartDevices(); err != nil { - return e.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) - } - - return e.JSON(http.StatusOK, map[string]string{"status": "ok"}) -} - // GetSSHKey generates key pair if it doesn't exist and returns signer func (h *Hub) GetSSHKey(dataDir string) (ssh.Signer, error) { if h.signer != nil { diff --git a/internal/hub/hub_test.go b/internal/hub/hub_test.go index a43243ca..a606704f 100644 --- a/internal/hub/hub_test.go +++ b/internal/hub/hub_test.go @@ -3,36 +3,20 @@ package hub_test import ( - "bytes" "crypto/ed25519" - "encoding/json" "encoding/pem" - "io" - "net/http" "os" "path/filepath" "strings" "testing" - "github.com/henrygd/beszel/internal/migrations" beszelTests "github.com/henrygd/beszel/internal/tests" - "github.com/pocketbase/pocketbase/core" - pbTests "github.com/pocketbase/pocketbase/tests" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "golang.org/x/crypto/ssh" ) -// marshal to json and return an io.Reader (for use in ApiScenario.Body) -func jsonReader(v any) io.Reader { - data, err := json.Marshal(v) - if err != nil { - panic(err) - } - return bytes.NewReader(data) -} - func TestMakeLink(t *testing.T) { hub, _ := beszelTests.NewTestHub(t.TempDir()) @@ -265,710 +249,6 @@ func TestGetSSHKey(t *testing.T) { }) } -func TestApiRoutesAuthentication(t *testing.T) { - hub, _ := beszelTests.NewTestHub(t.TempDir()) - defer hub.Cleanup() - - hub.StartHub() - - // Create test user and get auth token - user, err := beszelTests.CreateUser(hub, "testuser@example.com", "password123") - require.NoError(t, err, "Failed to create test user") - - adminUser, err := beszelTests.CreateRecord(hub, "users", map[string]any{ - "email": "admin@example.com", - "password": "password123", - "role": "admin", - }) - require.NoError(t, err, "Failed to create admin user") - adminUserToken, err := adminUser.NewAuthToken() - - // superUser, err := beszelTests.CreateRecord(hub, core.CollectionNameSuperusers, map[string]any{ - // "email": "superuser@example.com", - // "password": "password123", - // }) - // require.NoError(t, err, "Failed to create superuser") - - userToken, err := user.NewAuthToken() - require.NoError(t, err, "Failed to create auth token") - - // Create test system for user-alerts endpoints - system, err := beszelTests.CreateRecord(hub, "systems", map[string]any{ - "name": "test-system", - "users": []string{user.Id}, - "host": "127.0.0.1", - }) - require.NoError(t, err, "Failed to create test system") - - testAppFactory := func(t testing.TB) *pbTests.TestApp { - return hub.TestApp - } - - scenarios := []beszelTests.ApiScenario{ - // Auth Protected Routes - Should require authentication - { - Name: "POST /test-notification - no auth should fail", - Method: http.MethodPost, - URL: "/api/beszel/test-notification", - ExpectedStatus: 401, - ExpectedContent: []string{"requires valid"}, - TestAppFactory: testAppFactory, - Body: jsonReader(map[string]any{ - "url": "generic://127.0.0.1", - }), - }, - { - Name: "POST /test-notification - with auth should succeed", - Method: http.MethodPost, - URL: "/api/beszel/test-notification", - TestAppFactory: testAppFactory, - Headers: map[string]string{ - "Authorization": userToken, - }, - Body: jsonReader(map[string]any{ - "url": "generic://127.0.0.1", - }), - ExpectedStatus: 200, - ExpectedContent: []string{"sending message"}, - }, - { - Name: "GET /config-yaml - no auth should fail", - Method: http.MethodGet, - URL: "/api/beszel/config-yaml", - ExpectedStatus: 401, - ExpectedContent: []string{"requires valid"}, - TestAppFactory: testAppFactory, - }, - { - Name: "GET /config-yaml - with user auth should fail", - Method: http.MethodGet, - URL: "/api/beszel/config-yaml", - Headers: map[string]string{ - "Authorization": userToken, - }, - ExpectedStatus: 403, - ExpectedContent: []string{"Requires admin"}, - TestAppFactory: testAppFactory, - }, - { - Name: "GET /config-yaml - with admin auth should succeed", - Method: http.MethodGet, - URL: "/api/beszel/config-yaml", - Headers: map[string]string{ - "Authorization": adminUserToken, - }, - ExpectedStatus: 200, - ExpectedContent: []string{"test-system"}, - TestAppFactory: testAppFactory, - }, - { - Name: "GET /heartbeat-status - no auth should fail", - Method: http.MethodGet, - URL: "/api/beszel/heartbeat-status", - ExpectedStatus: 401, - ExpectedContent: []string{"requires valid"}, - TestAppFactory: testAppFactory, - }, - { - Name: "GET /heartbeat-status - with user auth should fail", - Method: http.MethodGet, - URL: "/api/beszel/heartbeat-status", - Headers: map[string]string{ - "Authorization": userToken, - }, - ExpectedStatus: 403, - ExpectedContent: []string{"Requires admin role"}, - TestAppFactory: testAppFactory, - }, - { - Name: "GET /heartbeat-status - with admin auth should succeed", - Method: http.MethodGet, - URL: "/api/beszel/heartbeat-status", - Headers: map[string]string{ - "Authorization": adminUserToken, - }, - ExpectedStatus: 200, - ExpectedContent: []string{`"enabled":false`}, - TestAppFactory: testAppFactory, - }, - { - Name: "POST /test-heartbeat - with user auth should fail", - Method: http.MethodPost, - URL: "/api/beszel/test-heartbeat", - Headers: map[string]string{ - "Authorization": userToken, - }, - ExpectedStatus: 403, - ExpectedContent: []string{"Requires admin role"}, - TestAppFactory: testAppFactory, - }, - { - Name: "POST /test-heartbeat - with admin auth should report disabled state", - Method: http.MethodPost, - URL: "/api/beszel/test-heartbeat", - Headers: map[string]string{ - "Authorization": adminUserToken, - }, - ExpectedStatus: 200, - ExpectedContent: []string{"Heartbeat not configured"}, - TestAppFactory: testAppFactory, - }, - { - Name: "GET /universal-token - no auth should fail", - Method: http.MethodGet, - URL: "/api/beszel/universal-token", - ExpectedStatus: 401, - ExpectedContent: []string{"requires valid"}, - TestAppFactory: testAppFactory, - }, - { - Name: "GET /universal-token - with auth should succeed", - Method: http.MethodGet, - URL: "/api/beszel/universal-token", - Headers: map[string]string{ - "Authorization": userToken, - }, - ExpectedStatus: 200, - ExpectedContent: []string{"active", "token", "permanent"}, - TestAppFactory: testAppFactory, - }, - { - Name: "GET /universal-token - enable permanent should succeed", - Method: http.MethodGet, - URL: "/api/beszel/universal-token?enable=1&permanent=1&token=permanent-token-123", - Headers: map[string]string{ - "Authorization": userToken, - }, - ExpectedStatus: 200, - ExpectedContent: []string{"\"permanent\":true", "permanent-token-123"}, - TestAppFactory: testAppFactory, - }, - { - Name: "POST /user-alerts - no auth should fail", - Method: http.MethodPost, - URL: "/api/beszel/user-alerts", - ExpectedStatus: 401, - ExpectedContent: []string{"requires valid"}, - TestAppFactory: testAppFactory, - Body: jsonReader(map[string]any{ - "name": "CPU", - "value": 80, - "min": 10, - "systems": []string{system.Id}, - }), - }, - { - Name: "POST /user-alerts - with auth should succeed", - Method: http.MethodPost, - URL: "/api/beszel/user-alerts", - Headers: map[string]string{ - "Authorization": userToken, - }, - ExpectedStatus: 200, - ExpectedContent: []string{"\"success\":true"}, - TestAppFactory: testAppFactory, - Body: jsonReader(map[string]any{ - "name": "CPU", - "value": 80, - "min": 10, - "systems": []string{system.Id}, - }), - }, - { - Name: "DELETE /user-alerts - no auth should fail", - Method: http.MethodDelete, - URL: "/api/beszel/user-alerts", - ExpectedStatus: 401, - ExpectedContent: []string{"requires valid"}, - TestAppFactory: testAppFactory, - Body: jsonReader(map[string]any{ - "name": "CPU", - "systems": []string{system.Id}, - }), - }, - { - Name: "DELETE /user-alerts - with auth should succeed", - Method: http.MethodDelete, - URL: "/api/beszel/user-alerts", - Headers: map[string]string{ - "Authorization": userToken, - }, - ExpectedStatus: 200, - ExpectedContent: []string{"\"success\":true"}, - TestAppFactory: testAppFactory, - Body: jsonReader(map[string]any{ - "name": "CPU", - "systems": []string{system.Id}, - }), - BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) { - // Create an alert to delete - beszelTests.CreateRecord(app, "alerts", map[string]any{ - "name": "CPU", - "system": system.Id, - "user": user.Id, - "value": 80, - "min": 10, - }) - }, - }, - { - Name: "GET /containers/logs - no auth should fail", - Method: http.MethodGet, - URL: "/api/beszel/containers/logs?system=test-system&container=test-container", - ExpectedStatus: 401, - ExpectedContent: []string{"requires valid"}, - TestAppFactory: testAppFactory, - }, - { - Name: "GET /containers/logs - with auth but missing system param should fail", - Method: http.MethodGet, - URL: "/api/beszel/containers/logs?container=test-container", - Headers: map[string]string{ - "Authorization": userToken, - }, - ExpectedStatus: 400, - ExpectedContent: []string{"system and container parameters are required"}, - TestAppFactory: testAppFactory, - }, - { - Name: "GET /containers/logs - with auth but missing container param should fail", - Method: http.MethodGet, - URL: "/api/beszel/containers/logs?system=test-system", - Headers: map[string]string{ - "Authorization": userToken, - }, - ExpectedStatus: 400, - ExpectedContent: []string{"system and container parameters are required"}, - TestAppFactory: testAppFactory, - }, - { - Name: "GET /containers/logs - with auth but invalid system should fail", - Method: http.MethodGet, - URL: "/api/beszel/containers/logs?system=invalid-system&container=0123456789ab", - Headers: map[string]string{ - "Authorization": userToken, - }, - ExpectedStatus: 404, - ExpectedContent: []string{"system not found"}, - TestAppFactory: testAppFactory, - }, - { - Name: "GET /containers/logs - traversal container should fail validation", - Method: http.MethodGet, - URL: "/api/beszel/containers/logs?system=" + system.Id + "&container=..%2F..%2Fversion", - Headers: map[string]string{ - "Authorization": userToken, - }, - ExpectedStatus: 400, - ExpectedContent: []string{"invalid container parameter"}, - TestAppFactory: testAppFactory, - }, - { - Name: "GET /containers/info - traversal container should fail validation", - Method: http.MethodGet, - URL: "/api/beszel/containers/info?system=" + system.Id + "&container=../../version?x=", - Headers: map[string]string{ - "Authorization": userToken, - }, - ExpectedStatus: 400, - ExpectedContent: []string{"invalid container parameter"}, - TestAppFactory: testAppFactory, - }, - { - Name: "GET /containers/info - non-hex container should fail validation", - Method: http.MethodGet, - URL: "/api/beszel/containers/info?system=" + system.Id + "&container=container_name", - Headers: map[string]string{ - "Authorization": userToken, - }, - ExpectedStatus: 400, - ExpectedContent: []string{"invalid container parameter"}, - TestAppFactory: testAppFactory, - }, - - // Auth Optional Routes - Should work without authentication - { - Name: "GET /getkey - no auth should fail", - Method: http.MethodGet, - URL: "/api/beszel/getkey", - ExpectedStatus: 401, - ExpectedContent: []string{"requires valid"}, - TestAppFactory: testAppFactory, - }, - { - Name: "GET /getkey - with auth should also succeed", - Method: http.MethodGet, - URL: "/api/beszel/getkey", - Headers: map[string]string{ - "Authorization": userToken, - }, - ExpectedStatus: 200, - ExpectedContent: []string{"\"key\":", "\"v\":"}, - TestAppFactory: testAppFactory, - }, - { - Name: "GET /info - should return the same as /getkey", - Method: http.MethodGet, - URL: "/api/beszel/info", - Headers: map[string]string{ - "Authorization": userToken, - }, - ExpectedStatus: 200, - ExpectedContent: []string{"\"key\":", "\"v\":"}, - TestAppFactory: testAppFactory, - }, - { - Name: "GET /first-run - no auth should succeed", - Method: http.MethodGet, - URL: "/api/beszel/first-run", - ExpectedStatus: 200, - ExpectedContent: []string{"\"firstRun\":false"}, - TestAppFactory: testAppFactory, - }, - { - Name: "GET /first-run - with auth should also succeed", - Method: http.MethodGet, - URL: "/api/beszel/first-run", - Headers: map[string]string{ - "Authorization": userToken, - }, - ExpectedStatus: 200, - ExpectedContent: []string{"\"firstRun\":false"}, - TestAppFactory: testAppFactory, - }, - { - Name: "GET /agent-connect - no auth should succeed (websocket upgrade fails but route is accessible)", - Method: http.MethodGet, - URL: "/api/beszel/agent-connect", - ExpectedStatus: 400, - ExpectedContent: []string{}, - TestAppFactory: testAppFactory, - }, - { - Name: "POST /test-notification - invalid auth token should fail", - Method: http.MethodPost, - URL: "/api/beszel/test-notification", - Body: jsonReader(map[string]any{ - "url": "generic://127.0.0.1", - }), - Headers: map[string]string{ - "Authorization": "invalid-token", - }, - ExpectedStatus: 401, - ExpectedContent: []string{"requires valid"}, - TestAppFactory: testAppFactory, - }, - { - Name: "POST /user-alerts - invalid auth token should fail", - Method: http.MethodPost, - URL: "/api/beszel/user-alerts", - Headers: map[string]string{ - "Authorization": "invalid-token", - }, - ExpectedStatus: 401, - ExpectedContent: []string{"requires valid"}, - TestAppFactory: testAppFactory, - Body: jsonReader(map[string]any{ - "name": "CPU", - "value": 80, - "min": 10, - "systems": []string{system.Id}, - }), - }, - } - - for _, scenario := range scenarios { - scenario.Test(t) - } -} - -func TestFirstUserCreation(t *testing.T) { - t.Run("CreateUserEndpoint available when no users exist", func(t *testing.T) { - hub, _ := beszelTests.NewTestHub(t.TempDir()) - defer hub.Cleanup() - - hub.StartHub() - - testAppFactoryExisting := func(t testing.TB) *pbTests.TestApp { - return hub.TestApp - } - - scenarios := []beszelTests.ApiScenario{ - { - Name: "POST /create-user - should be available when no users exist", - Method: http.MethodPost, - URL: "/api/beszel/create-user", - Body: jsonReader(map[string]any{ - "email": "firstuser@example.com", - "password": "password123", - }), - ExpectedStatus: 200, - ExpectedContent: []string{"User created"}, - TestAppFactory: testAppFactoryExisting, - BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) { - userCount, err := hub.CountRecords("users") - require.NoError(t, err) - require.Zero(t, userCount, "Should start with no users") - superusers, err := hub.FindAllRecords(core.CollectionNameSuperusers) - require.NoError(t, err) - require.EqualValues(t, 1, len(superusers), "Should start with one temporary superuser") - require.EqualValues(t, migrations.TempAdminEmail, superusers[0].GetString("email"), "Should have created one temporary superuser") - }, - AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) { - userCount, err := hub.CountRecords("users") - require.NoError(t, err) - require.EqualValues(t, 1, userCount, "Should have created one user") - superusers, err := hub.FindAllRecords(core.CollectionNameSuperusers) - require.NoError(t, err) - require.EqualValues(t, 1, len(superusers), "Should have created one superuser") - require.EqualValues(t, "firstuser@example.com", superusers[0].GetString("email"), "Should have created one superuser") - }, - }, - { - Name: "POST /create-user - should not be available when users exist", - Method: http.MethodPost, - URL: "/api/beszel/create-user", - Body: jsonReader(map[string]any{ - "email": "firstuser@example.com", - "password": "password123", - }), - ExpectedStatus: 404, - ExpectedContent: []string{"wasn't found"}, - TestAppFactory: testAppFactoryExisting, - }, - } - - for _, scenario := range scenarios { - scenario.Test(t) - } - }) - - t.Run("CreateUserEndpoint not available when USER_EMAIL, USER_PASSWORD are set", func(t *testing.T) { - t.Setenv("BESZEL_HUB_USER_EMAIL", "me@example.com") - t.Setenv("BESZEL_HUB_USER_PASSWORD", "password123") - - hub, _ := beszelTests.NewTestHub(t.TempDir()) - defer hub.Cleanup() - - hub.StartHub() - - testAppFactory := func(t testing.TB) *pbTests.TestApp { - return hub.TestApp - } - - scenario := beszelTests.ApiScenario{ - Name: "POST /create-user - should not be available when USER_EMAIL, USER_PASSWORD are set", - Method: http.MethodPost, - URL: "/api/beszel/create-user", - ExpectedStatus: 404, - ExpectedContent: []string{"wasn't found"}, - TestAppFactory: testAppFactory, - BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) { - users, err := hub.FindAllRecords("users") - require.NoError(t, err) - require.EqualValues(t, 1, len(users), "Should start with one user") - require.EqualValues(t, "me@example.com", users[0].GetString("email"), "Should have created one user") - superusers, err := hub.FindAllRecords(core.CollectionNameSuperusers) - require.NoError(t, err) - require.EqualValues(t, 1, len(superusers), "Should start with one superuser") - require.EqualValues(t, "me@example.com", superusers[0].GetString("email"), "Should have created one superuser") - }, - AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) { - users, err := hub.FindAllRecords("users") - require.NoError(t, err) - require.EqualValues(t, 1, len(users), "Should still have one user") - require.EqualValues(t, "me@example.com", users[0].GetString("email"), "Should have created one user") - superusers, err := hub.FindAllRecords(core.CollectionNameSuperusers) - require.NoError(t, err) - require.EqualValues(t, 1, len(superusers), "Should still have one superuser") - require.EqualValues(t, "me@example.com", superusers[0].GetString("email"), "Should have created one superuser") - }, - } - - scenario.Test(t) - }) -} - -func TestCreateUserEndpointAvailability(t *testing.T) { - t.Run("CreateUserEndpoint available when no users exist", func(t *testing.T) { - hub, _ := beszelTests.NewTestHub(t.TempDir()) - defer hub.Cleanup() - - // Ensure no users exist - userCount, err := hub.CountRecords("users") - require.NoError(t, err) - require.Zero(t, userCount, "Should start with no users") - - hub.StartHub() - - testAppFactory := func(t testing.TB) *pbTests.TestApp { - return hub.TestApp - } - - scenario := beszelTests.ApiScenario{ - Name: "POST /create-user - should be available when no users exist", - Method: http.MethodPost, - URL: "/api/beszel/create-user", - Body: jsonReader(map[string]any{ - "email": "firstuser@example.com", - "password": "password123", - }), - ExpectedStatus: 200, - ExpectedContent: []string{"User created"}, - TestAppFactory: testAppFactory, - } - - scenario.Test(t) - - // Verify user was created - userCount, err = hub.CountRecords("users") - require.NoError(t, err) - require.EqualValues(t, 1, userCount, "Should have created one user") - }) - - t.Run("CreateUserEndpoint not available when users exist", func(t *testing.T) { - hub, _ := beszelTests.NewTestHub(t.TempDir()) - defer hub.Cleanup() - - // Create a user first - _, err := beszelTests.CreateUser(hub, "existing@example.com", "password") - require.NoError(t, err) - - hub.StartHub() - - testAppFactory := func(t testing.TB) *pbTests.TestApp { - return hub.TestApp - } - - scenario := beszelTests.ApiScenario{ - Name: "POST /create-user - should not be available when users exist", - Method: http.MethodPost, - URL: "/api/beszel/create-user", - Body: jsonReader(map[string]any{ - "email": "another@example.com", - "password": "password123", - }), - ExpectedStatus: 404, - ExpectedContent: []string{"wasn't found"}, - TestAppFactory: testAppFactory, - } - - scenario.Test(t) - }) -} - -func TestAutoLoginMiddleware(t *testing.T) { - var hubs []*beszelTests.TestHub - - defer func() { - for _, hub := range hubs { - hub.Cleanup() - } - }() - - t.Setenv("AUTO_LOGIN", "user@test.com") - - testAppFactory := func(t testing.TB) *pbTests.TestApp { - hub, _ := beszelTests.NewTestHub(t.TempDir()) - hubs = append(hubs, hub) - hub.StartHub() - return hub.TestApp - } - - scenarios := []beszelTests.ApiScenario{ - { - Name: "GET /getkey - without auto login should fail", - Method: http.MethodGet, - URL: "/api/beszel/getkey", - ExpectedStatus: 401, - ExpectedContent: []string{"requires valid"}, - TestAppFactory: testAppFactory, - }, - { - Name: "GET /getkey - with auto login should fail if no matching user", - Method: http.MethodGet, - URL: "/api/beszel/getkey", - ExpectedStatus: 401, - ExpectedContent: []string{"requires valid"}, - TestAppFactory: testAppFactory, - }, - { - Name: "GET /getkey - with auto login should succeed", - Method: http.MethodGet, - URL: "/api/beszel/getkey", - ExpectedStatus: 200, - ExpectedContent: []string{"\"key\":", "\"v\":"}, - TestAppFactory: testAppFactory, - BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) { - beszelTests.CreateUser(app, "user@test.com", "password123") - }, - }, - } - - for _, scenario := range scenarios { - scenario.Test(t) - } -} - -func TestTrustedHeaderMiddleware(t *testing.T) { - var hubs []*beszelTests.TestHub - - defer func() { - for _, hub := range hubs { - hub.Cleanup() - } - }() - - t.Setenv("TRUSTED_AUTH_HEADER", "X-Beszel-Trusted") - - testAppFactory := func(t testing.TB) *pbTests.TestApp { - hub, _ := beszelTests.NewTestHub(t.TempDir()) - hubs = append(hubs, hub) - hub.StartHub() - return hub.TestApp - } - - scenarios := []beszelTests.ApiScenario{ - { - Name: "GET /getkey - without trusted header should fail", - Method: http.MethodGet, - URL: "/api/beszel/getkey", - ExpectedStatus: 401, - ExpectedContent: []string{"requires valid"}, - TestAppFactory: testAppFactory, - }, - { - Name: "GET /getkey - with trusted header should fail if no matching user", - Method: http.MethodGet, - URL: "/api/beszel/getkey", - Headers: map[string]string{ - "X-Beszel-Trusted": "user@test.com", - }, - ExpectedStatus: 401, - ExpectedContent: []string{"requires valid"}, - TestAppFactory: testAppFactory, - }, - { - Name: "GET /getkey - with trusted header should succeed", - Method: http.MethodGet, - URL: "/api/beszel/getkey", - Headers: map[string]string{ - "X-Beszel-Trusted": "user@test.com", - }, - ExpectedStatus: 200, - ExpectedContent: []string{"\"key\":", "\"v\":"}, - TestAppFactory: testAppFactory, - BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) { - beszelTests.CreateUser(app, "user@test.com", "password123") - }, - }, - } - - for _, scenario := range scenarios { - scenario.Test(t) - } -} - func TestAppUrl(t *testing.T) { t.Run("no APP_URL does't change app url", func(t *testing.T) { hub, _ := beszelTests.NewTestHub(t.TempDir())