diff --git a/beszel/internal/hub/config/config.go b/beszel/internal/hub/config/config.go index 650ea08..d70d09c 100644 --- a/beszel/internal/hub/config/config.go +++ b/beszel/internal/hub/config/config.go @@ -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 { diff --git a/beszel/internal/hub/hub.go b/beszel/internal/hub/hub.go index 492df1c..fa52ff1 100644 --- a/beszel/internal/hub/hub.go +++ b/beszel/internal/hub/hub.go @@ -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}) diff --git a/beszel/internal/hub/hub_test.go b/beszel/internal/hub/hub_test.go index 02618ce..1c0d2d1 100644 --- a/beszel/internal/hub/hub_test.go +++ b/beszel/internal/hub/hub_test.go @@ -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) + }) +} diff --git a/beszel/internal/tests/api.go b/beszel/internal/tests/api.go new file mode 100644 index 0000000..3c54f19 --- /dev/null +++ b/beszel/internal/tests/api.go @@ -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) + } +}