From 9bfcdd086aa19e0c16450d5e3c4d9ecda8e7981b Mon Sep 17 00:00:00 2001 From: Gani Georgiev Date: Fri, 23 Jun 2023 22:20:13 +0300 Subject: [PATCH] replaced .* errors with constructors and added apisBinds tests --- apis/record_helpers.go | 4 +- plugins/jsvm/internal/docs/docs.go | 25 +++++-- plugins/jsvm/vm.go | 56 +++++++------- plugins/jsvm/vm_test.go | 114 +++++++++++++++++++++++++++-- 4 files changed, 161 insertions(+), 38 deletions(-) diff --git a/apis/record_helpers.go b/apis/record_helpers.go index a556870f..d8d033d0 100644 --- a/apis/record_helpers.go +++ b/apis/record_helpers.go @@ -57,8 +57,8 @@ func RequestData(c echo.Context) *models.RequestData { return result } -// RecordAuthResponse generates and writes a properly formatted record -// auth response into the specified request context. +// RecordAuthResponse writes standardised json record auth response +// into the specified request context. func RecordAuthResponse( app core.App, c echo.Context, diff --git a/plugins/jsvm/internal/docs/docs.go b/plugins/jsvm/internal/docs/docs.go index c67b6256..771dd694 100644 --- a/plugins/jsvm/internal/docs/docs.go +++ b/plugins/jsvm/internal/docs/docs.go @@ -244,9 +244,28 @@ declare class ApiError implements apis.ApiError { constructor(status?: number, message?: string, data?: any) } +interface NotFoundError extends apis.NotFoundError{} // merge +declare class NotFoundError implements apis.NotFoundError { + constructor(message?: string, data?: any) +} + +interface BadRequestError extends apis.BadRequestError{} // merge +declare class BadRequestError implements apis.BadRequestError { + constructor(message?: string, data?: any) +} + +interface ForbiddenError extends apis.ForbiddenError{} // merge +declare class ForbiddenError implements apis.ForbiddenError { + constructor(message?: string, data?: any) +} + +interface UnauthorizedError extends apis.UnauthorizedError{} // merge +declare class UnauthorizedError implements apis.UnauthorizedError { + constructor(message?: string, data?: any) +} + declare namespace $apis { let requireRecordAuth: apis.requireRecordAuth - let requireSameContextRecordAuth: apis.requireSameContextRecordAuth let requireAdminAuth: apis.requireAdminAuth let requireAdminAuthOnlyIfAny: apis.requireAdminAuthOnlyIfAny let requireAdminOrRecordAuth: apis.requireAdminOrRecordAuth @@ -256,10 +275,6 @@ declare namespace $apis { let recordAuthResponse: apis.recordAuthResponse let enrichRecord: apis.enrichRecord let enrichRecords: apis.enrichRecords - let notFoundError: apis.newNotFoundError - let badRequestError: apis.newBadRequestError - let forbiddenError: apis.newForbiddenError - let unauthorizedError: apis.newUnauthorizedError } ` diff --git a/plugins/jsvm/vm.go b/plugins/jsvm/vm.go index 174096d3..18420160 100644 --- a/plugins/jsvm/vm.go +++ b/plugins/jsvm/vm.go @@ -261,11 +261,15 @@ func formsBinds(vm *goja.Runtime) { registerFactoryAsConstructor(vm, "TestS3FilesystemForm", forms.NewTestS3Filesystem) } -// @todo add tests func apisBinds(vm *goja.Runtime) { obj := vm.NewObject() vm.Set("$apis", obj) + vm.Set("Route", func(call goja.ConstructorCall) *goja.Object { + instance := &echo.Route{} + return structConstructor(vm, call, instance) + }) + // middlewares obj.Set("requireRecordAuth", apis.RequireRecordAuth) obj.Set("requireAdminAuth", apis.RequireAdminAuth) @@ -281,42 +285,42 @@ func apisBinds(vm *goja.Runtime) { obj.Set("enrichRecords", apis.EnrichRecords) // api errors - vm.Set("ApiError", func(call goja.ConstructorCall) *goja.Object { - status, _ := call.Argument(0).Export().(int64) - message, _ := call.Argument(1).Export().(string) - data := call.Argument(2).Export() - - instance := apis.NewApiError(int(status), message, data) - instanceValue := vm.ToValue(instance).(*goja.Object) - instanceValue.SetPrototype(call.This.Prototype()) - - return instanceValue - }) - obj.Set("notFoundError", apis.NewNotFoundError) - obj.Set("badRequestError", apis.NewBadRequestError) - obj.Set("forbiddenError", apis.NewForbiddenError) - obj.Set("unauthorizedError", apis.NewUnauthorizedError) - - vm.Set("Route", func(call goja.ConstructorCall) *goja.Object { - instance := &echo.Route{} - return structConstructor(vm, call, instance) - }) + registerFactoryAsConstructor(vm, "ApiError", apis.NewApiError) + registerFactoryAsConstructor(vm, "NotFoundError", apis.NewNotFoundError) + registerFactoryAsConstructor(vm, "BadRequestError", apis.NewBadRequestError) + registerFactoryAsConstructor(vm, "ForbiddenError", apis.NewForbiddenError) + registerFactoryAsConstructor(vm, "UnauthorizedError", apis.NewUnauthorizedError) } // ------------------------------------------------------------------- // registerFactoryAsConstructor registers the factory function as native JS constructor. +// +// If there is missing or nil arguments, their type zero value is used. func registerFactoryAsConstructor(vm *goja.Runtime, constructorName string, factoryFunc any) { + rv := reflect.ValueOf(factoryFunc) + rt := reflect.TypeOf(factoryFunc) + totalArgs := rt.NumIn() + vm.Set(constructorName, func(call goja.ConstructorCall) *goja.Object { - f := reflect.ValueOf(factoryFunc) + args := make([]reflect.Value, totalArgs) - args := []reflect.Value{} + for i := 0; i < totalArgs; i++ { + v := call.Argument(i).Export() - for _, v := range call.Arguments { - args = append(args, reflect.ValueOf(v.Export())) + // use the arg type zero value + if v == nil { + args[i] = reflect.New(rt.In(i)).Elem() + } else if number, ok := v.(int64); ok { + // goja uses int64 for "int"-like numbers but we rarely do that and use int most of the times + // (at later stage we can use reflection on the arguments to validate the types in case this is not sufficient anymore) + args[i] = reflect.ValueOf(int(number)) + } else { + args[i] = reflect.ValueOf(v) + } } - result := f.Call(args) + result := rv.Call(args) if len(result) != 1 { panic("the factory function should return only 1 item") diff --git a/plugins/jsvm/vm_test.go b/plugins/jsvm/vm_test.go index e810dc52..1a6ad7b0 100644 --- a/plugins/jsvm/vm_test.go +++ b/plugins/jsvm/vm_test.go @@ -4,11 +4,13 @@ import ( "encoding/json" "mime/multipart" "path/filepath" + "strings" "testing" "github.com/dop251/goja" validation "github.com/go-ozzo/ozzo-validation/v4" "github.com/pocketbase/dbx" + "github.com/pocketbase/pocketbase/apis" "github.com/pocketbase/pocketbase/daos" "github.com/pocketbase/pocketbase/models" "github.com/pocketbase/pocketbase/models/schema" @@ -18,6 +20,19 @@ import ( "github.com/pocketbase/pocketbase/tools/security" ) +func testBindsCount(vm *goja.Runtime, namespace string, count int, t *testing.T) { + v, err := vm.RunString(`Object.keys(` + namespace + `).length`) + if err != nil { + t.Fatal(err) + } + + total, _ := v.Export().(int64) + + if int(total) != count { + t.Fatalf("Expected %d %s binds, got %d", count, namespace, total) + } +} + // note: this test is useful as a reminder to update the tests in case // a new base binding is added. func TestBaseBindsCount(t *testing.T) { @@ -624,16 +639,105 @@ func TestFormsBinds(t *testing.T) { testBindsCount(vm, "this", 20, t) } -func testBindsCount(vm *goja.Runtime, namespace string, count int, t *testing.T) { - v, err := vm.RunString(`Object.keys(` + namespace + `).length`) +func TestApisBindsCount(t *testing.T) { + app, _ := tests.NewTestApp() + defer app.Cleanup() + + vm := goja.New() + apisBinds(vm) + + testBindsCount(vm, "this", 7, t) + testBindsCount(vm, "$apis", 10, t) +} + +func TestApisBindsRoute(t *testing.T) { + app, _ := tests.NewTestApp() + defer app.Cleanup() + + vm := goja.New() + baseBinds(vm) + apisBinds(vm) + + _, err := vm.RunString(` + let handlerCalls = 0; + + const route = new Route({ + method: "test_method", + path: "test_path", + handler: () => { + handlerCalls++; + } + }); + + route.handler(); + + if (handlerCalls != 1) { + throw new Error('Expected handlerCalls 1, got ' + handlerCalls); + } + + if (route.method != "test_method") { + throw new Error('Expected method "test_method", got ' + route.method); + } + + if (route.path != "test_path") { + throw new Error('Expected method "test_path", got ' + route.path); + } + `) if err != nil { t.Fatal(err) } +} - total, _ := v.Export().(int64) +func TestApisBindsApiError(t *testing.T) { + app, _ := tests.NewTestApp() + defer app.Cleanup() - if int(total) != count { - t.Fatalf("Expected %d %s binds, got %d", count, namespace, total) + vm := goja.New() + apisBinds(vm) + + scenarios := []struct { + js string + expectCode int + expectMessage string + expectData string + }{ + {"new ApiError()", 0, "", "null"}, + {"new ApiError(100, 'test', {'test': 1})", 100, "Test.", `{"test":1}`}, + {"new NotFoundError()", 404, "The requested resource wasn't found.", "null"}, + {"new NotFoundError('test', {'test': 1})", 404, "Test.", `{"test":1}`}, + {"new BadRequestError()", 400, "Something went wrong while processing your request.", "null"}, + {"new BadRequestError('test', {'test': 1})", 400, "Test.", `{"test":1}`}, + {"new ForbiddenError()", 403, "You are not allowed to perform this request.", "null"}, + {"new ForbiddenError('test', {'test': 1})", 403, "Test.", `{"test":1}`}, + {"new UnauthorizedError()", 401, "Missing or invalid authentication token.", "null"}, + {"new UnauthorizedError('test', {'test': 1})", 401, "Test.", `{"test":1}`}, + } + + for _, s := range scenarios { + v, err := vm.RunString(s.js) + if err != nil { + t.Errorf("[%s] %v", s.js, err) + continue + } + + apiErr, ok := v.Export().(*apis.ApiError) + if !ok { + t.Errorf("[%s] Expected ApiError, got %v", s.js, v) + continue + } + + if apiErr.Code != s.expectCode { + t.Errorf("[%s] Expected Code %d, got %d", s.js, s.expectCode, apiErr.Code) + } + + if !strings.Contains(apiErr.Message, s.expectMessage) { + t.Errorf("[%s] Expected Message %q, got %q", s.js, s.expectMessage, apiErr.Message) + } + + dataRaw, _ := json.Marshal(apiErr.RawData()) + if string(dataRaw) != s.expectData { + t.Errorf("[%s] Expected Data %q, got %q", s.js, s.expectData, dataRaw) + } } }