mirror of
https://github.com/henrygd/beszel.git
synced 2026-09-21 00:47:47 +02:00
* fix: make first-user bootstrap atomic * add tests --------- Co-authored-by: henrygd <hank@henrygd.me>
1153 lines
37 KiB
Go
1153 lines
37 KiB
Go
package hub_test
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"sort"
|
|
"testing"
|
|
"time"
|
|
|
|
beszelTests "github.com/henrygd/beszel/internal/tests"
|
|
|
|
"github.com/henrygd/beszel/internal/migrations"
|
|
"github.com/pocketbase/dbx"
|
|
"github.com/pocketbase/pocketbase/apis"
|
|
"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)
|
|
}
|
|
|
|
type gatedReader struct {
|
|
data []byte
|
|
started chan struct{}
|
|
release chan struct{}
|
|
offset int
|
|
}
|
|
|
|
func (r *gatedReader) Read(p []byte) (int, error) {
|
|
if r.offset == 0 {
|
|
close(r.started)
|
|
<-r.release
|
|
}
|
|
if r.offset >= len(r.data) {
|
|
return 0, io.EOF
|
|
}
|
|
n := copy(p, r.data[r.offset:])
|
|
r.offset += n
|
|
return n, nil
|
|
}
|
|
|
|
func firstUserTestMux(t *testing.T) (*beszelTests.TestHub, http.Handler) {
|
|
t.Helper()
|
|
hub, err := beszelTests.NewTestHub(t.TempDir())
|
|
require.NoError(t, err)
|
|
_ = hub.StartHub()
|
|
|
|
router, err := apis.NewRouter(hub.TestApp)
|
|
require.NoError(t, err)
|
|
serveEvent := &core.ServeEvent{App: hub.TestApp, Router: router}
|
|
|
|
var handler http.Handler
|
|
err = hub.TestApp.OnServe().Trigger(serveEvent, func(e *core.ServeEvent) error {
|
|
var buildErr error
|
|
handler, buildErr = e.Router.BuildMux()
|
|
return buildErr
|
|
})
|
|
require.NoError(t, err)
|
|
require.NotNil(t, handler)
|
|
return hub, handler
|
|
}
|
|
|
|
func postFirstUser(handler http.Handler, email string) *httptest.ResponseRecorder {
|
|
body, _ := json.Marshal(map[string]string{
|
|
"email": email,
|
|
"password": "password123",
|
|
})
|
|
req := httptest.NewRequest(http.MethodPost, "/api/beszel/create-user", bytes.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
recorder := httptest.NewRecorder()
|
|
handler.ServeHTTP(recorder, req)
|
|
return recorder
|
|
}
|
|
|
|
func TestApiRoutesAuthentication(t *testing.T) {
|
|
hub, user := beszelTests.GetHubWithUser(t)
|
|
defer hub.Cleanup()
|
|
|
|
userToken, err := user.NewAuthToken()
|
|
require.NoError(t, err, "Failed to create auth token")
|
|
|
|
// Create test user and get auth token
|
|
user2, err := beszelTests.CreateUser(hub, "testuser@example.com", "password123")
|
|
require.NoError(t, err, "Failed to create test user")
|
|
user2Token, err := user2.NewAuthToken()
|
|
require.NoError(t, err, "Failed to create user2 auth token")
|
|
|
|
adminUser, err := beszelTests.CreateUserWithRole(hub, "admin@example.com", "password123", "admin")
|
|
require.NoError(t, err, "Failed to create admin user")
|
|
adminUserToken, err := adminUser.NewAuthToken()
|
|
|
|
readOnlyUser, err := beszelTests.CreateUserWithRole(hub, "readonly@example.com", "password123", "readonly")
|
|
require.NoError(t, err, "Failed to create readonly user")
|
|
readOnlyUserToken, err := readOnlyUser.NewAuthToken()
|
|
require.NoError(t, err, "Failed to create readonly user auth token")
|
|
|
|
superuser, err := beszelTests.CreateSuperuser(hub, "superuser@example.com", "password123")
|
|
require.NoError(t, err, "Failed to create superuser")
|
|
superuserToken, err := superuser.NewAuthToken()
|
|
require.NoError(t, err, "Failed to create superuser auth token")
|
|
|
|
// Create test system
|
|
system, err := beszelTests.CreateRecord(hub, "systems", map[string]any{
|
|
"name": "test-system",
|
|
"users": []string{user.Id, readOnlyUser.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: "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{"The authorized record is not allowed to perform this action."},
|
|
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{"The authorized record is not allowed to perform this action."},
|
|
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{"The authorized record is not allowed to perform this action."},
|
|
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: "GET /universal-token - superuser should fail",
|
|
Method: http.MethodGet,
|
|
URL: "/api/beszel/universal-token",
|
|
Headers: map[string]string{
|
|
"Authorization": superuserToken,
|
|
},
|
|
ExpectedStatus: 403,
|
|
ExpectedContent: []string{"Superusers cannot use universal tokens"},
|
|
TestAppFactory: func(t testing.TB) *pbTests.TestApp {
|
|
return hub.TestApp
|
|
},
|
|
},
|
|
{
|
|
Name: "GET /universal-token - with readonly auth should fail",
|
|
Method: http.MethodGet,
|
|
URL: "/api/beszel/universal-token",
|
|
Headers: map[string]string{
|
|
"Authorization": readOnlyUserToken,
|
|
},
|
|
ExpectedStatus: 403,
|
|
ExpectedContent: []string{"The authorized record is not allowed to perform this action."},
|
|
TestAppFactory: testAppFactory,
|
|
},
|
|
{
|
|
Name: "POST /smart/refresh - missing system should fail 400 with user auth",
|
|
Method: http.MethodPost,
|
|
URL: "/api/beszel/smart/refresh",
|
|
Headers: map[string]string{
|
|
"Authorization": userToken,
|
|
},
|
|
ExpectedStatus: 400,
|
|
ExpectedContent: []string{"Invalid", "system", "parameter"},
|
|
TestAppFactory: testAppFactory,
|
|
},
|
|
{
|
|
Name: "POST /smart/refresh - with readonly auth should fail",
|
|
Method: http.MethodPost,
|
|
URL: fmt.Sprintf("/api/beszel/smart/refresh?system=%s", system.Id),
|
|
Headers: map[string]string{
|
|
"Authorization": readOnlyUserToken,
|
|
},
|
|
ExpectedStatus: 403,
|
|
ExpectedContent: []string{"The authorized record is not allowed to perform this action."},
|
|
TestAppFactory: testAppFactory,
|
|
},
|
|
{
|
|
Name: "POST /smart/refresh - non-user system should fail",
|
|
Method: http.MethodPost,
|
|
URL: fmt.Sprintf("/api/beszel/smart/refresh?system=%s", system.Id),
|
|
Headers: map[string]string{
|
|
"Authorization": user2Token,
|
|
},
|
|
ExpectedStatus: 404,
|
|
ExpectedContent: []string{"The requested resource wasn't found."},
|
|
TestAppFactory: testAppFactory,
|
|
},
|
|
{
|
|
Name: "POST /smart/refresh - good user should pass validation",
|
|
Method: http.MethodPost,
|
|
URL: fmt.Sprintf("/api/beszel/smart/refresh?system=%s", system.Id),
|
|
Headers: map[string]string{
|
|
"Authorization": userToken,
|
|
},
|
|
ExpectedStatus: 500,
|
|
ExpectedContent: []string{"Something went wrong while processing your request."},
|
|
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: "POST /user-alerts - readonly user can create own alert",
|
|
Method: http.MethodPost,
|
|
URL: "/api/beszel/user-alerts",
|
|
Headers: map[string]string{
|
|
"Authorization": readOnlyUserToken,
|
|
},
|
|
ExpectedStatus: 200,
|
|
ExpectedContent: []string{"\"success\":true"},
|
|
TestAppFactory: testAppFactory,
|
|
Body: jsonReader(map[string]any{
|
|
"name": "CPU", "value": 80, "min": 10, "systems": []string{system.Id},
|
|
}),
|
|
AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
|
|
alerts, _ := app.CountRecords("alerts", dbx.HashExp{"user": readOnlyUser.Id})
|
|
require.EqualValues(t, 1, alerts)
|
|
},
|
|
},
|
|
{
|
|
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: "DELETE /user-alerts - readonly user can delete own alert",
|
|
Method: http.MethodDelete,
|
|
URL: "/api/beszel/user-alerts",
|
|
Headers: map[string]string{
|
|
"Authorization": readOnlyUserToken,
|
|
},
|
|
ExpectedStatus: 200,
|
|
ExpectedContent: []string{"\"count\":1", "\"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) {
|
|
beszelTests.CreateRecord(app, "alerts", map[string]any{
|
|
"name": "CPU", "system": system.Id, "user": readOnlyUser.Id, "value": 80,
|
|
})
|
|
},
|
|
AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
|
|
alerts, _ := app.CountRecords("alerts", dbx.HashExp{"user": readOnlyUser.Id})
|
|
require.Zero(t, alerts)
|
|
},
|
|
},
|
|
{
|
|
Name: "GET /containers/logs - no auth should fail",
|
|
Method: http.MethodGet,
|
|
URL: "/api/beszel/containers/logs?system=test-system&container=abababababab",
|
|
ExpectedStatus: 401,
|
|
ExpectedContent: []string{"requires valid"},
|
|
TestAppFactory: testAppFactory,
|
|
},
|
|
{
|
|
Name: "GET /containers/logs - request for valid non-user system should fail",
|
|
Method: http.MethodGet,
|
|
URL: fmt.Sprintf("/api/beszel/containers/logs?system=%s&container=abababababab", system.Id),
|
|
ExpectedStatus: 404,
|
|
ExpectedContent: []string{"The requested resource wasn't found."},
|
|
TestAppFactory: testAppFactory,
|
|
Headers: map[string]string{
|
|
"Authorization": user2Token,
|
|
},
|
|
},
|
|
{
|
|
Name: "GET /containers/info - request for valid non-user system should fail",
|
|
Method: http.MethodGet,
|
|
URL: fmt.Sprintf("/api/beszel/containers/info?system=%s&container=abababababab", system.Id),
|
|
ExpectedStatus: 404,
|
|
ExpectedContent: []string{"The requested resource wasn't found."},
|
|
TestAppFactory: testAppFactory,
|
|
Headers: map[string]string{
|
|
"Authorization": user2Token,
|
|
},
|
|
},
|
|
{
|
|
Name: "GET /containers/info - SHARE_ALL_SYSTEMS allows non-member user",
|
|
Method: http.MethodGet,
|
|
URL: fmt.Sprintf("/api/beszel/containers/info?system=%s&container=abababababab", system.Id),
|
|
ExpectedStatus: 500,
|
|
ExpectedContent: []string{"Something went wrong while processing your request."},
|
|
TestAppFactory: testAppFactory,
|
|
Headers: map[string]string{
|
|
"Authorization": user2Token,
|
|
},
|
|
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
|
|
t.Setenv("SHARE_ALL_SYSTEMS", "true")
|
|
},
|
|
AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
|
|
t.Setenv("SHARE_ALL_SYSTEMS", "")
|
|
},
|
|
},
|
|
{
|
|
Name: "GET /containers/logs - with auth but missing system param should fail",
|
|
Method: http.MethodGet,
|
|
URL: "/api/beszel/containers/logs?container=abababababab",
|
|
Headers: map[string]string{
|
|
"Authorization": userToken,
|
|
},
|
|
ExpectedStatus: 400,
|
|
ExpectedContent: []string{"Invalid", "parameter"},
|
|
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{"Invalid", "parameter"},
|
|
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{"The requested resource wasn't 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", "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", "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", "parameter"},
|
|
TestAppFactory: testAppFactory,
|
|
},
|
|
{
|
|
Name: "GET /containers/logs - good user should pass validation",
|
|
Method: http.MethodGet,
|
|
URL: "/api/beszel/containers/logs?system=" + system.Id + "&container=0123456789ab",
|
|
Headers: map[string]string{
|
|
"Authorization": userToken,
|
|
},
|
|
ExpectedStatus: 500,
|
|
ExpectedContent: []string{"Something went wrong while processing your request."},
|
|
TestAppFactory: testAppFactory,
|
|
},
|
|
{
|
|
Name: "GET /containers/info - good user should pass validation",
|
|
Method: http.MethodGet,
|
|
URL: "/api/beszel/containers/info?system=" + system.Id + "&container=0123456789ab",
|
|
Headers: map[string]string{
|
|
"Authorization": userToken,
|
|
},
|
|
ExpectedStatus: 500,
|
|
ExpectedContent: []string{"Something went wrong while processing your request."},
|
|
TestAppFactory: testAppFactory,
|
|
},
|
|
// /systemd routes
|
|
{
|
|
Name: "GET /systemd/info - no auth should fail",
|
|
Method: http.MethodGet,
|
|
URL: fmt.Sprintf("/api/beszel/systemd/info?system=%s&service=nginx.service", system.Id),
|
|
ExpectedStatus: 401,
|
|
ExpectedContent: []string{"requires valid"},
|
|
TestAppFactory: testAppFactory,
|
|
},
|
|
{
|
|
Name: "GET /systemd/info - request for valid non-user system should fail",
|
|
Method: http.MethodGet,
|
|
URL: fmt.Sprintf("/api/beszel/systemd/info?system=%s&service=nginx.service", system.Id),
|
|
ExpectedStatus: 404,
|
|
ExpectedContent: []string{"The requested resource wasn't found."},
|
|
TestAppFactory: testAppFactory,
|
|
Headers: map[string]string{
|
|
"Authorization": user2Token,
|
|
},
|
|
},
|
|
{
|
|
Name: "GET /systemd/info - with auth but missing system param should fail",
|
|
Method: http.MethodGet,
|
|
URL: "/api/beszel/systemd/info?service=nginx.service",
|
|
Headers: map[string]string{
|
|
"Authorization": userToken,
|
|
},
|
|
ExpectedStatus: 400,
|
|
ExpectedContent: []string{"Invalid", "parameter"},
|
|
TestAppFactory: testAppFactory,
|
|
},
|
|
{
|
|
Name: "GET /systemd/info - with auth but missing service param should fail",
|
|
Method: http.MethodGet,
|
|
URL: fmt.Sprintf("/api/beszel/systemd/info?system=%s", system.Id),
|
|
Headers: map[string]string{
|
|
"Authorization": userToken,
|
|
},
|
|
ExpectedStatus: 400,
|
|
ExpectedContent: []string{"Invalid", "parameter"},
|
|
TestAppFactory: testAppFactory,
|
|
},
|
|
{
|
|
Name: "GET /systemd/info - with auth but invalid system should fail",
|
|
Method: http.MethodGet,
|
|
URL: "/api/beszel/systemd/info?system=invalid-system&service=nginx.service",
|
|
Headers: map[string]string{
|
|
"Authorization": userToken,
|
|
},
|
|
ExpectedStatus: 404,
|
|
ExpectedContent: []string{"The requested resource wasn't found."},
|
|
TestAppFactory: testAppFactory,
|
|
},
|
|
{
|
|
Name: "GET /systemd/info - service not in systemd_services collection should fail",
|
|
Method: http.MethodGet,
|
|
URL: fmt.Sprintf("/api/beszel/systemd/info?system=%s&service=notregistered.service", system.Id),
|
|
Headers: map[string]string{
|
|
"Authorization": userToken,
|
|
},
|
|
ExpectedStatus: 404,
|
|
ExpectedContent: []string{"The requested resource wasn't found."},
|
|
TestAppFactory: testAppFactory,
|
|
},
|
|
{
|
|
Name: "GET /systemd/info - with auth and existing service record should pass validation",
|
|
Method: http.MethodGet,
|
|
URL: fmt.Sprintf("/api/beszel/systemd/info?system=%s&service=nginx.service", system.Id),
|
|
Headers: map[string]string{
|
|
"Authorization": userToken,
|
|
},
|
|
ExpectedStatus: 500,
|
|
ExpectedContent: []string{"Something went wrong while processing your request."},
|
|
TestAppFactory: testAppFactory,
|
|
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
|
|
beszelTests.CreateRecord(app, "systemd_services", map[string]any{
|
|
"system": system.Id,
|
|
"name": "nginx.service",
|
|
"state": 0,
|
|
"sub": 1,
|
|
})
|
|
},
|
|
},
|
|
|
|
// 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},
|
|
}),
|
|
},
|
|
// this works but diff behavior on prod vs dev.
|
|
// dev returns 502; prod returns 200 with static html page 404
|
|
// TODO: align dev and prod behavior and re-enable this test
|
|
// {
|
|
// Name: "GET /update - shouldn't exist without CHECK_UPDATES env var",
|
|
// Method: http.MethodGet,
|
|
// URL: "/api/beszel/update",
|
|
// NotExpectedContent: []string{"v:", "\"v\":"},
|
|
// ExpectedStatus: 502,
|
|
// TestAppFactory: testAppFactory,
|
|
// },
|
|
}
|
|
|
|
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 TestFirstUserBootstrapAtomicity(t *testing.T) {
|
|
t.Run("concurrent complete requests produce exactly one winner", func(t *testing.T) {
|
|
hub, handler := firstUserTestMux(t)
|
|
defer hub.Cleanup()
|
|
|
|
start := make(chan struct{})
|
|
statuses := make(chan int, 2)
|
|
for _, email := range []string{"first@example.com", "second@example.com"} {
|
|
go func(email string) {
|
|
<-start
|
|
statuses <- postFirstUser(handler, email).Code
|
|
}(email)
|
|
}
|
|
close(start)
|
|
|
|
got := []int{<-statuses, <-statuses}
|
|
sort.Ints(got)
|
|
require.Equal(t, []int{http.StatusOK, http.StatusForbidden}, got)
|
|
|
|
users, err := hub.FindAllRecords("users")
|
|
require.NoError(t, err)
|
|
require.Len(t, users, 1)
|
|
superusers, err := hub.FindAllRecords(core.CollectionNameSuperusers)
|
|
require.NoError(t, err)
|
|
require.Len(t, superusers, 1)
|
|
require.NotEqual(t, migrations.TempAdminEmail, superusers[0].Email())
|
|
})
|
|
|
|
t.Run("partial body cannot retain stale bootstrap authorization", func(t *testing.T) {
|
|
hub, handler := firstUserTestMux(t)
|
|
defer hub.Cleanup()
|
|
|
|
body, err := json.Marshal(map[string]string{
|
|
"email": "parked@example.com",
|
|
"password": "password123",
|
|
})
|
|
require.NoError(t, err)
|
|
gated := &gatedReader{
|
|
data: body,
|
|
started: make(chan struct{}),
|
|
release: make(chan struct{}),
|
|
}
|
|
parkedRequest := httptest.NewRequest(http.MethodPost, "/api/beszel/create-user", gated)
|
|
parkedRequest.Header.Set("Content-Type", "application/json")
|
|
parkedRecorder := httptest.NewRecorder()
|
|
parkedDone := make(chan struct{})
|
|
go func() {
|
|
handler.ServeHTTP(parkedRecorder, parkedRequest)
|
|
close(parkedDone)
|
|
}()
|
|
|
|
select {
|
|
case <-gated.started:
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("parked request did not begin reading its body")
|
|
}
|
|
|
|
operatorRecorder := postFirstUser(handler, "operator@example.com")
|
|
require.Equal(t, http.StatusOK, operatorRecorder.Code)
|
|
lateRecorder := postFirstUser(handler, "late@example.com")
|
|
require.Equal(t, http.StatusForbidden, lateRecorder.Code)
|
|
|
|
close(gated.release)
|
|
select {
|
|
case <-parkedDone:
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("parked request did not finish")
|
|
}
|
|
require.Equal(t, http.StatusForbidden, parkedRecorder.Code)
|
|
|
|
users, err := hub.FindAllRecords("users")
|
|
require.NoError(t, err)
|
|
require.Len(t, users, 1)
|
|
require.Equal(t, "operator@example.com", users[0].Email())
|
|
superusers, err := hub.FindAllRecords(core.CollectionNameSuperusers)
|
|
require.NoError(t, err)
|
|
require.Len(t, superusers, 1)
|
|
require.Equal(t, "operator@example.com", superusers[0].Email())
|
|
})
|
|
}
|
|
|
|
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 TestUpdateEndpoint(t *testing.T) {
|
|
t.Setenv("CHECK_UPDATES", "true")
|
|
|
|
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")
|
|
// userToken, err := user.NewAuthToken()
|
|
|
|
testAppFactory := func(t testing.TB) *pbTests.TestApp {
|
|
return hub.TestApp
|
|
}
|
|
|
|
scenarios := []beszelTests.ApiScenario{
|
|
{
|
|
Name: "update endpoint shouldn't work without auth",
|
|
Method: http.MethodGet,
|
|
URL: "/api/beszel/update",
|
|
ExpectedStatus: 401,
|
|
ExpectedContent: []string{"requires valid"},
|
|
TestAppFactory: testAppFactory,
|
|
},
|
|
// leave this out for now since it actually makes a request to github
|
|
// {
|
|
// Name: "GET /update - with valid auth should succeed",
|
|
// Method: http.MethodGet,
|
|
// URL: "/api/beszel/update",
|
|
// Headers: map[string]string{
|
|
// "Authorization": userToken,
|
|
// },
|
|
// ExpectedStatus: 200,
|
|
// ExpectedContent: []string{`"v":`},
|
|
// TestAppFactory: testAppFactory,
|
|
// },
|
|
}
|
|
|
|
for _, scenario := range scenarios {
|
|
scenario.Test(t)
|
|
}
|
|
}
|