mirror of
https://github.com/fankes/beszel.git
synced 2025-10-19 01:39:34 +08:00
refactor: api router groups and auth handling
- require auth for `/api/beszel/getkey` - Change `GET /api/beszel/send-test-notification` endpoint to `POST /api/beszel/test-notification`. - add tests for API endpoints
This commit is contained in:
@@ -10,7 +10,6 @@ import (
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/apis"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/spf13/cast"
|
||||
"gopkg.in/yaml.v3"
|
||||
@@ -279,9 +278,8 @@ func createFingerprintRecord(app core.App, systemID, token string) error {
|
||||
|
||||
// Returns the current config.yml file as a JSON object
|
||||
func GetYamlConfig(e *core.RequestEvent) error {
|
||||
info, _ := e.RequestInfo()
|
||||
if info.Auth == nil || info.Auth.GetString("role") != "admin" {
|
||||
return apis.NewForbiddenError("Forbidden", nil)
|
||||
if e.Auth.GetString("role") != "admin" {
|
||||
return e.ForbiddenError("Requires admin role", nil)
|
||||
}
|
||||
configContent, err := generateYAML(e.App)
|
||||
if err != nil {
|
||||
|
@@ -224,27 +224,33 @@ func (h *Hub) registerCronJobs(_ *core.ServeEvent) error {
|
||||
|
||||
// custom api routes
|
||||
func (h *Hub) registerApiRoutes(se *core.ServeEvent) error {
|
||||
// returns public key and version
|
||||
se.Router.GET("/api/beszel/getkey", func(e *core.RequestEvent) error {
|
||||
info, _ := e.RequestInfo()
|
||||
if info.Auth == nil {
|
||||
return apis.NewForbiddenError("Forbidden", nil)
|
||||
}
|
||||
return e.JSON(http.StatusOK, map[string]string{"key": h.pubKey, "v": beszel.Version})
|
||||
})
|
||||
// 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
|
||||
se.Router.GET("/api/beszel/first-run", func(e *core.RequestEvent) error {
|
||||
total, err := h.CountRecords("users")
|
||||
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("/getkey", func(e *core.RequestEvent) error {
|
||||
return e.JSON(http.StatusOK, map[string]string{"key": h.pubKey, "v": beszel.Version})
|
||||
})
|
||||
// send test notification
|
||||
se.Router.GET("/api/beszel/send-test-notification", h.SendTestNotification)
|
||||
// API endpoint to get config.yml content
|
||||
se.Router.GET("/api/beszel/config-yaml", config.GetYamlConfig)
|
||||
apiAuth.POST("/test-notification", h.SendTestNotification)
|
||||
// get config.yml content
|
||||
apiAuth.GET("/config-yaml", config.GetYamlConfig)
|
||||
// handle agent websocket connection
|
||||
se.Router.GET("/api/beszel/agent-connect", h.handleAgentConnect)
|
||||
apiNoAuth.GET("/agent-connect", h.handleAgentConnect)
|
||||
// get or create universal tokens
|
||||
se.Router.GET("/api/beszel/universal-token", h.getUniversalToken)
|
||||
apiAuth.GET("/universal-token", h.getUniversalToken)
|
||||
// create first user endpoint only needed if no users exist
|
||||
if totalUsers, _ := h.CountRecords("users"); totalUsers == 0 {
|
||||
se.Router.POST("/api/beszel/create-user", h.um.CreateFirstUser)
|
||||
@@ -254,18 +260,12 @@ func (h *Hub) registerApiRoutes(se *core.ServeEvent) error {
|
||||
|
||||
// Handler for universal token API endpoint (create, read, delete)
|
||||
func (h *Hub) getUniversalToken(e *core.RequestEvent) error {
|
||||
info, err := e.RequestInfo()
|
||||
if err != nil || info.Auth == nil {
|
||||
return apis.NewForbiddenError("Forbidden", nil)
|
||||
}
|
||||
|
||||
tokenMap := universalTokenMap.GetMap()
|
||||
userID := info.Auth.Id
|
||||
userID := e.Auth.Id
|
||||
query := e.Request.URL.Query()
|
||||
token := query.Get("token")
|
||||
tokenSet := token != ""
|
||||
|
||||
if !tokenSet {
|
||||
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})
|
||||
|
@@ -4,27 +4,37 @@
|
||||
package hub_test
|
||||
|
||||
import (
|
||||
"beszel/internal/tests"
|
||||
beszelTests "beszel/internal/tests"
|
||||
"testing"
|
||||
|
||||
"bytes"
|
||||
"crypto/ed25519"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
func getTestHub(t testing.TB) *tests.TestHub {
|
||||
hub, _ := tests.NewTestHub(t.TempDir())
|
||||
return hub
|
||||
// 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 := getTestHub(t)
|
||||
hub, _ := beszelTests.NewTestHub(t.TempDir())
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -114,7 +124,7 @@ func TestMakeLink(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestGetSSHKey(t *testing.T) {
|
||||
hub := getTestHub(t)
|
||||
hub, _ := beszelTests.NewTestHub(t.TempDir())
|
||||
|
||||
// Test Case 1: Key generation (no existing key)
|
||||
t.Run("KeyGeneration", func(t *testing.T) {
|
||||
@@ -254,3 +264,340 @@ 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 /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"},
|
||||
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,
|
||||
})
|
||||
},
|
||||
},
|
||||
|
||||
// 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 /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 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)
|
||||
})
|
||||
}
|
||||
|
309
beszel/internal/tests/api.go
Normal file
309
beszel/internal/tests/api.go
Normal file
@@ -0,0 +1,309 @@
|
||||
package tests
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"maps"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/pocketbase/pocketbase/apis"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
pbtests "github.com/pocketbase/pocketbase/tests"
|
||||
"github.com/pocketbase/pocketbase/tools/hook"
|
||||
)
|
||||
|
||||
// NOTE: This is a copy of https://github.com/pocketbase/pocketbase/blob/master/tests/api.go
|
||||
// with the following changes:
|
||||
// - Removed automatic cleanup of the test app in ApiScenario.Test (Aug 17 2025)
|
||||
|
||||
// ApiScenario defines a single api request test case/scenario.
|
||||
type ApiScenario struct {
|
||||
// Name is the test name.
|
||||
Name string
|
||||
|
||||
// Method is the HTTP method of the test request to use.
|
||||
Method string
|
||||
|
||||
// URL is the url/path of the endpoint you want to test.
|
||||
URL string
|
||||
|
||||
// Body specifies the body to send with the request.
|
||||
//
|
||||
// For example:
|
||||
//
|
||||
// strings.NewReader(`{"title":"abc"}`)
|
||||
Body io.Reader
|
||||
|
||||
// Headers specifies the headers to send with the request (e.g. "Authorization": "abc")
|
||||
Headers map[string]string
|
||||
|
||||
// Delay adds a delay before checking the expectations usually
|
||||
// to ensure that all fired non-awaited go routines have finished
|
||||
Delay time.Duration
|
||||
|
||||
// Timeout specifies how long to wait before cancelling the request context.
|
||||
//
|
||||
// A zero or negative value means that there will be no timeout.
|
||||
Timeout time.Duration
|
||||
|
||||
// expectations
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
// ExpectedStatus specifies the expected response HTTP status code.
|
||||
ExpectedStatus int
|
||||
|
||||
// List of keywords that MUST exist in the response body.
|
||||
//
|
||||
// Either ExpectedContent or NotExpectedContent must be set if the response body is non-empty.
|
||||
// Leave both fields empty if you want to ensure that the response didn't have any body (e.g. 204).
|
||||
ExpectedContent []string
|
||||
|
||||
// List of keywords that MUST NOT exist in the response body.
|
||||
//
|
||||
// Either ExpectedContent or NotExpectedContent must be set if the response body is non-empty.
|
||||
// Leave both fields empty if you want to ensure that the response didn't have any body (e.g. 204).
|
||||
NotExpectedContent []string
|
||||
|
||||
// List of hook events to check whether they were fired or not.
|
||||
//
|
||||
// You can use the wildcard "*" event key if you want to ensure
|
||||
// that no other hook events except those listed have been fired.
|
||||
//
|
||||
// For example:
|
||||
//
|
||||
// map[string]int{ "*": 0 } // no hook events were fired
|
||||
// map[string]int{ "*": 0, "EventA": 2 } // no hook events, except EventA were fired
|
||||
// map[string]int{ "EventA": 2, "EventB": 0 } // ensures that EventA was fired exactly 2 times and EventB exactly 0 times.
|
||||
ExpectedEvents map[string]int
|
||||
|
||||
// test hooks
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
TestAppFactory func(t testing.TB) *pbtests.TestApp
|
||||
BeforeTestFunc func(t testing.TB, app *pbtests.TestApp, e *core.ServeEvent)
|
||||
AfterTestFunc func(t testing.TB, app *pbtests.TestApp, res *http.Response)
|
||||
}
|
||||
|
||||
// Test executes the test scenario.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// func TestListExample(t *testing.T) {
|
||||
// scenario := tests.ApiScenario{
|
||||
// Name: "list example collection",
|
||||
// Method: http.MethodGet,
|
||||
// URL: "/api/collections/example/records",
|
||||
// ExpectedStatus: 200,
|
||||
// ExpectedContent: []string{
|
||||
// `"totalItems":3`,
|
||||
// `"id":"0yxhwia2amd8gec"`,
|
||||
// `"id":"achvryl401bhse3"`,
|
||||
// `"id":"llvuca81nly1qls"`,
|
||||
// },
|
||||
// ExpectedEvents: map[string]int{
|
||||
// "OnRecordsListRequest": 1,
|
||||
// "OnRecordEnrich": 3,
|
||||
// },
|
||||
// }
|
||||
//
|
||||
// scenario.Test(t)
|
||||
// }
|
||||
func (scenario *ApiScenario) Test(t *testing.T) {
|
||||
t.Run(scenario.normalizedName(), func(t *testing.T) {
|
||||
scenario.test(t)
|
||||
})
|
||||
}
|
||||
|
||||
// Benchmark benchmarks the test scenario.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// func BenchmarkListExample(b *testing.B) {
|
||||
// scenario := tests.ApiScenario{
|
||||
// Name: "list example collection",
|
||||
// Method: http.MethodGet,
|
||||
// URL: "/api/collections/example/records",
|
||||
// ExpectedStatus: 200,
|
||||
// ExpectedContent: []string{
|
||||
// `"totalItems":3`,
|
||||
// `"id":"0yxhwia2amd8gec"`,
|
||||
// `"id":"achvryl401bhse3"`,
|
||||
// `"id":"llvuca81nly1qls"`,
|
||||
// },
|
||||
// ExpectedEvents: map[string]int{
|
||||
// "OnRecordsListRequest": 1,
|
||||
// "OnRecordEnrich": 3,
|
||||
// },
|
||||
// }
|
||||
//
|
||||
// scenario.Benchmark(b)
|
||||
// }
|
||||
func (scenario *ApiScenario) Benchmark(b *testing.B) {
|
||||
b.Run(scenario.normalizedName(), func(b *testing.B) {
|
||||
for i := 0; i < b.N; i++ {
|
||||
scenario.test(b)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func (scenario *ApiScenario) normalizedName() string {
|
||||
var name = scenario.Name
|
||||
|
||||
if name == "" {
|
||||
name = fmt.Sprintf("%s:%s", scenario.Method, scenario.URL)
|
||||
}
|
||||
|
||||
return name
|
||||
}
|
||||
|
||||
func (scenario *ApiScenario) test(t testing.TB) {
|
||||
var testApp *pbtests.TestApp
|
||||
if scenario.TestAppFactory != nil {
|
||||
testApp = scenario.TestAppFactory(t)
|
||||
if testApp == nil {
|
||||
t.Fatal("TestAppFactory must return a non-nill app instance")
|
||||
}
|
||||
} else {
|
||||
var testAppErr error
|
||||
testApp, testAppErr = pbtests.NewTestApp()
|
||||
if testAppErr != nil {
|
||||
t.Fatalf("Failed to initialize the test app instance: %v", testAppErr)
|
||||
}
|
||||
}
|
||||
// defer testApp.Cleanup()
|
||||
|
||||
baseRouter, err := apis.NewRouter(testApp)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// manually trigger the serve event to ensure that custom app routes and middlewares are registered
|
||||
serveEvent := new(core.ServeEvent)
|
||||
serveEvent.App = testApp
|
||||
serveEvent.Router = baseRouter
|
||||
|
||||
serveErr := testApp.OnServe().Trigger(serveEvent, func(e *core.ServeEvent) error {
|
||||
if scenario.BeforeTestFunc != nil {
|
||||
scenario.BeforeTestFunc(t, testApp, e)
|
||||
}
|
||||
|
||||
// reset the event counters in case a hook was triggered from a before func (eg. db save)
|
||||
testApp.ResetEventCalls()
|
||||
|
||||
// add middleware to timeout long-running requests (eg. keep-alive routes)
|
||||
e.Router.Bind(&hook.Handler[*core.RequestEvent]{
|
||||
Func: func(re *core.RequestEvent) error {
|
||||
slowTimer := time.AfterFunc(3*time.Second, func() {
|
||||
t.Logf("[WARN] Long running test %q", scenario.Name)
|
||||
})
|
||||
defer slowTimer.Stop()
|
||||
|
||||
if scenario.Timeout > 0 {
|
||||
ctx, cancelFunc := context.WithTimeout(re.Request.Context(), scenario.Timeout)
|
||||
defer cancelFunc()
|
||||
re.Request = re.Request.Clone(ctx)
|
||||
}
|
||||
|
||||
return re.Next()
|
||||
},
|
||||
Priority: -9999,
|
||||
})
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
req := httptest.NewRequest(scenario.Method, scenario.URL, scenario.Body)
|
||||
|
||||
// set default header
|
||||
req.Header.Set("content-type", "application/json")
|
||||
|
||||
// set scenario headers
|
||||
for k, v := range scenario.Headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
|
||||
// execute request
|
||||
mux, err := e.Router.BuildMux()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to build router mux: %v", err)
|
||||
}
|
||||
mux.ServeHTTP(recorder, req)
|
||||
|
||||
res := recorder.Result()
|
||||
|
||||
if res.StatusCode != scenario.ExpectedStatus {
|
||||
t.Errorf("Expected status code %d, got %d", scenario.ExpectedStatus, res.StatusCode)
|
||||
}
|
||||
|
||||
if scenario.Delay > 0 {
|
||||
time.Sleep(scenario.Delay)
|
||||
}
|
||||
|
||||
if len(scenario.ExpectedContent) == 0 && len(scenario.NotExpectedContent) == 0 {
|
||||
if len(recorder.Body.Bytes()) != 0 {
|
||||
t.Errorf("Expected empty body, got \n%v", recorder.Body.String())
|
||||
}
|
||||
} else {
|
||||
// normalize json response format
|
||||
buffer := new(bytes.Buffer)
|
||||
err := json.Compact(buffer, recorder.Body.Bytes())
|
||||
var normalizedBody string
|
||||
if err != nil {
|
||||
// not a json...
|
||||
normalizedBody = recorder.Body.String()
|
||||
} else {
|
||||
normalizedBody = buffer.String()
|
||||
}
|
||||
|
||||
for _, item := range scenario.ExpectedContent {
|
||||
if !strings.Contains(normalizedBody, item) {
|
||||
t.Errorf("Cannot find %v in response body \n%v", item, normalizedBody)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
for _, item := range scenario.NotExpectedContent {
|
||||
if strings.Contains(normalizedBody, item) {
|
||||
t.Errorf("Didn't expect %v in response body \n%v", item, normalizedBody)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
remainingEvents := maps.Clone(testApp.EventCalls)
|
||||
|
||||
var noOtherEventsShouldRemain bool
|
||||
for event, expectedNum := range scenario.ExpectedEvents {
|
||||
if event == "*" && expectedNum <= 0 {
|
||||
noOtherEventsShouldRemain = true
|
||||
continue
|
||||
}
|
||||
|
||||
actualNum := remainingEvents[event]
|
||||
if actualNum != expectedNum {
|
||||
t.Errorf("Expected event %s to be called %d, got %d", event, expectedNum, actualNum)
|
||||
}
|
||||
|
||||
delete(remainingEvents, event)
|
||||
}
|
||||
|
||||
if noOtherEventsShouldRemain && len(remainingEvents) > 0 {
|
||||
t.Errorf("Missing expected remaining events:\n%#v\nAll triggered app events are:\n%#v", remainingEvents, testApp.EventCalls)
|
||||
}
|
||||
|
||||
if scenario.AfterTestFunc != nil {
|
||||
scenario.AfterTestFunc(t, testApp, res)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if serveErr != nil {
|
||||
t.Fatalf("Failed to trigger app serve hook: %v", serveErr)
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user