initial v0.8 pre-release

This commit is contained in:
Gani Georgiev
2022-10-30 10:28:14 +02:00
parent 9cbb2e750e
commit 90dba45d7c
388 changed files with 21580 additions and 13603 deletions
+26 -27
View File
@@ -9,20 +9,19 @@ import (
"github.com/pocketbase/pocketbase/forms"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/tokens"
"github.com/pocketbase/pocketbase/tools/rest"
"github.com/pocketbase/pocketbase/tools/routine"
"github.com/pocketbase/pocketbase/tools/search"
)
// BindAdminApi registers the admin api endpoints and the corresponding handlers.
func BindAdminApi(app core.App, rg *echo.Group) {
// bindAdminApi registers the admin api endpoints and the corresponding handlers.
func bindAdminApi(app core.App, rg *echo.Group) {
api := adminApi{app: app}
subGroup := rg.Group("/admins", ActivityLogger(app))
subGroup.POST("/auth-via-email", api.emailAuth, RequireGuestOnly())
subGroup.POST("/auth-with-password", api.authWithPassword, RequireGuestOnly())
subGroup.POST("/request-password-reset", api.requestPasswordReset)
subGroup.POST("/confirm-password-reset", api.confirmPasswordReset)
subGroup.POST("/refresh", api.refresh, RequireAdminAuth())
subGroup.POST("/auth-refresh", api.authRefresh, RequireAdminAuth())
subGroup.GET("", api.list, RequireAdminAuth())
subGroup.POST("", api.create, RequireAdminAuthOnlyIfAny(app))
subGroup.GET("/:id", api.view, RequireAdminAuth())
@@ -37,7 +36,7 @@ type adminApi struct {
func (api *adminApi) authResponse(c echo.Context, admin *models.Admin) error {
token, tokenErr := tokens.NewAdminAuthToken(api.app, admin)
if tokenErr != nil {
return rest.NewBadRequestError("Failed to create auth token.", tokenErr)
return NewBadRequestError("Failed to create auth token.", tokenErr)
}
event := &core.AdminAuthEvent{
@@ -54,24 +53,24 @@ func (api *adminApi) authResponse(c echo.Context, admin *models.Admin) error {
})
}
func (api *adminApi) refresh(c echo.Context) error {
func (api *adminApi) authRefresh(c echo.Context) error {
admin, _ := c.Get(ContextAdminKey).(*models.Admin)
if admin == nil {
return rest.NewNotFoundError("Missing auth admin context.", nil)
return NewNotFoundError("Missing auth admin context.", nil)
}
return api.authResponse(c, admin)
}
func (api *adminApi) emailAuth(c echo.Context) error {
func (api *adminApi) authWithPassword(c echo.Context) error {
form := forms.NewAdminLogin(api.app)
if readErr := c.Bind(form); readErr != nil {
return rest.NewBadRequestError("An error occurred while loading the submitted data.", readErr)
return NewBadRequestError("An error occurred while loading the submitted data.", readErr)
}
admin, submitErr := form.Submit()
if submitErr != nil {
return rest.NewBadRequestError("Failed to authenticate.", submitErr)
return NewBadRequestError("Failed to authenticate.", submitErr)
}
return api.authResponse(c, admin)
@@ -80,11 +79,11 @@ func (api *adminApi) emailAuth(c echo.Context) error {
func (api *adminApi) requestPasswordReset(c echo.Context) error {
form := forms.NewAdminPasswordResetRequest(api.app)
if err := c.Bind(form); err != nil {
return rest.NewBadRequestError("An error occurred while loading the submitted data.", err)
return NewBadRequestError("An error occurred while loading the submitted data.", err)
}
if err := form.Validate(); err != nil {
return rest.NewBadRequestError("An error occurred while validating the form.", err)
return NewBadRequestError("An error occurred while validating the form.", err)
}
// run in background because we don't need to show the result
@@ -101,12 +100,12 @@ func (api *adminApi) requestPasswordReset(c echo.Context) error {
func (api *adminApi) confirmPasswordReset(c echo.Context) error {
form := forms.NewAdminPasswordResetConfirm(api.app)
if readErr := c.Bind(form); readErr != nil {
return rest.NewBadRequestError("An error occurred while loading the submitted data.", readErr)
return NewBadRequestError("An error occurred while loading the submitted data.", readErr)
}
admin, submitErr := form.Submit()
if submitErr != nil {
return rest.NewBadRequestError("Failed to set new password.", submitErr)
return NewBadRequestError("Failed to set new password.", submitErr)
}
return api.authResponse(c, admin)
@@ -124,7 +123,7 @@ func (api *adminApi) list(c echo.Context) error {
ParseAndExec(c.QueryString(), &admins)
if err != nil {
return rest.NewBadRequestError("", err)
return NewBadRequestError("", err)
}
event := &core.AdminsListEvent{
@@ -141,12 +140,12 @@ func (api *adminApi) list(c echo.Context) error {
func (api *adminApi) view(c echo.Context) error {
id := c.PathParam("id")
if id == "" {
return rest.NewNotFoundError("", nil)
return NewNotFoundError("", nil)
}
admin, err := api.app.Dao().FindAdminById(id)
if err != nil || admin == nil {
return rest.NewNotFoundError("", err)
return NewNotFoundError("", err)
}
event := &core.AdminViewEvent{
@@ -166,7 +165,7 @@ func (api *adminApi) create(c echo.Context) error {
// load request
if err := c.Bind(form); err != nil {
return rest.NewBadRequestError("Failed to load the submitted data due to invalid formatting.", err)
return NewBadRequestError("Failed to load the submitted data due to invalid formatting.", err)
}
event := &core.AdminCreateEvent{
@@ -179,7 +178,7 @@ func (api *adminApi) create(c echo.Context) error {
return func() error {
return api.app.OnAdminBeforeCreateRequest().Trigger(event, func(e *core.AdminCreateEvent) error {
if err := next(); err != nil {
return rest.NewBadRequestError("Failed to create admin.", err)
return NewBadRequestError("Failed to create admin.", err)
}
return e.HttpContext.JSON(http.StatusOK, e.Admin)
@@ -197,19 +196,19 @@ func (api *adminApi) create(c echo.Context) error {
func (api *adminApi) update(c echo.Context) error {
id := c.PathParam("id")
if id == "" {
return rest.NewNotFoundError("", nil)
return NewNotFoundError("", nil)
}
admin, err := api.app.Dao().FindAdminById(id)
if err != nil || admin == nil {
return rest.NewNotFoundError("", err)
return NewNotFoundError("", err)
}
form := forms.NewAdminUpsert(api.app, admin)
// load request
if err := c.Bind(form); err != nil {
return rest.NewBadRequestError("Failed to load the submitted data due to invalid formatting.", err)
return NewBadRequestError("Failed to load the submitted data due to invalid formatting.", err)
}
event := &core.AdminUpdateEvent{
@@ -222,7 +221,7 @@ func (api *adminApi) update(c echo.Context) error {
return func() error {
return api.app.OnAdminBeforeUpdateRequest().Trigger(event, func(e *core.AdminUpdateEvent) error {
if err := next(); err != nil {
return rest.NewBadRequestError("Failed to update admin.", err)
return NewBadRequestError("Failed to update admin.", err)
}
return e.HttpContext.JSON(http.StatusOK, e.Admin)
@@ -240,12 +239,12 @@ func (api *adminApi) update(c echo.Context) error {
func (api *adminApi) delete(c echo.Context) error {
id := c.PathParam("id")
if id == "" {
return rest.NewNotFoundError("", nil)
return NewNotFoundError("", nil)
}
admin, err := api.app.Dao().FindAdminById(id)
if err != nil || admin == nil {
return rest.NewNotFoundError("", err)
return NewNotFoundError("", err)
}
event := &core.AdminDeleteEvent{
@@ -255,7 +254,7 @@ func (api *adminApi) delete(c echo.Context) error {
handlerErr := api.app.OnAdminBeforeDeleteRequest().Trigger(event, func(e *core.AdminDeleteEvent) error {
if err := api.app.Dao().DeleteAdmin(e.Admin); err != nil {
return rest.NewBadRequestError("Failed to delete admin.", err)
return NewBadRequestError("Failed to delete admin.", err)
}
return e.HttpContext.NoContent(http.StatusNoContent)
+184 -120
View File
@@ -14,39 +14,47 @@ import (
"github.com/pocketbase/pocketbase/tools/types"
)
func TestAdminAuth(t *testing.T) {
func TestAdminAuthWithEmail(t *testing.T) {
scenarios := []tests.ApiScenario{
{
Name: "empty data",
Method: http.MethodPost,
Url: "/api/admins/auth-via-email",
Url: "/api/admins/auth-with-password",
Body: strings.NewReader(``),
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{"email":{"code":"validation_required","message":"Cannot be blank."},"password":{"code":"validation_required","message":"Cannot be blank."}}`},
ExpectedContent: []string{`"data":{"identity":{"code":"validation_required","message":"Cannot be blank."},"password":{"code":"validation_required","message":"Cannot be blank."}}`},
},
{
Name: "invalid data",
Method: http.MethodPost,
Url: "/api/admins/auth-via-email",
Url: "/api/admins/auth-with-password",
Body: strings.NewReader(`{`),
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "wrong email/password",
Name: "wrong email",
Method: http.MethodPost,
Url: "/api/admins/auth-via-email",
Body: strings.NewReader(`{"email":"missing@example.com","password":"wrong_pass"}`),
Url: "/api/admins/auth-with-password",
Body: strings.NewReader(`{"identity":"missing@example.com","password":"1234567890"}`),
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "wrong password",
Method: http.MethodPost,
Url: "/api/admins/auth-with-password",
Body: strings.NewReader(`{"identity":"test@example.com","password":"invalid"}`),
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "valid email/password (already authorized)",
Method: http.MethodPost,
Url: "/api/admins/auth-via-email",
Body: strings.NewReader(`{"email":"test@example.com","password":"1234567890"}`),
Url: "/api/admins/auth-with-password",
Body: strings.NewReader(`{"identity":"test@example.com","password":"1234567890"}`),
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4MTYwMH0.han3_sG65zLddpcX2ic78qgy7FKecuPfOpFa8Dvi5Bg",
},
ExpectedStatus: 400,
ExpectedContent: []string{`"message":"The request can be accessed only by guests.","data":{}`},
@@ -54,11 +62,11 @@ func TestAdminAuth(t *testing.T) {
{
Name: "valid email/password (guest)",
Method: http.MethodPost,
Url: "/api/admins/auth-via-email",
Body: strings.NewReader(`{"email":"test@example.com","password":"1234567890"}`),
Url: "/api/admins/auth-with-password",
Body: strings.NewReader(`{"identity":"test@example.com","password":"1234567890"}`),
ExpectedStatus: 200,
ExpectedContent: []string{
`"admin":{"id":"2b4a97cc-3f83-4d01-a26b-3d77bc842d3c"`,
`"admin":{"id":"sywbhecnh46rhm0"`,
`"token":`,
},
ExpectedEvents: map[string]int{
@@ -158,21 +166,41 @@ func TestAdminConfirmPasswordReset(t *testing.T) {
ExpectedContent: []string{`"data":{}`},
},
{
Name: "expired token",
Method: http.MethodPost,
Url: "/api/admins/confirm-password-reset",
Body: strings.NewReader(`{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTY0MTAxMzIwMH0.Gp_1b5WVhqjj2o3nJhNUlJmpdiwFLXN72LbMP-26gjA","password":"1234567890","passwordConfirm":"1234567890"}`),
Name: "expired token",
Method: http.MethodPost,
Url: "/api/admins/confirm-password-reset",
Body: strings.NewReader(`{
"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImV4cCI6MTY0MDk5MTY2MX0.GLwCOsgWTTEKXTK-AyGW838de1OeZGIjfHH0FoRLqZg",
"password":"1234567890",
"passwordConfirm":"1234567890"
}`),
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{"token":{"code":"validation_invalid_token","message":"Invalid or expired token."}}}`},
},
{
Name: "valid token",
Method: http.MethodPost,
Url: "/api/admins/confirm-password-reset",
Body: strings.NewReader(`{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg5MzQ3NDAwMH0.72IhlL_5CpNGE0ZKM7sV9aAKa3wxQaMZdDiHBo0orpw","password":"1234567890","passwordConfirm":"1234567890"}`),
Name: "valid token + invalid password",
Method: http.MethodPost,
Url: "/api/admins/confirm-password-reset",
Body: strings.NewReader(`{
"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImV4cCI6MjIwODk4MTYwMH0.kwFEler6KSMKJNstuaSDvE1QnNdCta5qSnjaIQ0hhhc",
"password":"123456",
"passwordConfirm":"123456"
}`),
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{"password":{"code":"validation_length_out_of_range"`},
},
{
Name: "valid token + valid password",
Method: http.MethodPost,
Url: "/api/admins/confirm-password-reset",
Body: strings.NewReader(`{
"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImV4cCI6MjIwODk4MTYwMH0.kwFEler6KSMKJNstuaSDvE1QnNdCta5qSnjaIQ0hhhc",
"password":"1234567891",
"passwordConfirm":"1234567891"
}`),
ExpectedStatus: 200,
ExpectedContent: []string{
`"admin":{"id":"2b4a97cc-3f83-4d01-a26b-3d77bc842d3c"`,
`"admin":{"id":"sywbhecnh46rhm0"`,
`"token":`,
},
ExpectedEvents: map[string]int{
@@ -193,30 +221,40 @@ func TestAdminRefresh(t *testing.T) {
{
Name: "unauthorized",
Method: http.MethodPost,
Url: "/api/admins/refresh",
Url: "/api/admins/auth-refresh",
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "authorized as user",
Method: http.MethodPost,
Url: "/api/admins/refresh",
Url: "/api/admins/auth-refresh",
RequestHeaders: map[string]string{
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
},
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "authorized as admin",
Name: "authorized as admin (expired token)",
Method: http.MethodPost,
Url: "/api/admins/refresh",
Url: "/api/admins/auth-refresh",
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTY0MDk5MTY2MX0.I7w8iktkleQvC7_UIRpD7rNzcU4OnF7i7SFIUu6lD_4",
},
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "authorized as admin (valid token)",
Method: http.MethodPost,
Url: "/api/admins/auth-refresh",
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"admin":{"id":"2b4a97cc-3f83-4d01-a26b-3d77bc842d3c"`,
`"admin":{"id":"sywbhecnh46rhm0"`,
`"token":`,
},
ExpectedEvents: map[string]int{
@@ -244,7 +282,7 @@ func TestAdminsList(t *testing.T) {
Method: http.MethodGet,
Url: "/api/admins",
RequestHeaders: map[string]string{
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
},
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
@@ -254,16 +292,17 @@ func TestAdminsList(t *testing.T) {
Method: http.MethodGet,
Url: "/api/admins",
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"page":1`,
`"perPage":30`,
`"totalItems":2`,
`"totalItems":3`,
`"items":[{`,
`"id":"2b4a97cc-3f83-4d01-a26b-3d77bc842d3c"`,
`"id":"3f8397cc-2b4a-a26b-4d01-42d3c3d77bc8"`,
`"id":"sywbhecnh46rhm0"`,
`"id":"sbmbsdb40jyxf7h"`,
`"id":"9q2trqumvlyr3bd"`,
},
ExpectedEvents: map[string]int{
"OnAdminsListRequest": 1,
@@ -274,15 +313,19 @@ func TestAdminsList(t *testing.T) {
Method: http.MethodGet,
Url: "/api/admins?page=2&perPage=1&sort=-created",
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"page":2`,
`"perPage":1`,
`"totalItems":2`,
`"totalItems":3`,
`"items":[{`,
`"id":"2b4a97cc-3f83-4d01-a26b-3d77bc842d3c"`,
`"id":"sbmbsdb40jyxf7h"`,
},
NotExpectedContent: []string{
`"tokenKey"`,
`"passwordHash"`,
},
ExpectedEvents: map[string]int{
"OnAdminsListRequest": 1,
@@ -293,7 +336,7 @@ func TestAdminsList(t *testing.T) {
Method: http.MethodGet,
Url: "/api/admins?filter=invalidfield~'test2'",
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
@@ -301,9 +344,9 @@ func TestAdminsList(t *testing.T) {
{
Name: "authorized as admin + valid filter",
Method: http.MethodGet,
Url: "/api/admins?filter=email~'test2'",
Url: "/api/admins?filter=email~'test3'",
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 200,
ExpectedContent: []string{
@@ -311,7 +354,11 @@ func TestAdminsList(t *testing.T) {
`"perPage":30`,
`"totalItems":1`,
`"items":[{`,
`"id":"3f8397cc-2b4a-a26b-4d01-42d3c3d77bc8"`,
`"id":"9q2trqumvlyr3bd"`,
},
NotExpectedContent: []string{
`"tokenKey"`,
`"passwordHash"`,
},
ExpectedEvents: map[string]int{
"OnAdminsListRequest": 1,
@@ -329,36 +376,26 @@ func TestAdminView(t *testing.T) {
{
Name: "unauthorized",
Method: http.MethodGet,
Url: "/api/admins/3f8397cc-2b4a-a26b-4d01-42d3c3d77bc8",
Url: "/api/admins/sbmbsdb40jyxf7h",
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "authorized as user",
Method: http.MethodGet,
Url: "/api/admins/3f8397cc-2b4a-a26b-4d01-42d3c3d77bc8",
Url: "/api/admins/sbmbsdb40jyxf7h",
RequestHeaders: map[string]string{
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
},
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "authorized as admin + invalid admin id",
Method: http.MethodGet,
Url: "/api/admins/invalid",
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
},
ExpectedStatus: 404,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "authorized as admin + nonexisting admin id",
Method: http.MethodGet,
Url: "/api/admins/b97ccf83-34a2-4d01-a26b-3d77bc842d3c",
Url: "/api/admins/nonexisting",
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 404,
ExpectedContent: []string{`"data":{}`},
@@ -366,13 +403,17 @@ func TestAdminView(t *testing.T) {
{
Name: "authorized as admin + existing admin id",
Method: http.MethodGet,
Url: "/api/admins/3f8397cc-2b4a-a26b-4d01-42d3c3d77bc8",
Url: "/api/admins/sbmbsdb40jyxf7h",
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"id":"3f8397cc-2b4a-a26b-4d01-42d3c3d77bc8"`,
`"id":"sbmbsdb40jyxf7h"`,
},
NotExpectedContent: []string{
`"tokenKey"`,
`"passwordHash"`,
},
ExpectedEvents: map[string]int{
"OnAdminViewRequest": 1,
@@ -390,36 +431,26 @@ func TestAdminDelete(t *testing.T) {
{
Name: "unauthorized",
Method: http.MethodDelete,
Url: "/api/admins/3f8397cc-2b4a-a26b-4d01-42d3c3d77bc8",
Url: "/api/admins/sbmbsdb40jyxf7h",
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "authorized as user",
Method: http.MethodDelete,
Url: "/api/admins/3f8397cc-2b4a-a26b-4d01-42d3c3d77bc8",
Url: "/api/admins/sbmbsdb40jyxf7h",
RequestHeaders: map[string]string{
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
},
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "authorized as admin + invalid admin id",
Name: "authorized as admin + missing admin id",
Method: http.MethodDelete,
Url: "/api/admins/invalid",
Url: "/api/admins/missing",
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
},
ExpectedStatus: 404,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "authorized as admin + nonexisting admin id",
Method: http.MethodDelete,
Url: "/api/admins/b97ccf83-34a2-4d01-a26b-3d77bc842d3c",
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 404,
ExpectedContent: []string{`"data":{}`},
@@ -427,9 +458,9 @@ func TestAdminDelete(t *testing.T) {
{
Name: "authorized as admin + existing admin id",
Method: http.MethodDelete,
Url: "/api/admins/3f8397cc-2b4a-a26b-4d01-42d3c3d77bc8",
Url: "/api/admins/sbmbsdb40jyxf7h",
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 204,
ExpectedEvents: map[string]int{
@@ -442,15 +473,15 @@ func TestAdminDelete(t *testing.T) {
{
Name: "authorized as admin - try to delete the only remaining admin",
Method: http.MethodDelete,
Url: "/api/admins/2b4a97cc-3f83-4d01-a26b-3d77bc842d3c",
Url: "/api/admins/sywbhecnh46rhm0",
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
// delete all admins except the authorized one
adminModel := &models.Admin{}
_, err := app.Dao().DB().Delete(adminModel.TableName(), dbx.Not(dbx.HashExp{
"id": "2b4a97cc-3f83-4d01-a26b-3d77bc842d3c",
"id": "sywbhecnh46rhm0",
})).Execute()
if err != nil {
t.Fatal(err)
@@ -508,7 +539,7 @@ func TestAdminCreate(t *testing.T) {
Method: http.MethodPost,
Url: "/api/admins",
RequestHeaders: map[string]string{
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
},
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
@@ -519,7 +550,7 @@ func TestAdminCreate(t *testing.T) {
Url: "/api/admins",
Body: strings.NewReader(``),
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{"email":{"code":"validation_required","message":"Cannot be blank."},"password":{"code":"validation_required","message":"Cannot be blank."}}`},
@@ -530,7 +561,7 @@ func TestAdminCreate(t *testing.T) {
Url: "/api/admins",
Body: strings.NewReader(`{`),
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
@@ -539,20 +570,36 @@ func TestAdminCreate(t *testing.T) {
Name: "authorized as admin + invalid data",
Method: http.MethodPost,
Url: "/api/admins",
Body: strings.NewReader(`{"email":"test@example.com","password":"1234","passwordConfirm":"4321","avatar":99}`),
Body: strings.NewReader(`{
"email":"test@example.com",
"password":"1234",
"passwordConfirm":"4321",
"avatar":99
}`),
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 400,
ExpectedContent: []string{
`"data":{`,
`"avatar":{"code":"validation_max_less_equal_than_required"`,
`"email":{"code":"validation_admin_email_exists"`,
`"password":{"code":"validation_length_out_of_range"`,
`"passwordConfirm":{"code":"validation_values_mismatch"`,
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{"avatar":{"code":"validation_max_less_equal_than_required","message":"Must be no greater than 9."},"email":{"code":"validation_admin_email_exists","message":"Admin email already exists."},"password":{"code":"validation_length_out_of_range","message":"The length must be between 10 and 100."},"passwordConfirm":{"code":"validation_values_mismatch","message":"Values don't match."}}`},
},
{
Name: "authorized as admin + valid data",
Method: http.MethodPost,
Url: "/api/admins",
Body: strings.NewReader(`{"email":"testnew@example.com","password":"1234567890","passwordConfirm":"1234567890","avatar":3}`),
Body: strings.NewReader(`{
"email":"testnew@example.com",
"password":"1234567890",
"passwordConfirm":"1234567890",
"avatar":3
}`),
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 200,
ExpectedContent: []string{
@@ -560,6 +607,12 @@ func TestAdminCreate(t *testing.T) {
`"email":"testnew@example.com"`,
`"avatar":3`,
},
NotExpectedContent: []string{
`"password"`,
`"passwordConfirm"`,
`"tokenKey"`,
`"passwordHash"`,
},
ExpectedEvents: map[string]int{
"OnModelBeforeCreate": 1,
"OnModelAfterCreate": 1,
@@ -579,38 +632,27 @@ func TestAdminUpdate(t *testing.T) {
{
Name: "unauthorized",
Method: http.MethodPatch,
Url: "/api/admins/3f8397cc-2b4a-a26b-4d01-42d3c3d77bc8",
Url: "/api/admins/sbmbsdb40jyxf7h",
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "authorized as user",
Method: http.MethodPatch,
Url: "/api/admins/3f8397cc-2b4a-a26b-4d01-42d3c3d77bc8",
Url: "/api/admins/sbmbsdb40jyxf7h",
RequestHeaders: map[string]string{
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
},
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "authorized as admin + invalid admin id",
Name: "authorized as admin + missing admin",
Method: http.MethodPatch,
Url: "/api/admins/invalid",
Url: "/api/admins/missing",
Body: strings.NewReader(``),
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
},
ExpectedStatus: 404,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "authorized as admin + nonexisting admin id",
Method: http.MethodPatch,
Url: "/api/admins/b97ccf83-34a2-4d01-a26b-3d77bc842d3c",
Body: strings.NewReader(``),
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 404,
ExpectedContent: []string{`"data":{}`},
@@ -618,14 +660,14 @@ func TestAdminUpdate(t *testing.T) {
{
Name: "authorized as admin + empty data",
Method: http.MethodPatch,
Url: "/api/admins/3f8397cc-2b4a-a26b-4d01-42d3c3d77bc8",
Url: "/api/admins/sbmbsdb40jyxf7h",
Body: strings.NewReader(``),
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"id":"3f8397cc-2b4a-a26b-4d01-42d3c3d77bc8"`,
`"id":"sbmbsdb40jyxf7h"`,
`"email":"test2@example.com"`,
`"avatar":2`,
},
@@ -639,10 +681,10 @@ func TestAdminUpdate(t *testing.T) {
{
Name: "authorized as admin + invalid formatted data",
Method: http.MethodPatch,
Url: "/api/admins/3f8397cc-2b4a-a26b-4d01-42d3c3d77bc8",
Url: "/api/admins/sbmbsdb40jyxf7h",
Body: strings.NewReader(`{`),
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
@@ -650,27 +692,49 @@ func TestAdminUpdate(t *testing.T) {
{
Name: "authorized as admin + invalid data",
Method: http.MethodPatch,
Url: "/api/admins/3f8397cc-2b4a-a26b-4d01-42d3c3d77bc8",
Body: strings.NewReader(`{"email":"test@example.com","password":"1234","passwordConfirm":"4321","avatar":99}`),
Url: "/api/admins/sbmbsdb40jyxf7h",
Body: strings.NewReader(`{
"email":"test@example.com",
"password":"1234",
"passwordConfirm":"4321",
"avatar":99
}`),
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 400,
ExpectedContent: []string{
`"data":{`,
`"avatar":{"code":"validation_max_less_equal_than_required"`,
`"email":{"code":"validation_admin_email_exists"`,
`"password":{"code":"validation_length_out_of_range"`,
`"passwordConfirm":{"code":"validation_values_mismatch"`,
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{"avatar":{"code":"validation_max_less_equal_than_required","message":"Must be no greater than 9."},"email":{"code":"validation_admin_email_exists","message":"Admin email already exists."},"password":{"code":"validation_length_out_of_range","message":"The length must be between 10 and 100."},"passwordConfirm":{"code":"validation_values_mismatch","message":"Values don't match."}}`},
},
{
Method: http.MethodPatch,
Url: "/api/admins/3f8397cc-2b4a-a26b-4d01-42d3c3d77bc8",
Body: strings.NewReader(`{"email":"testnew@example.com","password":"1234567890","passwordConfirm":"1234567890","avatar":5}`),
Url: "/api/admins/sbmbsdb40jyxf7h",
Body: strings.NewReader(`{
"email":"testnew@example.com",
"password":"1234567891",
"passwordConfirm":"1234567891",
"avatar":5
}`),
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"id":"3f8397cc-2b4a-a26b-4d01-42d3c3d77bc8"`,
`"id":"sbmbsdb40jyxf7h"`,
`"email":"testnew@example.com"`,
`"avatar":5`,
},
NotExpectedContent: []string{
`"password"`,
`"passwordConfirm"`,
`"tokenKey"`,
`"passwordHash"`,
},
ExpectedEvents: map[string]int{
"OnModelBeforeUpdate": 1,
"OnModelAfterUpdate": 1,
+108
View File
@@ -0,0 +1,108 @@
package apis
import (
"net/http"
"strings"
validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/pocketbase/pocketbase/tools/inflector"
)
// ApiError defines the struct for a basic api error response.
type ApiError struct {
Code int `json:"code"`
Message string `json:"message"`
Data map[string]any `json:"data"`
// stores unformatted error data (could be an internal error, text, etc.)
rawData any
}
// Error makes it compatible with the `error` interface.
func (e *ApiError) Error() string {
return e.Message
}
// RawData returns the unformatted error data (could be an internal error, text, etc.)
func (e *ApiError) RawData() any {
return e.rawData
}
// NewNotFoundError creates and returns 404 `ApiError`.
func NewNotFoundError(message string, data any) *ApiError {
if message == "" {
message = "The requested resource wasn't found."
}
return NewApiError(http.StatusNotFound, message, data)
}
// NewBadRequestError creates and returns 400 `ApiError`.
func NewBadRequestError(message string, data any) *ApiError {
if message == "" {
message = "Something went wrong while processing your request."
}
return NewApiError(http.StatusBadRequest, message, data)
}
// NewForbiddenError creates and returns 403 `ApiError`.
func NewForbiddenError(message string, data any) *ApiError {
if message == "" {
message = "You are not allowed to perform this request."
}
return NewApiError(http.StatusForbidden, message, data)
}
// NewUnauthorizedError creates and returns 401 `ApiError`.
func NewUnauthorizedError(message string, data any) *ApiError {
if message == "" {
message = "Missing or invalid authentication token."
}
return NewApiError(http.StatusUnauthorized, message, data)
}
// NewApiError creates and returns new normalized `ApiError` instance.
func NewApiError(status int, message string, data any) *ApiError {
message = inflector.Sentenize(message)
formattedData := map[string]any{}
if v, ok := data.(validation.Errors); ok {
formattedData = resolveValidationErrors(v)
}
return &ApiError{
rawData: data,
Data: formattedData,
Code: status,
Message: strings.TrimSpace(message),
}
}
func resolveValidationErrors(validationErrors validation.Errors) map[string]any {
result := map[string]any{}
// extract from each validation error its error code and message.
for name, err := range validationErrors {
// check for nested errors
if nestedErrs, ok := err.(validation.Errors); ok {
result[name] = resolveValidationErrors(nestedErrs)
continue
}
errCode := "validation_invalid_value" // default
if errObj, ok := err.(validation.ErrorObject); ok {
errCode = errObj.Code()
}
result[name] = map[string]string{
"code": errCode,
"message": inflector.Sentenize(err.Error()),
}
}
return result
}
+150
View File
@@ -0,0 +1,150 @@
package apis_test
import (
"encoding/json"
"errors"
"testing"
validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/pocketbase/pocketbase/apis"
)
func TestNewApiErrorWithRawData(t *testing.T) {
e := apis.NewApiError(
300,
"message_test",
"rawData_test",
)
result, _ := json.Marshal(e)
expected := `{"code":300,"message":"Message_test.","data":{}}`
if string(result) != expected {
t.Errorf("Expected %v, got %v", expected, string(result))
}
if e.Error() != "Message_test." {
t.Errorf("Expected %q, got %q", "Message_test.", e.Error())
}
if e.RawData() != "rawData_test" {
t.Errorf("Expected rawData %v, got %v", "rawData_test", e.RawData())
}
}
func TestNewApiErrorWithValidationData(t *testing.T) {
e := apis.NewApiError(
300,
"message_test",
validation.Errors{
"err1": errors.New("test error"),
"err2": validation.ErrRequired,
"err3": validation.Errors{
"sub1": errors.New("test error"),
"sub2": validation.ErrRequired,
"sub3": validation.Errors{
"sub11": validation.ErrRequired,
},
},
},
)
result, _ := json.Marshal(e)
expected := `{"code":300,"message":"Message_test.","data":{"err1":{"code":"validation_invalid_value","message":"Test error."},"err2":{"code":"validation_required","message":"Cannot be blank."},"err3":{"sub1":{"code":"validation_invalid_value","message":"Test error."},"sub2":{"code":"validation_required","message":"Cannot be blank."},"sub3":{"sub11":{"code":"validation_required","message":"Cannot be blank."}}}}}`
if string(result) != expected {
t.Errorf("Expected %v, got %v", expected, string(result))
}
if e.Error() != "Message_test." {
t.Errorf("Expected %q, got %q", "Message_test.", e.Error())
}
if e.RawData() == nil {
t.Error("Expected non-nil rawData")
}
}
func TestNewNotFoundError(t *testing.T) {
scenarios := []struct {
message string
data any
expected string
}{
{"", nil, `{"code":404,"message":"The requested resource wasn't found.","data":{}}`},
{"demo", "rawData_test", `{"code":404,"message":"Demo.","data":{}}`},
{"demo", validation.Errors{"err1": errors.New("test error")}, `{"code":404,"message":"Demo.","data":{"err1":{"code":"validation_invalid_value","message":"Test error."}}}`},
}
for i, scenario := range scenarios {
e := apis.NewNotFoundError(scenario.message, scenario.data)
result, _ := json.Marshal(e)
if string(result) != scenario.expected {
t.Errorf("(%d) Expected %v, got %v", i, scenario.expected, string(result))
}
}
}
func TestNewBadRequestError(t *testing.T) {
scenarios := []struct {
message string
data any
expected string
}{
{"", nil, `{"code":400,"message":"Something went wrong while processing your request.","data":{}}`},
{"demo", "rawData_test", `{"code":400,"message":"Demo.","data":{}}`},
{"demo", validation.Errors{"err1": errors.New("test error")}, `{"code":400,"message":"Demo.","data":{"err1":{"code":"validation_invalid_value","message":"Test error."}}}`},
}
for i, scenario := range scenarios {
e := apis.NewBadRequestError(scenario.message, scenario.data)
result, _ := json.Marshal(e)
if string(result) != scenario.expected {
t.Errorf("(%d) Expected %v, got %v", i, scenario.expected, string(result))
}
}
}
func TestNewForbiddenError(t *testing.T) {
scenarios := []struct {
message string
data any
expected string
}{
{"", nil, `{"code":403,"message":"You are not allowed to perform this request.","data":{}}`},
{"demo", "rawData_test", `{"code":403,"message":"Demo.","data":{}}`},
{"demo", validation.Errors{"err1": errors.New("test error")}, `{"code":403,"message":"Demo.","data":{"err1":{"code":"validation_invalid_value","message":"Test error."}}}`},
}
for i, scenario := range scenarios {
e := apis.NewForbiddenError(scenario.message, scenario.data)
result, _ := json.Marshal(e)
if string(result) != scenario.expected {
t.Errorf("(%d) Expected %v, got %v", i, scenario.expected, string(result))
}
}
}
func TestNewUnauthorizedError(t *testing.T) {
scenarios := []struct {
message string
data any
expected string
}{
{"", nil, `{"code":401,"message":"Missing or invalid authentication token.","data":{}}`},
{"demo", "rawData_test", `{"code":401,"message":"Demo.","data":{}}`},
{"demo", validation.Errors{"err1": errors.New("test error")}, `{"code":401,"message":"Demo.","data":{"err1":{"code":"validation_invalid_value","message":"Test error."}}}`},
}
for i, scenario := range scenarios {
e := apis.NewUnauthorizedError(scenario.message, scenario.data)
result, _ := json.Marshal(e)
if string(result) != scenario.expected {
t.Errorf("(%d) Expected %v, got %v", i, scenario.expected, string(result))
}
}
}
+30 -21
View File
@@ -2,6 +2,7 @@
package apis
import (
"errors"
"fmt"
"io/fs"
"log"
@@ -13,7 +14,6 @@ import (
"github.com/labstack/echo/v5"
"github.com/labstack/echo/v5/middleware"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/rest"
"github.com/pocketbase/pocketbase/ui"
"github.com/spf13/cast"
)
@@ -43,7 +43,7 @@ func InitApi(app core.App) (*echo.Echo, error) {
return
}
var apiErr *rest.ApiError
var apiErr *ApiError
switch v := err.(type) {
case *echo.HTTPError:
@@ -51,8 +51,8 @@ func InitApi(app core.App) (*echo.Echo, error) {
log.Println(v.Internal)
}
msg := fmt.Sprintf("%v", v.Message)
apiErr = rest.NewApiError(v.Code, msg, v)
case *rest.ApiError:
apiErr = NewApiError(v.Code, msg, v)
case *ApiError:
if app.IsDebug() && v.RawData() != nil {
log.Println(v.RawData())
}
@@ -61,7 +61,7 @@ func InitApi(app core.App) (*echo.Echo, error) {
if err != nil && app.IsDebug() {
log.Println(err)
}
apiErr = rest.NewBadRequestError("", err)
apiErr = NewBadRequestError("", err)
}
// Send response
@@ -84,14 +84,14 @@ func InitApi(app core.App) (*echo.Echo, error) {
// default routes
api := e.Group("/api")
BindSettingsApi(app, api)
BindAdminApi(app, api)
BindUserApi(app, api)
BindCollectionApi(app, api)
BindRecordApi(app, api)
BindFileApi(app, api)
BindRealtimeApi(app, api)
BindLogsApi(app, api)
bindSettingsApi(app, api)
bindAdminApi(app, api)
bindCollectionApi(app, api)
bindRecordCrudApi(app, api)
bindRecordAuthApi(app, api)
bindFileApi(app, api)
bindRealtimeApi(app, api)
bindLogsApi(app, api)
// trigger the custom BeforeServe hook for the created api router
// allowing users to further adjust its options or register new routes
@@ -114,22 +114,31 @@ func InitApi(app core.App) (*echo.Echo, error) {
// StaticDirectoryHandler is similar to `echo.StaticDirectoryHandler`
// but without the directory redirect which conflicts with RemoveTrailingSlash middleware.
//
// If a file resource is missing and indexFallback is set, the request
// will be forwarded to the base index.html (useful also for SPA).
//
// @see https://github.com/labstack/echo/issues/2211
func StaticDirectoryHandler(fileSystem fs.FS, disablePathUnescaping bool) echo.HandlerFunc {
func StaticDirectoryHandler(fileSystem fs.FS, indexFallback bool) echo.HandlerFunc {
return func(c echo.Context) error {
p := c.PathParam("*")
if !disablePathUnescaping { // when router is already unescaping we do not want to do is twice
tmpPath, err := url.PathUnescape(p)
if err != nil {
return fmt.Errorf("failed to unescape path variable: %w", err)
}
p = tmpPath
// escape url path
tmpPath, err := url.PathUnescape(p)
if err != nil {
return fmt.Errorf("failed to unescape path variable: %w", err)
}
p = tmpPath
// fs.FS.Open() already assumes that file names are relative to FS root path and considers name with prefix `/` as invalid
name := filepath.ToSlash(filepath.Clean(strings.TrimPrefix(p, "/")))
return c.FileFS(name, fileSystem)
fileErr := c.FileFS(name, fileSystem)
if fileErr != nil && indexFallback && errors.Is(fileErr, echo.ErrNotFound) {
return c.FileFS("index.html", fileSystem)
}
return fileErr
}
}
+2 -2
View File
@@ -6,8 +6,8 @@ import (
"testing"
"github.com/labstack/echo/v5"
"github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/tests"
"github.com/pocketbase/pocketbase/tools/rest"
)
func Test404(t *testing.T) {
@@ -91,7 +91,7 @@ func TestCustomRoutesAndErrorsHandling(t *testing.T) {
Method: http.MethodGet,
Path: "/api-error",
Handler: func(c echo.Context) error {
return rest.NewApiError(500, "test message", errors.New("internal_test"))
return apis.NewApiError(500, "test message", errors.New("internal_test"))
},
})
},
+14 -15
View File
@@ -7,12 +7,11 @@ import (
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/forms"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/tools/rest"
"github.com/pocketbase/pocketbase/tools/search"
)
// BindCollectionApi registers the collection api endpoints and the corresponding handlers.
func BindCollectionApi(app core.App, rg *echo.Group) {
// bindCollectionApi registers the collection api endpoints and the corresponding handlers.
func bindCollectionApi(app core.App, rg *echo.Group) {
api := collectionApi{app: app}
subGroup := rg.Group("/collections", ActivityLogger(app), RequireAdminAuth())
@@ -30,7 +29,7 @@ type collectionApi struct {
func (api *collectionApi) list(c echo.Context) error {
fieldResolver := search.NewSimpleFieldResolver(
"id", "created", "updated", "name", "system",
"id", "created", "updated", "name", "system", "type",
)
collections := []*models.Collection{}
@@ -40,7 +39,7 @@ func (api *collectionApi) list(c echo.Context) error {
ParseAndExec(c.QueryString(), &collections)
if err != nil {
return rest.NewBadRequestError("", err)
return NewBadRequestError("", err)
}
event := &core.CollectionsListEvent{
@@ -57,7 +56,7 @@ func (api *collectionApi) list(c echo.Context) error {
func (api *collectionApi) view(c echo.Context) error {
collection, err := api.app.Dao().FindCollectionByNameOrId(c.PathParam("collection"))
if err != nil || collection == nil {
return rest.NewNotFoundError("", err)
return NewNotFoundError("", err)
}
event := &core.CollectionViewEvent{
@@ -77,7 +76,7 @@ func (api *collectionApi) create(c echo.Context) error {
// load request
if err := c.Bind(form); err != nil {
return rest.NewBadRequestError("Failed to load the submitted data due to invalid formatting.", err)
return NewBadRequestError("Failed to load the submitted data due to invalid formatting.", err)
}
event := &core.CollectionCreateEvent{
@@ -90,7 +89,7 @@ func (api *collectionApi) create(c echo.Context) error {
return func() error {
return api.app.OnCollectionBeforeCreateRequest().Trigger(event, func(e *core.CollectionCreateEvent) error {
if err := next(); err != nil {
return rest.NewBadRequestError("Failed to create the collection.", err)
return NewBadRequestError("Failed to create the collection.", err)
}
return e.HttpContext.JSON(http.StatusOK, e.Collection)
@@ -108,14 +107,14 @@ func (api *collectionApi) create(c echo.Context) error {
func (api *collectionApi) update(c echo.Context) error {
collection, err := api.app.Dao().FindCollectionByNameOrId(c.PathParam("collection"))
if err != nil || collection == nil {
return rest.NewNotFoundError("", err)
return NewNotFoundError("", err)
}
form := forms.NewCollectionUpsert(api.app, collection)
// load request
if err := c.Bind(form); err != nil {
return rest.NewBadRequestError("Failed to load the submitted data due to invalid formatting.", err)
return NewBadRequestError("Failed to load the submitted data due to invalid formatting.", err)
}
event := &core.CollectionUpdateEvent{
@@ -128,7 +127,7 @@ func (api *collectionApi) update(c echo.Context) error {
return func() error {
return api.app.OnCollectionBeforeUpdateRequest().Trigger(event, func(e *core.CollectionUpdateEvent) error {
if err := next(); err != nil {
return rest.NewBadRequestError("Failed to update the collection.", err)
return NewBadRequestError("Failed to update the collection.", err)
}
return e.HttpContext.JSON(http.StatusOK, e.Collection)
@@ -146,7 +145,7 @@ func (api *collectionApi) update(c echo.Context) error {
func (api *collectionApi) delete(c echo.Context) error {
collection, err := api.app.Dao().FindCollectionByNameOrId(c.PathParam("collection"))
if err != nil || collection == nil {
return rest.NewNotFoundError("", err)
return NewNotFoundError("", err)
}
event := &core.CollectionDeleteEvent{
@@ -156,7 +155,7 @@ func (api *collectionApi) delete(c echo.Context) error {
handlerErr := api.app.OnCollectionBeforeDeleteRequest().Trigger(event, func(e *core.CollectionDeleteEvent) error {
if err := api.app.Dao().DeleteCollection(e.Collection); err != nil {
return rest.NewBadRequestError("Failed to delete collection. Make sure that the collection is not referenced by other collections.", err)
return NewBadRequestError("Failed to delete collection. Make sure that the collection is not referenced by other collections.", err)
}
return e.HttpContext.NoContent(http.StatusNoContent)
@@ -174,7 +173,7 @@ func (api *collectionApi) bulkImport(c echo.Context) error {
// load request data
if err := c.Bind(form); err != nil {
return rest.NewBadRequestError("Failed to load the submitted data due to invalid formatting.", err)
return NewBadRequestError("Failed to load the submitted data due to invalid formatting.", err)
}
event := &core.CollectionsImportEvent{
@@ -189,7 +188,7 @@ func (api *collectionApi) bulkImport(c echo.Context) error {
form.Collections = e.Collections // ensures that the form always has the latest changes
if err := next(); err != nil {
return rest.NewBadRequestError("Failed to import the submitted collections.", err)
return NewBadRequestError("Failed to import the submitted collections.", err)
}
return e.HttpContext.NoContent(http.StatusNoContent)
+448 -137
View File
@@ -2,6 +2,8 @@ package apis_test
import (
"net/http"
"os"
"path/filepath"
"strings"
"testing"
@@ -24,7 +26,7 @@ func TestCollectionsList(t *testing.T) {
Method: http.MethodGet,
Url: "/api/collections",
RequestHeaders: map[string]string{
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
},
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
@@ -34,19 +36,23 @@ func TestCollectionsList(t *testing.T) {
Method: http.MethodGet,
Url: "/api/collections",
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"page":1`,
`"perPage":30`,
`"totalItems":5`,
`"totalItems":7`,
`"items":[{`,
`"id":"abe78266-fd4d-4aea-962d-8c0138ac522b"`,
`"id":"3f2888f8-075d-49fe-9d09-ea7e951000dc"`,
`"id":"2c1010aa-b8fe-41d9-a980-99534ca8a167"`,
`"id":"3cd6fe92-70dc-4819-8542-4d036faabd89"`,
`"id":"f12f3eb6-b980-4bf6-b1e4-36de0450c8be"`,
`"id":"_pb_users_auth_"`,
`"id":"v851q4r790rhknl"`,
`"id":"kpv709sk2lqbqk8"`,
`"id":"wsmn24bux7wo113"`,
`"id":"sz5l5z67tg7gku0"`,
`"id":"wzlqyes4orhoygb"`,
`"id":"4d1blo5cuycfaca"`,
`"type":"auth"`,
`"type":"base"`,
},
ExpectedEvents: map[string]int{
"OnCollectionsListRequest": 1,
@@ -57,16 +63,16 @@ func TestCollectionsList(t *testing.T) {
Method: http.MethodGet,
Url: "/api/collections?page=2&perPage=2&sort=-created",
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"page":2`,
`"perPage":2`,
`"totalItems":5`,
`"totalItems":7`,
`"items":[{`,
`"id":"3f2888f8-075d-49fe-9d09-ea7e951000dc"`,
`"id":"2c1010aa-b8fe-41d9-a980-99534ca8a167"`,
`"id":"4d1blo5cuycfaca"`,
`"id":"wzlqyes4orhoygb"`,
},
ExpectedEvents: map[string]int{
"OnCollectionsListRequest": 1,
@@ -77,7 +83,7 @@ func TestCollectionsList(t *testing.T) {
Method: http.MethodGet,
Url: "/api/collections?filter=invalidfield~'demo2'",
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
@@ -85,17 +91,20 @@ func TestCollectionsList(t *testing.T) {
{
Name: "authorized as admin + valid filter",
Method: http.MethodGet,
Url: "/api/collections?filter=name~'demo2'",
Url: "/api/collections?filter=name~'demo'",
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"page":1`,
`"perPage":30`,
`"totalItems":1`,
`"totalItems":4`,
`"items":[{`,
`"id":"2c1010aa-b8fe-41d9-a980-99534ca8a167"`,
`"id":"wsmn24bux7wo113"`,
`"id":"sz5l5z67tg7gku0"`,
`"id":"wzlqyes4orhoygb"`,
`"id":"4d1blo5cuycfaca"`,
},
ExpectedEvents: map[string]int{
"OnCollectionsListRequest": 1,
@@ -113,16 +122,16 @@ func TestCollectionView(t *testing.T) {
{
Name: "unauthorized",
Method: http.MethodGet,
Url: "/api/collections/demo",
Url: "/api/collections/demo1",
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "authorized as user",
Method: http.MethodGet,
Url: "/api/collections/demo",
Url: "/api/collections/demo1",
RequestHeaders: map[string]string{
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
},
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
@@ -132,7 +141,7 @@ func TestCollectionView(t *testing.T) {
Method: http.MethodGet,
Url: "/api/collections/missing",
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 404,
ExpectedContent: []string{`"data":{}`},
@@ -140,13 +149,14 @@ func TestCollectionView(t *testing.T) {
{
Name: "authorized as admin + using the collection name",
Method: http.MethodGet,
Url: "/api/collections/demo",
Url: "/api/collections/demo1",
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"id":"3f2888f8-075d-49fe-9d09-ea7e951000dc"`,
`"id":"wsmn24bux7wo113"`,
`"name":"demo1"`,
},
ExpectedEvents: map[string]int{
"OnCollectionViewRequest": 1,
@@ -155,13 +165,14 @@ func TestCollectionView(t *testing.T) {
{
Name: "authorized as admin + using the collection id",
Method: http.MethodGet,
Url: "/api/collections/3f2888f8-075d-49fe-9d09-ea7e951000dc",
Url: "/api/collections/wsmn24bux7wo113",
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"id":"3f2888f8-075d-49fe-9d09-ea7e951000dc"`,
`"id":"wsmn24bux7wo113"`,
`"name":"demo1"`,
},
ExpectedEvents: map[string]int{
"OnCollectionViewRequest": 1,
@@ -175,20 +186,29 @@ func TestCollectionView(t *testing.T) {
}
func TestCollectionDelete(t *testing.T) {
ensureDeletedFiles := func(app *tests.TestApp, collectionId string) {
storageDir := filepath.Join(app.DataDir(), "storage", collectionId)
entries, _ := os.ReadDir(storageDir)
if len(entries) != 0 {
t.Errorf("Expected empty/deleted dir, found %d", len(entries))
}
}
scenarios := []tests.ApiScenario{
{
Name: "unauthorized",
Method: http.MethodDelete,
Url: "/api/collections/demo3",
Url: "/api/collections/demo1",
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "authorized as user",
Method: http.MethodDelete,
Url: "/api/collections/demo3",
Url: "/api/collections/demo1",
RequestHeaders: map[string]string{
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
},
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
@@ -196,9 +216,9 @@ func TestCollectionDelete(t *testing.T) {
{
Name: "authorized as admin + nonexisting collection identifier",
Method: http.MethodDelete,
Url: "/api/collections/b97ccf83-34a2-4d01-a26b-3d77bc842d3c",
Url: "/api/collections/missing",
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 404,
ExpectedContent: []string{`"data":{}`},
@@ -206,9 +226,9 @@ func TestCollectionDelete(t *testing.T) {
{
Name: "authorized as admin + using the collection name",
Method: http.MethodDelete,
Url: "/api/collections/demo3",
Url: "/api/collections/demo1",
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 204,
ExpectedEvents: map[string]int{
@@ -217,13 +237,16 @@ func TestCollectionDelete(t *testing.T) {
"OnCollectionBeforeDeleteRequest": 1,
"OnCollectionAfterDeleteRequest": 1,
},
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
ensureDeletedFiles(app, "wsmn24bux7wo113")
},
},
{
Name: "authorized as admin + using the collection id",
Method: http.MethodDelete,
Url: "/api/collections/3cd6fe92-70dc-4819-8542-4d036faabd89",
Url: "/api/collections/wsmn24bux7wo113",
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 204,
ExpectedEvents: map[string]int{
@@ -232,13 +255,16 @@ func TestCollectionDelete(t *testing.T) {
"OnCollectionBeforeDeleteRequest": 1,
"OnCollectionAfterDeleteRequest": 1,
},
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
ensureDeletedFiles(app, "wsmn24bux7wo113")
},
},
{
Name: "authorized as admin + trying to delete a system collection",
Method: http.MethodDelete,
Url: "/api/collections/profiles",
Url: "/api/collections/nologin",
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
@@ -249,9 +275,9 @@ func TestCollectionDelete(t *testing.T) {
{
Name: "authorized as admin + trying to delete a referenced collection",
Method: http.MethodDelete,
Url: "/api/collections/demo",
Url: "/api/collections/demo2",
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
@@ -280,7 +306,7 @@ func TestCollectionCreate(t *testing.T) {
Method: http.MethodPost,
Url: "/api/collections",
RequestHeaders: map[string]string{
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
},
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
@@ -291,7 +317,7 @@ func TestCollectionCreate(t *testing.T) {
Url: "/api/collections",
Body: strings.NewReader(``),
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 400,
ExpectedContent: []string{
@@ -304,9 +330,9 @@ func TestCollectionCreate(t *testing.T) {
Name: "authorized as admin + invalid data (eg. existing name)",
Method: http.MethodPost,
Url: "/api/collections",
Body: strings.NewReader(`{"name":"demo","schema":[{"type":"text","name":""}]}`),
Body: strings.NewReader(`{"name":"demo1","type":"base","schema":[{"type":"text","name":""}]}`),
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 400,
ExpectedContent: []string{
@@ -319,16 +345,18 @@ func TestCollectionCreate(t *testing.T) {
Name: "authorized as admin + valid data",
Method: http.MethodPost,
Url: "/api/collections",
Body: strings.NewReader(`{"name":"new","schema":[{"type":"text","id":"12345789","name":"test"}]}`),
Body: strings.NewReader(`{"name":"new","type":"base","schema":[{"type":"text","id":"12345789","name":"test"}]}`),
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"id":`,
`"name":"new"`,
`"type":"base"`,
`"system":false`,
`"schema":[{"system":false,"id":"12345789","name":"test","type":"text","required":false,"unique":false,"options":{"min":null,"max":null,"pattern":""}}]`,
`"options":{}`,
},
ExpectedEvents: map[string]int{
"OnModelBeforeCreate": 1,
@@ -337,6 +365,154 @@ func TestCollectionCreate(t *testing.T) {
"OnCollectionAfterCreateRequest": 1,
},
},
{
Name: "creating auth collection without specified options",
Method: http.MethodPost,
Url: "/api/collections",
Body: strings.NewReader(`{"name":"new","type":"auth","schema":[{"type":"text","id":"12345789","name":"test"}]}`),
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"id":`,
`"name":"new"`,
`"type":"auth"`,
`"system":false`,
`"schema":[{"system":false,"id":"12345789","name":"test","type":"text","required":false,"unique":false,"options":{"min":null,"max":null,"pattern":""}}]`,
`"options":{"allowEmailAuth":false,"allowOAuth2Auth":false,"allowUsernameAuth":false,"exceptEmailDomains":null,"manageRule":null,"minPasswordLength":0,"onlyEmailDomains":null,"requireEmail":false}`,
},
ExpectedEvents: map[string]int{
"OnModelBeforeCreate": 1,
"OnModelAfterCreate": 1,
"OnCollectionBeforeCreateRequest": 1,
"OnCollectionAfterCreateRequest": 1,
},
},
{
Name: "trying to create auth collection with reserved auth fields",
Method: http.MethodPost,
Url: "/api/collections",
Body: strings.NewReader(`{
"name":"new",
"type":"auth",
"schema":[
{"type":"text","name":"email"},
{"type":"text","name":"username"},
{"type":"text","name":"verified"},
{"type":"text","name":"emailVisibility"},
{"type":"text","name":"lastResetSentAt"},
{"type":"text","name":"lastVerificationSentAt"},
{"type":"text","name":"tokenKey"},
{"type":"text","name":"passwordHash"},
{"type":"text","name":"password"},
{"type":"text","name":"passwordConfirm"},
{"type":"text","name":"oldPassword"}
]
}`),
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 400,
ExpectedContent: []string{
`"data":{"schema":{`,
`"0":{"name":{"code":"validation_reserved_auth_field_name"`,
`"1":{"name":{"code":"validation_reserved_auth_field_name"`,
`"2":{"name":{"code":"validation_reserved_auth_field_name"`,
`"3":{"name":{"code":"validation_reserved_auth_field_name"`,
`"4":{"name":{"code":"validation_reserved_auth_field_name"`,
`"5":{"name":{"code":"validation_reserved_auth_field_name"`,
`"6":{"name":{"code":"validation_reserved_auth_field_name"`,
`"7":{"name":{"code":"validation_reserved_auth_field_name"`,
`"8":{"name":{"code":"validation_reserved_auth_field_name"`,
},
},
{
Name: "creating base collection with reserved auth fields",
Method: http.MethodPost,
Url: "/api/collections",
Body: strings.NewReader(`{
"name":"new",
"type":"base",
"schema":[
{"type":"text","name":"email"},
{"type":"text","name":"username"},
{"type":"text","name":"verified"},
{"type":"text","name":"emailVisibility"},
{"type":"text","name":"lastResetSentAt"},
{"type":"text","name":"lastVerificationSentAt"},
{"type":"text","name":"tokenKey"},
{"type":"text","name":"passwordHash"},
{"type":"text","name":"password"},
{"type":"text","name":"passwordConfirm"},
{"type":"text","name":"oldPassword"}
]
}`),
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"name":"new"`,
`"type":"base"`,
`"schema":[{`,
},
ExpectedEvents: map[string]int{
"OnModelBeforeCreate": 1,
"OnModelAfterCreate": 1,
"OnCollectionBeforeCreateRequest": 1,
"OnCollectionAfterCreateRequest": 1,
},
},
{
Name: "trying to create base collection with reserved base fields",
Method: http.MethodPost,
Url: "/api/collections",
Body: strings.NewReader(`{
"name":"new",
"type":"base",
"schema":[
{"type":"text","name":"id"},
{"type":"text","name":"created"},
{"type":"text","name":"updated"},
{"type":"text","name":"expand"},
{"type":"text","name":"collectionId"},
{"type":"text","name":"collectionName"}
]
}`),
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 400,
ExpectedContent: []string{
`"data":{"schema":{`,
`"0":{"name":{"code":"validation_not_in_invalid`,
`"1":{"name":{"code":"validation_not_in_invalid`,
`"2":{"name":{"code":"validation_not_in_invalid`,
`"3":{"name":{"code":"validation_not_in_invalid`,
`"4":{"name":{"code":"validation_not_in_invalid`,
`"5":{"name":{"code":"validation_not_in_invalid`,
},
},
{
Name: "trying to create auth collection with invalid options",
Method: http.MethodPost,
Url: "/api/collections",
Body: strings.NewReader(`{
"name":"new",
"type":"auth",
"schema":[{"type":"text","id":"12345789","name":"test"}],
"options":{"allowUsernameAuth": true}
}`),
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 400,
ExpectedContent: []string{
`"data":{`,
`"options":{"minPasswordLength":{"code":"validation_required"`,
},
},
}
for _, scenario := range scenarios {
@@ -349,64 +525,80 @@ func TestCollectionUpdate(t *testing.T) {
{
Name: "unauthorized",
Method: http.MethodPatch,
Url: "/api/collections/demo",
Url: "/api/collections/demo1",
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "authorized as user",
Method: http.MethodPatch,
Url: "/api/collections/demo",
Url: "/api/collections/demo1",
RequestHeaders: map[string]string{
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
},
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "authorized as admin + empty data",
Name: "authorized as admin + missing collection",
Method: http.MethodPatch,
Url: "/api/collections/demo",
Body: strings.NewReader(``),
Url: "/api/collections/missing",
Body: strings.NewReader(`{}`),
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 404,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "authorized as admin + empty body",
Method: http.MethodPatch,
Url: "/api/collections/demo1",
Body: strings.NewReader(`{}`),
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"id":"3f2888f8-075d-49fe-9d09-ea7e951000dc"`,
`"id":"wsmn24bux7wo113"`,
`"name":"demo1"`,
},
ExpectedEvents: map[string]int{
"OnModelBeforeUpdate": 1,
"OnModelAfterUpdate": 1,
"OnCollectionBeforeUpdateRequest": 1,
"OnCollectionAfterUpdateRequest": 1,
"OnCollectionBeforeUpdateRequest": 1,
"OnModelAfterUpdate": 1,
"OnModelBeforeUpdate": 1,
},
},
{
Name: "authorized as admin + invalid data (eg. existing name)",
Method: http.MethodPatch,
Url: "/api/collections/demo",
Body: strings.NewReader(`{"name":"demo2"}`),
Url: "/api/collections/demo1",
Body: strings.NewReader(`{
"name":"demo2",
"type":"auth"
}`),
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 400,
ExpectedContent: []string{
`"data":{`,
`"name":{"code":"validation_collection_name_exists"`,
`"type":{"code":"validation_collection_type_change"`,
},
},
{
Name: "authorized as admin + valid data",
Method: http.MethodPatch,
Url: "/api/collections/demo",
Url: "/api/collections/demo1",
Body: strings.NewReader(`{"name":"new"}`),
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"id":"3f2888f8-075d-49fe-9d09-ea7e951000dc"`,
`"id":`,
`"name":"new"`,
},
ExpectedEvents: map[string]int{
@@ -415,25 +607,139 @@ func TestCollectionUpdate(t *testing.T) {
"OnCollectionBeforeUpdateRequest": 1,
"OnCollectionAfterUpdateRequest": 1,
},
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
// check if the record table was renamed
if !app.Dao().HasTable("new") {
t.Fatal("Couldn't find record table 'new'.")
}
},
},
{
Name: "trying to update auth collection with reserved auth fields",
Method: http.MethodPatch,
Url: "/api/collections/users",
Body: strings.NewReader(`{
"schema":[
{"type":"text","name":"email"},
{"type":"text","name":"username"},
{"type":"text","name":"verified"},
{"type":"text","name":"emailVisibility"},
{"type":"text","name":"lastResetSentAt"},
{"type":"text","name":"lastVerificationSentAt"},
{"type":"text","name":"tokenKey"},
{"type":"text","name":"passwordHash"},
{"type":"text","name":"password"},
{"type":"text","name":"passwordConfirm"},
{"type":"text","name":"oldPassword"}
]
}`),
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 400,
ExpectedContent: []string{
`"data":{"schema":{`,
`"0":{"name":{"code":"validation_reserved_auth_field_name"`,
`"1":{"name":{"code":"validation_reserved_auth_field_name"`,
`"2":{"name":{"code":"validation_reserved_auth_field_name"`,
`"3":{"name":{"code":"validation_reserved_auth_field_name"`,
`"4":{"name":{"code":"validation_reserved_auth_field_name"`,
`"5":{"name":{"code":"validation_reserved_auth_field_name"`,
`"6":{"name":{"code":"validation_reserved_auth_field_name"`,
`"7":{"name":{"code":"validation_reserved_auth_field_name"`,
`"8":{"name":{"code":"validation_reserved_auth_field_name"`,
},
},
{
Name: "updating base collection with reserved auth fields",
Method: http.MethodPatch,
Url: "/api/collections/demo1",
Body: strings.NewReader(`{
"schema":[
{"type":"text","name":"email"},
{"type":"text","name":"username"},
{"type":"text","name":"verified"},
{"type":"text","name":"emailVisibility"},
{"type":"text","name":"lastResetSentAt"},
{"type":"text","name":"lastVerificationSentAt"},
{"type":"text","name":"tokenKey"},
{"type":"text","name":"passwordHash"},
{"type":"text","name":"password"},
{"type":"text","name":"passwordConfirm"},
{"type":"text","name":"oldPassword"}
]
}`),
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"name":"demo1"`,
`"type":"base"`,
`"schema":[{`,
`"email"`,
`"username"`,
`"verified"`,
`"emailVisibility"`,
`"lastResetSentAt"`,
`"lastVerificationSentAt"`,
`"tokenKey"`,
`"passwordHash"`,
`"password"`,
`"passwordConfirm"`,
`"oldPassword"`,
},
ExpectedEvents: map[string]int{
"OnModelBeforeUpdate": 1,
"OnModelAfterUpdate": 1,
"OnCollectionBeforeUpdateRequest": 1,
"OnCollectionAfterUpdateRequest": 1,
},
},
{
Name: "authorized as admin + valid data and id as identifier",
Name: "trying to update base collection with reserved base fields",
Method: http.MethodPatch,
Url: "/api/collections/3f2888f8-075d-49fe-9d09-ea7e951000dc",
Body: strings.NewReader(`{"name":"new"}`),
Url: "/api/collections/demo1",
Body: strings.NewReader(`{
"name":"new",
"type":"base",
"schema":[
{"type":"text","name":"id"},
{"type":"text","name":"created"},
{"type":"text","name":"updated"},
{"type":"text","name":"expand"},
{"type":"text","name":"collectionId"},
{"type":"text","name":"collectionName"}
]
}`),
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 200,
ExpectedStatus: 400,
ExpectedContent: []string{
`"id":"3f2888f8-075d-49fe-9d09-ea7e951000dc"`,
`"name":"new"`,
`"data":{"schema":{`,
`"0":{"name":{"code":"validation_not_in_invalid`,
`"1":{"name":{"code":"validation_not_in_invalid`,
`"2":{"name":{"code":"validation_not_in_invalid`,
`"3":{"name":{"code":"validation_not_in_invalid`,
`"4":{"name":{"code":"validation_not_in_invalid`,
`"5":{"name":{"code":"validation_not_in_invalid`,
},
ExpectedEvents: map[string]int{
"OnModelBeforeUpdate": 1,
"OnModelAfterUpdate": 1,
"OnCollectionBeforeUpdateRequest": 1,
"OnCollectionAfterUpdateRequest": 1,
},
{
Name: "trying to update auth collection with invalid options",
Method: http.MethodPatch,
Url: "/api/collections/users",
Body: strings.NewReader(`{
"options":{"minPasswordLength": 4}
}`),
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 400,
ExpectedContent: []string{
`"data":{`,
`"options":{"minPasswordLength":{"code":"validation_min_greater_equal_than_required"`,
},
},
}
@@ -457,7 +763,7 @@ func TestCollectionImport(t *testing.T) {
Method: http.MethodPut,
Url: "/api/collections/import",
RequestHeaders: map[string]string{
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
},
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
@@ -468,7 +774,7 @@ func TestCollectionImport(t *testing.T) {
Url: "/api/collections/import",
Body: strings.NewReader(`{"collections":[]}`),
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 400,
ExpectedContent: []string{
@@ -480,8 +786,9 @@ func TestCollectionImport(t *testing.T) {
if err := app.Dao().CollectionQuery().All(&collections); err != nil {
t.Fatal(err)
}
if len(collections) != 5 {
t.Fatalf("Expected %d collections, got %d", 5, len(collections))
expected := 7
if len(collections) != expected {
t.Fatalf("Expected %d collections, got %d", expected, len(collections))
}
},
},
@@ -491,7 +798,7 @@ func TestCollectionImport(t *testing.T) {
Url: "/api/collections/import",
Body: strings.NewReader(`{"deleteMissing": true, "collections":[{"name": "test123"}]}`),
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 400,
ExpectedContent: []string{
@@ -500,14 +807,16 @@ func TestCollectionImport(t *testing.T) {
},
ExpectedEvents: map[string]int{
"OnCollectionsBeforeImportRequest": 1,
"OnModelBeforeDelete": 6,
},
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
collections := []*models.Collection{}
if err := app.Dao().CollectionQuery().All(&collections); err != nil {
t.Fatal(err)
}
if len(collections) != 5 {
t.Fatalf("Expected %d collections, got %d", 5, len(collections))
expected := 7
if len(collections) != expected {
t.Fatalf("Expected %d collections, got %d", expected, len(collections))
}
},
},
@@ -531,7 +840,7 @@ func TestCollectionImport(t *testing.T) {
]
}`),
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 400,
ExpectedContent: []string{
@@ -547,8 +856,9 @@ func TestCollectionImport(t *testing.T) {
if err := app.Dao().CollectionQuery().All(&collections); err != nil {
t.Fatal(err)
}
if len(collections) != 5 {
t.Fatalf("Expected %d collections, got %d", 5, len(collections))
expected := 7
if len(collections) != expected {
t.Fatalf("Expected %d collections, got %d", expected, len(collections))
}
},
},
@@ -581,7 +891,7 @@ func TestCollectionImport(t *testing.T) {
]
}`),
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 204,
ExpectedEvents: map[string]int{
@@ -595,8 +905,9 @@ func TestCollectionImport(t *testing.T) {
if err := app.Dao().CollectionQuery().All(&collections); err != nil {
t.Fatal(err)
}
if len(collections) != 7 {
t.Fatalf("Expected %d collections, got %d", 7, len(collections))
expected := 9
if len(collections) != expected {
t.Fatalf("Expected %d collections, got %d", expected, len(collections))
}
},
},
@@ -608,45 +919,54 @@ func TestCollectionImport(t *testing.T) {
"deleteMissing": true,
"collections":[
{
"id":"abe78266-fd4d-4aea-962d-8c0138ac522b",
"name":"profiles",
"system":true,
"listRule":"userId = @request.user.id",
"viewRule":"created > 'test_change'",
"createRule":"userId = @request.user.id",
"updateRule":"userId = @request.user.id",
"deleteRule":"userId = @request.user.id",
"schema":[
"name": "new_import",
"schema": [
{
"id":"koih1lqx",
"name":"userId",
"type":"user",
"system":true,
"required":true,
"unique":true,
"options":{
"maxSelect":1,
"cascadeDelete":true
}
},
{
"id":"69ycbg3q",
"name":"rel",
"type":"relation",
"system":false,
"required":false,
"unique":false,
"options":{
"maxSelect":2,
"collectionId":"abe78266-fd4d-4aea-962d-8c0138ac522b",
"cascadeDelete":false
}
"id": "koih1lqx",
"name": "test",
"type": "text"
}
]
},
{
"id":"3f2888f8-075d-49fe-9d09-ea7e951000dc",
"name":"demo",
"id": "kpv709sk2lqbqk8",
"system": true,
"name": "nologin",
"type": "auth",
"options": {
"allowEmailAuth": false,
"allowOAuth2Auth": false,
"allowUsernameAuth": false,
"exceptEmailDomains": [],
"manageRule": "@request.auth.collectionName = 'users'",
"minPasswordLength": 8,
"onlyEmailDomains": [],
"requireEmail": true
},
"listRule": "",
"viewRule": "",
"createRule": "",
"updateRule": "",
"deleteRule": "",
"schema": [
{
"id": "x8zzktwe",
"name": "name",
"type": "text",
"system": false,
"required": false,
"unique": false,
"options": {
"min": null,
"max": null,
"pattern": ""
}
}
]
},
{
"id":"wsmn24bux7wo113",
"name":"demo1",
"schema":[
{
"id":"_2hlxbmp",
@@ -662,28 +982,18 @@ func TestCollectionImport(t *testing.T) {
}
}
]
},
{
"name": "new_import",
"schema": [
{
"id": "koih1lqx",
"name": "test",
"type": "text"
}
]
}
]
}`),
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 204,
ExpectedEvents: map[string]int{
"OnCollectionsAfterImportRequest": 1,
"OnCollectionsBeforeImportRequest": 1,
"OnModelBeforeDelete": 3,
"OnModelAfterDelete": 3,
"OnModelBeforeDelete": 5,
"OnModelAfterDelete": 5,
"OnModelBeforeUpdate": 2,
"OnModelAfterUpdate": 2,
"OnModelBeforeCreate": 1,
@@ -694,8 +1004,9 @@ func TestCollectionImport(t *testing.T) {
if err := app.Dao().CollectionQuery().All(&collections); err != nil {
t.Fatal(err)
}
if len(collections) != 3 {
t.Fatalf("Expected %d collections, got %d", 3, len(collections))
expected := 3
if len(collections) != expected {
t.Fatalf("Expected %d collections, got %d", expected, len(collections))
}
},
},
+11 -12
View File
@@ -6,14 +6,13 @@ import (
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/models/schema"
"github.com/pocketbase/pocketbase/tools/list"
"github.com/pocketbase/pocketbase/tools/rest"
)
var imageContentTypes = []string{"image/png", "image/jpg", "image/jpeg"}
var imageContentTypes = []string{"image/png", "image/jpg", "image/jpeg", "image/gif"}
var defaultThumbSizes = []string{"100x100"}
// BindFileApi registers the file api endpoints and the corresponding handlers.
func BindFileApi(app core.App, rg *echo.Group) {
// bindFileApi registers the file api endpoints and the corresponding handlers.
func bindFileApi(app core.App, rg *echo.Group) {
api := fileApi{app: app}
subGroup := rg.Group("/files", ActivityLogger(app))
@@ -27,30 +26,30 @@ type fileApi struct {
func (api *fileApi) download(c echo.Context) error {
collection, _ := c.Get(ContextCollectionKey).(*models.Collection)
if collection == nil {
return rest.NewNotFoundError("", nil)
return NewNotFoundError("", nil)
}
recordId := c.PathParam("recordId")
if recordId == "" {
return rest.NewNotFoundError("", nil)
return NewNotFoundError("", nil)
}
record, err := api.app.Dao().FindRecordById(collection, recordId, nil)
record, err := api.app.Dao().FindRecordById(collection.Id, recordId)
if err != nil {
return rest.NewNotFoundError("", err)
return NewNotFoundError("", err)
}
filename := c.PathParam("filename")
fileField := record.FindFileFieldByFile(filename)
if fileField == nil {
return rest.NewNotFoundError("", nil)
return NewNotFoundError("", nil)
}
options, _ := fileField.Options.(*schema.FileOptions)
fs, err := api.app.NewFilesystem()
if err != nil {
return rest.NewBadRequestError("Filesystem initialization failure.", err)
return NewBadRequestError("Filesystem initialization failure.", err)
}
defer fs.Close()
@@ -64,7 +63,7 @@ func (api *fileApi) download(c echo.Context) error {
// extract the original file meta attributes and check it existence
oAttrs, oAttrsErr := fs.Attributes(originalPath)
if oAttrsErr != nil {
return rest.NewNotFoundError("", err)
return NewNotFoundError("", err)
}
// check if it is an image
@@ -96,7 +95,7 @@ func (api *fileApi) download(c echo.Context) error {
return api.app.OnFileDownloadRequest().Trigger(event, func(e *core.FileDownloadEvent) error {
if err := fs.Serve(e.HttpContext.Response(), e.ServedPath, e.ServedName); err != nil {
return rest.NewNotFoundError("", err)
return NewNotFoundError("", err)
}
return nil
+21 -20
View File
@@ -14,14 +14,15 @@ import (
func TestFileDownload(t *testing.T) {
_, currentFile, _, _ := runtime.Caller(0)
dataDirRelPath := "../tests/data/"
testFilePath := filepath.Join(path.Dir(currentFile), dataDirRelPath, "storage/3f2888f8-075d-49fe-9d09-ea7e951000dc/848a1dea-5ddd-42d6-a00d-030547bffcfe/8fe61d65-6a2e-4f11-87b3-d8a3170bfd4f.txt")
testImgPath := filepath.Join(path.Dir(currentFile), dataDirRelPath, "storage/3f2888f8-075d-49fe-9d09-ea7e951000dc/577bd676-aacb-4072-b7da-99d00ee210a4/4881bdef-06b4-4dea-8d97-6125ad242677.png")
testThumbCropCenterPath := filepath.Join(path.Dir(currentFile), dataDirRelPath, "storage/3f2888f8-075d-49fe-9d09-ea7e951000dc/577bd676-aacb-4072-b7da-99d00ee210a4/thumbs_4881bdef-06b4-4dea-8d97-6125ad242677.png/70x50_4881bdef-06b4-4dea-8d97-6125ad242677.png")
testThumbCropTopPath := filepath.Join(path.Dir(currentFile), dataDirRelPath, "storage/3f2888f8-075d-49fe-9d09-ea7e951000dc/577bd676-aacb-4072-b7da-99d00ee210a4/thumbs_4881bdef-06b4-4dea-8d97-6125ad242677.png/70x50t_4881bdef-06b4-4dea-8d97-6125ad242677.png")
testThumbCropBottomPath := filepath.Join(path.Dir(currentFile), dataDirRelPath, "storage/3f2888f8-075d-49fe-9d09-ea7e951000dc/577bd676-aacb-4072-b7da-99d00ee210a4/thumbs_4881bdef-06b4-4dea-8d97-6125ad242677.png/70x50b_4881bdef-06b4-4dea-8d97-6125ad242677.png")
testThumbFitPath := filepath.Join(path.Dir(currentFile), dataDirRelPath, "storage/3f2888f8-075d-49fe-9d09-ea7e951000dc/577bd676-aacb-4072-b7da-99d00ee210a4/thumbs_4881bdef-06b4-4dea-8d97-6125ad242677.png/70x50f_4881bdef-06b4-4dea-8d97-6125ad242677.png")
testThumbZeroWidthPath := filepath.Join(path.Dir(currentFile), dataDirRelPath, "storage/3f2888f8-075d-49fe-9d09-ea7e951000dc/577bd676-aacb-4072-b7da-99d00ee210a4/thumbs_4881bdef-06b4-4dea-8d97-6125ad242677.png/0x50_4881bdef-06b4-4dea-8d97-6125ad242677.png")
testThumbZeroHeightPath := filepath.Join(path.Dir(currentFile), dataDirRelPath, "storage/3f2888f8-075d-49fe-9d09-ea7e951000dc/577bd676-aacb-4072-b7da-99d00ee210a4/thumbs_4881bdef-06b4-4dea-8d97-6125ad242677.png/70x0_4881bdef-06b4-4dea-8d97-6125ad242677.png")
testFilePath := filepath.Join(path.Dir(currentFile), dataDirRelPath, "storage/_pb_users_auth_/oap640cot4yru2s/test_kfd2wYLxkz.txt")
testImgPath := filepath.Join(path.Dir(currentFile), dataDirRelPath, "storage/_pb_users_auth_/4q1xlclmfloku33/300_1SEi6Q6U72.png")
testThumbCropCenterPath := filepath.Join(path.Dir(currentFile), dataDirRelPath, "storage/_pb_users_auth_/4q1xlclmfloku33/thumbs_300_1SEi6Q6U72.png/70x50_300_1SEi6Q6U72.png")
testThumbCropTopPath := filepath.Join(path.Dir(currentFile), dataDirRelPath, "storage/_pb_users_auth_/4q1xlclmfloku33/thumbs_300_1SEi6Q6U72.png/70x50t_300_1SEi6Q6U72.png")
testThumbCropBottomPath := filepath.Join(path.Dir(currentFile), dataDirRelPath, "storage/_pb_users_auth_/4q1xlclmfloku33/thumbs_300_1SEi6Q6U72.png/70x50b_300_1SEi6Q6U72.png")
testThumbFitPath := filepath.Join(path.Dir(currentFile), dataDirRelPath, "storage/_pb_users_auth_/4q1xlclmfloku33/thumbs_300_1SEi6Q6U72.png/70x50f_300_1SEi6Q6U72.png")
testThumbZeroWidthPath := filepath.Join(path.Dir(currentFile), dataDirRelPath, "storage/_pb_users_auth_/4q1xlclmfloku33/thumbs_300_1SEi6Q6U72.png/0x50_300_1SEi6Q6U72.png")
testThumbZeroHeightPath := filepath.Join(path.Dir(currentFile), dataDirRelPath, "storage/_pb_users_auth_/4q1xlclmfloku33/thumbs_300_1SEi6Q6U72.png/70x0_300_1SEi6Q6U72.png")
testFile, fileErr := os.ReadFile(testFilePath)
if fileErr != nil {
@@ -67,28 +68,28 @@ func TestFileDownload(t *testing.T) {
{
Name: "missing collection",
Method: http.MethodGet,
Url: "/api/files/missing/577bd676-aacb-4072-b7da-99d00ee210a4/4881bdef-06b4-4dea-8d97-6125ad242677.png",
Url: "/api/files/missing/4q1xlclmfloku33/300_1SEi6Q6U72.png",
ExpectedStatus: 404,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "missing record",
Method: http.MethodGet,
Url: "/api/files/demo/00000000-aacb-4072-b7da-99d00ee210a4/4881bdef-06b4-4dea-8d97-6125ad242677.png",
Url: "/api/files/_pb_users_auth_/missing/300_1SEi6Q6U72.png",
ExpectedStatus: 404,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "missing file",
Method: http.MethodGet,
Url: "/api/files/demo/577bd676-aacb-4072-b7da-99d00ee210a4/00000000-06b4-4dea-8d97-6125ad242677.png",
Url: "/api/files/_pb_users_auth_/4q1xlclmfloku33/missing.png",
ExpectedStatus: 404,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "existing image",
Method: http.MethodGet,
Url: "/api/files/demo/577bd676-aacb-4072-b7da-99d00ee210a4/4881bdef-06b4-4dea-8d97-6125ad242677.png",
Url: "/api/files/_pb_users_auth_/4q1xlclmfloku33/300_1SEi6Q6U72.png",
ExpectedStatus: 200,
ExpectedContent: []string{string(testImg)},
ExpectedEvents: map[string]int{
@@ -98,7 +99,7 @@ func TestFileDownload(t *testing.T) {
{
Name: "existing image - missing thumb (should fallback to the original)",
Method: http.MethodGet,
Url: "/api/files/demo/577bd676-aacb-4072-b7da-99d00ee210a4/4881bdef-06b4-4dea-8d97-6125ad242677.png?thumb=999x999",
Url: "/api/files/_pb_users_auth_/4q1xlclmfloku33/300_1SEi6Q6U72.png?thumb=999x999",
ExpectedStatus: 200,
ExpectedContent: []string{string(testImg)},
ExpectedEvents: map[string]int{
@@ -108,7 +109,7 @@ func TestFileDownload(t *testing.T) {
{
Name: "existing image - existing thumb (crop center)",
Method: http.MethodGet,
Url: "/api/files/demo/577bd676-aacb-4072-b7da-99d00ee210a4/4881bdef-06b4-4dea-8d97-6125ad242677.png?thumb=70x50",
Url: "/api/files/_pb_users_auth_/4q1xlclmfloku33/300_1SEi6Q6U72.png?thumb=70x50",
ExpectedStatus: 200,
ExpectedContent: []string{string(testThumbCropCenter)},
ExpectedEvents: map[string]int{
@@ -118,7 +119,7 @@ func TestFileDownload(t *testing.T) {
{
Name: "existing image - existing thumb (crop top)",
Method: http.MethodGet,
Url: "/api/files/demo/577bd676-aacb-4072-b7da-99d00ee210a4/4881bdef-06b4-4dea-8d97-6125ad242677.png?thumb=70x50t",
Url: "/api/files/_pb_users_auth_/4q1xlclmfloku33/300_1SEi6Q6U72.png?thumb=70x50t",
ExpectedStatus: 200,
ExpectedContent: []string{string(testThumbCropTop)},
ExpectedEvents: map[string]int{
@@ -128,7 +129,7 @@ func TestFileDownload(t *testing.T) {
{
Name: "existing image - existing thumb (crop bottom)",
Method: http.MethodGet,
Url: "/api/files/demo/577bd676-aacb-4072-b7da-99d00ee210a4/4881bdef-06b4-4dea-8d97-6125ad242677.png?thumb=70x50b",
Url: "/api/files/_pb_users_auth_/4q1xlclmfloku33/300_1SEi6Q6U72.png?thumb=70x50b",
ExpectedStatus: 200,
ExpectedContent: []string{string(testThumbCropBottom)},
ExpectedEvents: map[string]int{
@@ -138,7 +139,7 @@ func TestFileDownload(t *testing.T) {
{
Name: "existing image - existing thumb (fit)",
Method: http.MethodGet,
Url: "/api/files/demo/577bd676-aacb-4072-b7da-99d00ee210a4/4881bdef-06b4-4dea-8d97-6125ad242677.png?thumb=70x50f",
Url: "/api/files/_pb_users_auth_/4q1xlclmfloku33/300_1SEi6Q6U72.png?thumb=70x50f",
ExpectedStatus: 200,
ExpectedContent: []string{string(testThumbFit)},
ExpectedEvents: map[string]int{
@@ -148,7 +149,7 @@ func TestFileDownload(t *testing.T) {
{
Name: "existing image - existing thumb (zero width)",
Method: http.MethodGet,
Url: "/api/files/demo/577bd676-aacb-4072-b7da-99d00ee210a4/4881bdef-06b4-4dea-8d97-6125ad242677.png?thumb=0x50",
Url: "/api/files/_pb_users_auth_/4q1xlclmfloku33/300_1SEi6Q6U72.png?thumb=0x50",
ExpectedStatus: 200,
ExpectedContent: []string{string(testThumbZeroWidth)},
ExpectedEvents: map[string]int{
@@ -158,7 +159,7 @@ func TestFileDownload(t *testing.T) {
{
Name: "existing image - existing thumb (zero height)",
Method: http.MethodGet,
Url: "/api/files/demo/577bd676-aacb-4072-b7da-99d00ee210a4/4881bdef-06b4-4dea-8d97-6125ad242677.png?thumb=70x0",
Url: "/api/files/_pb_users_auth_/4q1xlclmfloku33/300_1SEi6Q6U72.png?thumb=70x0",
ExpectedStatus: 200,
ExpectedContent: []string{string(testThumbZeroHeight)},
ExpectedEvents: map[string]int{
@@ -168,7 +169,7 @@ func TestFileDownload(t *testing.T) {
{
Name: "existing non image file - thumb parameter should be ignored",
Method: http.MethodGet,
Url: "/api/files/demo/848a1dea-5ddd-42d6-a00d-030547bffcfe/8fe61d65-6a2e-4f11-87b3-d8a3170bfd4f.txt?thumb=100x100",
Url: "/api/files/_pb_users_auth_/oap640cot4yru2s/test_kfd2wYLxkz.txt?thumb=100x100",
ExpectedStatus: 200,
ExpectedContent: []string{string(testFile)},
ExpectedEvents: map[string]int{
+7 -8
View File
@@ -7,12 +7,11 @@ import (
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/tools/rest"
"github.com/pocketbase/pocketbase/tools/search"
)
// BindLogsApi registers the request logs api endpoints.
func BindLogsApi(app core.App, rg *echo.Group) {
// bindLogsApi registers the request logs api endpoints.
func bindLogsApi(app core.App, rg *echo.Group) {
api := logsApi{app: app}
subGroup := rg.Group("/logs", RequireAdminAuth())
@@ -39,7 +38,7 @@ func (api *logsApi) requestsList(c echo.Context) error {
ParseAndExec(c.QueryString(), &[]*models.Request{})
if err != nil {
return rest.NewBadRequestError("", err)
return NewBadRequestError("", err)
}
return c.JSON(http.StatusOK, result)
@@ -55,13 +54,13 @@ func (api *logsApi) requestsStats(c echo.Context) error {
var err error
expr, err = search.FilterData(filter).BuildExpr(fieldResolver)
if err != nil {
return rest.NewBadRequestError("Invalid filter format.", err)
return NewBadRequestError("Invalid filter format.", err)
}
}
stats, err := api.app.LogsDao().RequestsStats(expr)
if err != nil {
return rest.NewBadRequestError("Failed to generate requests stats.", err)
return NewBadRequestError("Failed to generate requests stats.", err)
}
return c.JSON(http.StatusOK, stats)
@@ -70,12 +69,12 @@ func (api *logsApi) requestsStats(c echo.Context) error {
func (api *logsApi) requestView(c echo.Context) error {
id := c.PathParam("id")
if id == "" {
return rest.NewNotFoundError("", nil)
return NewNotFoundError("", nil)
}
request, err := api.app.LogsDao().FindRequestById(id)
if err != nil || request == nil {
return rest.NewNotFoundError("", err)
return NewNotFoundError("", err)
}
return c.JSON(http.StatusOK, request)
+14 -14
View File
@@ -18,11 +18,11 @@ func TestRequestsList(t *testing.T) {
ExpectedContent: []string{`"data":{}`},
},
{
Name: "authorized as user",
Name: "authorized as auth record",
Method: http.MethodGet,
Url: "/api/logs/requests",
RequestHeaders: map[string]string{
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
},
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
@@ -32,7 +32,7 @@ func TestRequestsList(t *testing.T) {
Method: http.MethodGet,
Url: "/api/logs/requests",
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
if err := tests.MockRequestLogsData(app); err != nil {
@@ -54,7 +54,7 @@ func TestRequestsList(t *testing.T) {
Method: http.MethodGet,
Url: "/api/logs/requests?filter=status>200",
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
if err := tests.MockRequestLogsData(app); err != nil {
@@ -87,11 +87,11 @@ func TestRequestView(t *testing.T) {
ExpectedContent: []string{`"data":{}`},
},
{
Name: "authorized as user",
Name: "authorized as auth record",
Method: http.MethodGet,
Url: "/api/logs/requests/873f2133-9f38-44fb-bf82-c8f53b310d91",
RequestHeaders: map[string]string{
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
},
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
@@ -101,7 +101,7 @@ func TestRequestView(t *testing.T) {
Method: http.MethodGet,
Url: "/api/logs/requests/missing1-9f38-44fb-bf82-c8f53b310d91",
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
if err := tests.MockRequestLogsData(app); err != nil {
@@ -116,7 +116,7 @@ func TestRequestView(t *testing.T) {
Method: http.MethodGet,
Url: "/api/logs/requests/873f2133-9f38-44fb-bf82-c8f53b310d91",
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
if err := tests.MockRequestLogsData(app); err != nil {
@@ -145,11 +145,11 @@ func TestRequestsStats(t *testing.T) {
ExpectedContent: []string{`"data":{}`},
},
{
Name: "authorized as user",
Name: "authorized as auth record",
Method: http.MethodGet,
Url: "/api/logs/requests/stats",
RequestHeaders: map[string]string{
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
},
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
@@ -159,7 +159,7 @@ func TestRequestsStats(t *testing.T) {
Method: http.MethodGet,
Url: "/api/logs/requests/stats",
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
if err := tests.MockRequestLogsData(app); err != nil {
@@ -168,7 +168,7 @@ func TestRequestsStats(t *testing.T) {
},
ExpectedStatus: 200,
ExpectedContent: []string{
`[{"total":1,"date":"2022-05-01 10:00:00.000"},{"total":1,"date":"2022-05-02 10:00:00.000"}]`,
`[{"total":1,"date":"2022-05-01 10:00:00.000Z"},{"total":1,"date":"2022-05-02 10:00:00.000Z"}]`,
},
},
{
@@ -176,7 +176,7 @@ func TestRequestsStats(t *testing.T) {
Method: http.MethodGet,
Url: "/api/logs/requests/stats?filter=status>200",
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
if err := tests.MockRequestLogsData(app); err != nil {
@@ -185,7 +185,7 @@ func TestRequestsStats(t *testing.T) {
},
ExpectedStatus: 200,
ExpectedContent: []string{
`[{"total":1,"date":"2022-05-02 10:00:00.000"}]`,
`[{"total":1,"date":"2022-05-02 10:00:00.000Z"}]`,
},
},
}
+131 -64
View File
@@ -11,30 +11,32 @@ import (
"github.com/labstack/echo/v5"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/tools/rest"
"github.com/pocketbase/pocketbase/tokens"
"github.com/pocketbase/pocketbase/tools/list"
"github.com/pocketbase/pocketbase/tools/routine"
"github.com/pocketbase/pocketbase/tools/security"
"github.com/pocketbase/pocketbase/tools/types"
"github.com/spf13/cast"
)
// Common request context keys used by the middlewares and api handlers.
const (
ContextUserKey string = "user"
ContextAdminKey string = "admin"
ContextAuthRecordKey string = "authRecord"
ContextCollectionKey string = "collection"
)
// RequireGuestOnly middleware requires a request to NOT have a valid
// Authorization header set.
// Authorization header.
//
// This middleware is the opposite of [apis.RequireAdminOrUserAuth()].
// This middleware is the opposite of [apis.RequireAdminOrRecordAuth()].
func RequireGuestOnly() echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
err := rest.NewBadRequestError("The request can be accessed only by guests.", nil)
err := NewBadRequestError("The request can be accessed only by guests.", nil)
user, _ := c.Get(ContextUserKey).(*models.User)
if user != nil {
record, _ := c.Get(ContextAuthRecordKey).(*models.Record)
if record != nil {
return err
}
@@ -48,14 +50,55 @@ func RequireGuestOnly() echo.MiddlewareFunc {
}
}
// RequireUserAuth middleware requires a request to have
// a valid user Authorization header set (aka. `Authorization: User ...`).
func RequireUserAuth() echo.MiddlewareFunc {
// RequireRecordAuth middleware requires a request to have
// a valid record auth Authorization header.
//
// The auth record could be from any collection.
//
// You can further filter the allowed record auth collections by
// specifying their names.
//
// Example:
// apis.RequireRecordAuth()
// Or:
// apis.RequireRecordAuth("users", "supervisors")
//
// To restrict the auth record only to the loaded context collection,
// use [apis.RequireSameContextRecordAuth()] instead.
func RequireRecordAuth(optCollectionNames ...string) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
user, _ := c.Get(ContextUserKey).(*models.User)
if user == nil {
return rest.NewUnauthorizedError("The request requires valid user authorization token to be set.", nil)
record, _ := c.Get(ContextAuthRecordKey).(*models.Record)
if record == nil {
return NewUnauthorizedError("The request requires valid record authorization token to be set.", nil)
}
// check record collection name
if len(optCollectionNames) > 0 && !list.ExistInSlice(record.Collection().Name, optCollectionNames) {
return NewForbiddenError("The authorized record model is not allowed to perform this action.", nil)
}
return next(c)
}
}
}
//
// RequireSameContextRecordAuth middleware requires a request to have
// a valid record Authorization header.
//
// The auth record must be from the same collection already loaded in the context.
func RequireSameContextRecordAuth() echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
record, _ := c.Get(ContextAuthRecordKey).(*models.Record)
if record == nil {
return NewUnauthorizedError("The request requires valid record authorization token to be set.", nil)
}
collection, _ := c.Get(ContextCollectionKey).(*models.Collection)
if collection == nil || record.Collection().Id != collection.Id {
return NewForbiddenError(fmt.Sprintf("The request requires auth record from %s collection.", record.Collection().Name), nil)
}
return next(c)
@@ -64,13 +107,13 @@ func RequireUserAuth() echo.MiddlewareFunc {
}
// RequireAdminAuth middleware requires a request to have
// a valid admin Authorization header set (aka. `Authorization: Admin ...`).
// a valid admin Authorization header.
func RequireAdminAuth() echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
admin, _ := c.Get(ContextAdminKey).(*models.Admin)
if admin == nil {
return rest.NewUnauthorizedError("The request requires admin authorization token to be set.", nil)
return NewUnauthorizedError("The request requires valid admin authorization token to be set.", nil)
}
return next(c)
@@ -79,14 +122,14 @@ func RequireAdminAuth() echo.MiddlewareFunc {
}
// RequireAdminAuthOnlyIfAny middleware requires a request to have
// a valid admin Authorization header set (aka. `Authorization: Admin ...`)
// ONLY if the application has at least 1 existing Admin model.
// a valid admin Authorization header ONLY if the application has
// at least 1 existing Admin model.
func RequireAdminAuthOnlyIfAny(app core.App) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
totalAdmins, err := app.Dao().TotalAdmins()
if err != nil {
return rest.NewBadRequestError("Failed to fetch admins info.", err)
return NewBadRequestError("Failed to fetch admins info.", err)
}
admin, _ := c.Get(ContextAdminKey).(*models.Admin)
@@ -95,24 +138,29 @@ func RequireAdminAuthOnlyIfAny(app core.App) echo.MiddlewareFunc {
return next(c)
}
return rest.NewUnauthorizedError("The request requires admin authorization token to be set.", nil)
return NewUnauthorizedError("The request requires valid admin authorization token to be set.", nil)
}
}
}
// RequireAdminOrUserAuth middleware requires a request to have
// a valid admin or user Authorization header set
// (aka. `Authorization: Admin ...` or `Authorization: User ...`).
// RequireAdminOrRecordAuth middleware requires a request to have
// a valid admin or record Authorization header set.
//
// You can further filter the allowed auth record collections by providing their names.
//
// This middleware is the opposite of [apis.RequireGuestOnly()].
func RequireAdminOrUserAuth() echo.MiddlewareFunc {
func RequireAdminOrRecordAuth(optCollectionNames ...string) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
admin, _ := c.Get(ContextAdminKey).(*models.Admin)
user, _ := c.Get(ContextUserKey).(*models.User)
record, _ := c.Get(ContextAuthRecordKey).(*models.Record)
if admin == nil && user == nil {
return rest.NewUnauthorizedError("The request requires admin or user authorization token to be set.", nil)
if admin == nil && record == nil {
return NewUnauthorizedError("The request requires admin or record authorization token to be set.", nil)
}
if record != nil && len(optCollectionNames) > 0 && !list.ExistInSlice(record.Collection().Name, optCollectionNames) {
return NewForbiddenError("The authorized record model is not allowed to perform this action.", nil)
}
return next(c)
@@ -121,29 +169,33 @@ func RequireAdminOrUserAuth() echo.MiddlewareFunc {
}
// RequireAdminOrOwnerAuth middleware requires a request to have
// a valid admin or user owner Authorization header set
// (aka. `Authorization: Admin ...` or `Authorization: User ...`).
// a valid admin or auth record owner Authorization header set.
//
// This middleware is similar to [apis.RequireAdminOrUserAuth()] but
// for the user token expects to have the same id as the path parameter
// `ownerIdParam` (default to "id").
// This middleware is similar to [apis.RequireAdminOrRecordAuth()] but
// for the auth record token expects to have the same id as the path
// parameter ownerIdParam (default to "id" if empty).
func RequireAdminOrOwnerAuth(ownerIdParam string) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
admin, _ := c.Get(ContextAdminKey).(*models.Admin)
if admin != nil {
return next(c)
}
record, _ := c.Get(ContextAuthRecordKey).(*models.Record)
if record == nil {
return NewUnauthorizedError("The request requires admin or record authorization token to be set.", nil)
}
if ownerIdParam == "" {
ownerIdParam = "id"
}
ownerId := c.PathParam(ownerIdParam)
admin, _ := c.Get(ContextAdminKey).(*models.Admin)
loggedUser, _ := c.Get(ContextUserKey).(*models.User)
if admin == nil && loggedUser == nil {
return rest.NewUnauthorizedError("The request requires admin or user authorization token to be set.", nil)
}
if admin == nil && loggedUser.Id != ownerId {
return rest.NewForbiddenError("You are not allowed to perform this request.", nil)
// note: it is "safe" to compare only the record id since the auth
// record ids are treated as unique across all auth collections
if record.Id != ownerId {
return NewForbiddenError("You are not allowed to perform this request.", nil)
}
return next(c)
@@ -152,32 +204,41 @@ func RequireAdminOrOwnerAuth(ownerIdParam string) echo.MiddlewareFunc {
}
// LoadAuthContext middleware reads the Authorization request header
// and loads the token related user or admin instance into the
// and loads the token related record or admin instance into the
// request's context.
//
// This middleware is expected to be registered by default for all routes.
// This middleware is expected to be already registered by default for all routes.
func LoadAuthContext(app core.App) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
token := c.Request().Header.Get("Authorization")
if token == "" {
return next(c)
}
if token != "" {
if strings.HasPrefix(token, "User ") {
user, err := app.Dao().FindUserByToken(
token[5:],
app.Settings().UserAuthToken.Secret,
)
if err == nil && user != nil {
c.Set(ContextUserKey, user)
}
} else if strings.HasPrefix(token, "Admin ") {
admin, err := app.Dao().FindAdminByToken(
token[6:],
app.Settings().AdminAuthToken.Secret,
)
if err == nil && admin != nil {
c.Set(ContextAdminKey, admin)
}
// the schema is not required and it is only for
// compatibility with the defaults of some HTTP clients
token = strings.TrimPrefix(token, "Bearer ")
claims, _ := security.ParseUnverifiedJWT(token)
tokenType := cast.ToString(claims["type"])
switch tokenType {
case tokens.TypeAdmin:
admin, err := app.Dao().FindAdminByToken(
token,
app.Settings().AdminAuthToken.Secret,
)
if err == nil && admin != nil {
c.Set(ContextAdminKey, admin)
}
case tokens.TypeAuthRecord:
record, err := app.Dao().FindAuthRecordByToken(
token,
app.Settings().RecordAuthToken.Secret,
)
if err == nil && record != nil {
c.Set(ContextAuthRecordKey, record)
}
}
@@ -188,13 +249,19 @@ func LoadAuthContext(app core.App) echo.MiddlewareFunc {
// LoadCollectionContext middleware finds the collection with related
// path identifier and loads it into the request context.
func LoadCollectionContext(app core.App) echo.MiddlewareFunc {
//
// Set optCollectionTypes to further filter the found collection by its type.
func LoadCollectionContext(app core.App, optCollectionTypes ...string) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
if param := c.PathParam("collection"); param != "" {
collection, err := app.Dao().FindCollectionByNameOrId(param)
if err != nil || collection == nil {
return rest.NewNotFoundError("", err)
return NewNotFoundError("", err)
}
if len(optCollectionTypes) > 0 && !list.ExistInSlice(collection.Type, optCollectionTypes) {
return NewBadRequestError("Invalid collection type.", nil)
}
c.Set(ContextCollectionKey, collection)
@@ -231,7 +298,7 @@ func ActivityLogger(app core.App) echo.MiddlewareFunc {
status = v.Code
meta["errorMessage"] = v.Message
meta["errorDetails"] = fmt.Sprint(v.Internal)
case *rest.ApiError:
case *ApiError:
status = v.Code
meta["errorMessage"] = v.Message
meta["errorDetails"] = fmt.Sprint(v.RawData())
@@ -242,8 +309,8 @@ func ActivityLogger(app core.App) echo.MiddlewareFunc {
}
requestAuth := models.RequestAuthGuest
if c.Get(ContextUserKey) != nil {
requestAuth = models.RequestAuthUser
if c.Get(ContextAuthRecordKey) != nil {
requestAuth = models.RequestAuthRecord
} else if c.Get(ContextAdminKey) != nil {
requestAuth = models.RequestAuthAdmin
}
+418 -42
View File
@@ -12,11 +12,11 @@ import (
func TestRequireGuestOnly(t *testing.T) {
scenarios := []tests.ApiScenario{
{
Name: "valid user token",
Name: "valid record token",
Method: http.MethodGet,
Url: "/my/test",
RequestHeaders: map[string]string{
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.AddRoute(echo.Route{
@@ -38,7 +38,7 @@ func TestRequireGuestOnly(t *testing.T) {
Method: http.MethodGet,
Url: "/my/test",
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.AddRoute(echo.Route{
@@ -60,7 +60,7 @@ func TestRequireGuestOnly(t *testing.T) {
Method: http.MethodGet,
Url: "/my/test",
RequestHeaders: map[string]string{
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxNjQwOTkxNjYxfQ.HkAldxpbn0EybkMfFGQKEJUIYKE5UJA0AjcsrV7Q6Io",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoxNjQwOTkxNjYxfQ.HqvpCpM0RAk3Qu9PfCMuZsk_DKh9UYuzFLwXBMTZd1w",
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.AddRoute(echo.Route{
@@ -103,7 +103,7 @@ func TestRequireGuestOnly(t *testing.T) {
}
}
func TestRequireUserAuth(t *testing.T) {
func TestRequireRecordAuth(t *testing.T) {
scenarios := []tests.ApiScenario{
{
Name: "guest",
@@ -117,7 +117,7 @@ func TestRequireUserAuth(t *testing.T) {
return c.String(200, "test123")
},
Middlewares: []echo.MiddlewareFunc{
apis.RequireUserAuth(),
apis.RequireRecordAuth(),
},
})
},
@@ -129,7 +129,7 @@ func TestRequireUserAuth(t *testing.T) {
Method: http.MethodGet,
Url: "/my/test",
RequestHeaders: map[string]string{
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxNjQwOTkxNjYxfQ.HkAldxpbn0EybkMfFGQKEJUIYKE5UJA0AjcsrV7Q6Io",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoxNjQwOTkxNjYxfQ.HqvpCpM0RAk3Qu9PfCMuZsk_DKh9UYuzFLwXBMTZd1w",
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.AddRoute(echo.Route{
@@ -139,7 +139,7 @@ func TestRequireUserAuth(t *testing.T) {
return c.String(200, "test123")
},
Middlewares: []echo.MiddlewareFunc{
apis.RequireUserAuth(),
apis.RequireRecordAuth(),
},
})
},
@@ -151,7 +151,7 @@ func TestRequireUserAuth(t *testing.T) {
Method: http.MethodGet,
Url: "/my/test",
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.AddRoute(echo.Route{
@@ -161,7 +161,7 @@ func TestRequireUserAuth(t *testing.T) {
return c.String(200, "test123")
},
Middlewares: []echo.MiddlewareFunc{
apis.RequireUserAuth(),
apis.RequireRecordAuth(),
},
})
},
@@ -169,11 +169,11 @@ func TestRequireUserAuth(t *testing.T) {
ExpectedContent: []string{`"data":{}`},
},
{
Name: "valid user token",
Name: "valid record token",
Method: http.MethodGet,
Url: "/my/test",
RequestHeaders: map[string]string{
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.AddRoute(echo.Route{
@@ -183,7 +183,167 @@ func TestRequireUserAuth(t *testing.T) {
return c.String(200, "test123")
},
Middlewares: []echo.MiddlewareFunc{
apis.RequireUserAuth(),
apis.RequireRecordAuth(),
},
})
},
ExpectedStatus: 200,
ExpectedContent: []string{"test123"},
},
{
Name: "valid record token with collection not in the restricted list",
Method: http.MethodGet,
Url: "/my/test",
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.AddRoute(echo.Route{
Method: http.MethodGet,
Path: "/my/test",
Handler: func(c echo.Context) error {
return c.String(200, "test123")
},
Middlewares: []echo.MiddlewareFunc{
apis.RequireRecordAuth("demo1", "demo2"),
},
})
},
ExpectedStatus: 403,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "valid record token with collection in the restricted list",
Method: http.MethodGet,
Url: "/my/test",
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.AddRoute(echo.Route{
Method: http.MethodGet,
Path: "/my/test",
Handler: func(c echo.Context) error {
return c.String(200, "test123")
},
Middlewares: []echo.MiddlewareFunc{
apis.RequireRecordAuth("demo1", "demo2", "users"),
},
})
},
ExpectedStatus: 200,
ExpectedContent: []string{"test123"},
},
}
for _, scenario := range scenarios {
scenario.Test(t)
}
}
func TestRequireSameContextRecordAuth(t *testing.T) {
scenarios := []tests.ApiScenario{
{
Name: "guest",
Method: http.MethodGet,
Url: "/my/users/test",
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.AddRoute(echo.Route{
Method: http.MethodGet,
Path: "/my/:collection/test",
Handler: func(c echo.Context) error {
return c.String(200, "test123")
},
Middlewares: []echo.MiddlewareFunc{
apis.RequireSameContextRecordAuth(),
},
})
},
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "expired/invalid token",
Method: http.MethodGet,
Url: "/my/users/test",
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoxNjQwOTkxNjYxfQ.HqvpCpM0RAk3Qu9PfCMuZsk_DKh9UYuzFLwXBMTZd1w",
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.AddRoute(echo.Route{
Method: http.MethodGet,
Path: "/my/:collection/test",
Handler: func(c echo.Context) error {
return c.String(200, "test123")
},
Middlewares: []echo.MiddlewareFunc{
apis.RequireSameContextRecordAuth(),
},
})
},
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "valid admin token",
Method: http.MethodGet,
Url: "/my/users/test",
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.AddRoute(echo.Route{
Method: http.MethodGet,
Path: "/my/:collection/test",
Handler: func(c echo.Context) error {
return c.String(200, "test123")
},
Middlewares: []echo.MiddlewareFunc{
apis.RequireSameContextRecordAuth(),
},
})
},
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "valid record token but from different collection",
Method: http.MethodGet,
Url: "/my/users/test",
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6ImdrMzkwcWVnczR5NDd3biIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoidjg1MXE0cjc5MHJoa25sIiwiZXhwIjoyMjA4OTg1MjYxfQ.q34IWXrRWsjLvbbVNRfAs_J4SoTHloNBfdGEiLmy-D8",
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.AddRoute(echo.Route{
Method: http.MethodGet,
Path: "/my/:collection/test",
Handler: func(c echo.Context) error {
return c.String(200, "test123")
},
Middlewares: []echo.MiddlewareFunc{
apis.RequireSameContextRecordAuth(),
},
})
},
ExpectedStatus: 403,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "valid record token",
Method: http.MethodGet,
Url: "/my/test",
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.AddRoute(echo.Route{
Method: http.MethodGet,
Path: "/my/test",
Handler: func(c echo.Context) error {
return c.String(200, "test123")
},
Middlewares: []echo.MiddlewareFunc{
apis.RequireRecordAuth(),
},
})
},
@@ -223,7 +383,7 @@ func TestRequireAdminAuth(t *testing.T) {
Method: http.MethodGet,
Url: "/my/test",
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTY0MTAxMzIwMH0.Gp_1b5WVhqjj2o3nJhNUlJmpdiwFLXN72LbMP-26gjA",
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTY0MTAxMzIwMH0.Gp_1b5WVhqjj2o3nJhNUlJmpdiwFLXN72LbMP-26gjA",
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.AddRoute(echo.Route{
@@ -241,11 +401,11 @@ func TestRequireAdminAuth(t *testing.T) {
ExpectedContent: []string{`"data":{}`},
},
{
Name: "valid user token",
Name: "valid record token",
Method: http.MethodGet,
Url: "/my/test",
RequestHeaders: map[string]string{
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.AddRoute(echo.Route{
@@ -267,7 +427,7 @@ func TestRequireAdminAuth(t *testing.T) {
Method: http.MethodGet,
Url: "/my/test",
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.AddRoute(echo.Route{
@@ -342,7 +502,7 @@ func TestRequireAdminAuthOnlyIfAny(t *testing.T) {
Method: http.MethodGet,
Url: "/my/test",
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTY0MTAxMzIwMH0.Gp_1b5WVhqjj2o3nJhNUlJmpdiwFLXN72LbMP-26gjA",
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTY0MTAxMzIwMH0.Gp_1b5WVhqjj2o3nJhNUlJmpdiwFLXN72LbMP-26gjA",
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.AddRoute(echo.Route{
@@ -360,11 +520,11 @@ func TestRequireAdminAuthOnlyIfAny(t *testing.T) {
ExpectedContent: []string{`"data":{}`},
},
{
Name: "valid user token",
Name: "valid record token",
Method: http.MethodGet,
Url: "/my/test",
RequestHeaders: map[string]string{
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.AddRoute(echo.Route{
@@ -386,7 +546,7 @@ func TestRequireAdminAuthOnlyIfAny(t *testing.T) {
Method: http.MethodGet,
Url: "/my/test",
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.AddRoute(echo.Route{
@@ -410,7 +570,7 @@ func TestRequireAdminAuthOnlyIfAny(t *testing.T) {
}
}
func TestRequireAdminOrUserAuth(t *testing.T) {
func TestRequireAdminOrRecordAuth(t *testing.T) {
scenarios := []tests.ApiScenario{
{
Name: "guest",
@@ -424,7 +584,7 @@ func TestRequireAdminOrUserAuth(t *testing.T) {
return c.String(200, "test123")
},
Middlewares: []echo.MiddlewareFunc{
apis.RequireAdminOrUserAuth(),
apis.RequireAdminOrRecordAuth(),
},
})
},
@@ -436,7 +596,7 @@ func TestRequireAdminOrUserAuth(t *testing.T) {
Method: http.MethodGet,
Url: "/my/test",
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTY0MTAxMzIwMH0.Gp_1b5WVhqjj2o3nJhNUlJmpdiwFLXN72LbMP-26gjA",
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTY0MTAxMzIwMH0.Gp_1b5WVhqjj2o3nJhNUlJmpdiwFLXN72LbMP-26gjA",
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.AddRoute(echo.Route{
@@ -446,7 +606,7 @@ func TestRequireAdminOrUserAuth(t *testing.T) {
return c.String(200, "test123")
},
Middlewares: []echo.MiddlewareFunc{
apis.RequireAdminOrUserAuth(),
apis.RequireAdminOrRecordAuth(),
},
})
},
@@ -454,11 +614,11 @@ func TestRequireAdminOrUserAuth(t *testing.T) {
ExpectedContent: []string{`"data":{}`},
},
{
Name: "valid user token",
Name: "valid record token",
Method: http.MethodGet,
Url: "/my/test",
RequestHeaders: map[string]string{
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.AddRoute(echo.Route{
@@ -468,7 +628,51 @@ func TestRequireAdminOrUserAuth(t *testing.T) {
return c.String(200, "test123")
},
Middlewares: []echo.MiddlewareFunc{
apis.RequireAdminOrUserAuth(),
apis.RequireAdminOrRecordAuth(),
},
})
},
ExpectedStatus: 200,
ExpectedContent: []string{"test123"},
},
{
Name: "valid record token with collection not in the restricted list",
Method: http.MethodGet,
Url: "/my/test",
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.AddRoute(echo.Route{
Method: http.MethodGet,
Path: "/my/test",
Handler: func(c echo.Context) error {
return c.String(200, "test123")
},
Middlewares: []echo.MiddlewareFunc{
apis.RequireAdminOrRecordAuth("demo1", "demo2", "clients"),
},
})
},
ExpectedStatus: 403,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "valid record token with collection in the restricted list",
Method: http.MethodGet,
Url: "/my/test",
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.AddRoute(echo.Route{
Method: http.MethodGet,
Path: "/my/test",
Handler: func(c echo.Context) error {
return c.String(200, "test123")
},
Middlewares: []echo.MiddlewareFunc{
apis.RequireAdminOrRecordAuth("demo1", "demo2", "users"),
},
})
},
@@ -480,7 +684,7 @@ func TestRequireAdminOrUserAuth(t *testing.T) {
Method: http.MethodGet,
Url: "/my/test",
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.AddRoute(echo.Route{
@@ -490,7 +694,29 @@ func TestRequireAdminOrUserAuth(t *testing.T) {
return c.String(200, "test123")
},
Middlewares: []echo.MiddlewareFunc{
apis.RequireAdminOrUserAuth(),
apis.RequireAdminOrRecordAuth(),
},
})
},
ExpectedStatus: 200,
ExpectedContent: []string{"test123"},
},
{
Name: "valid admin token + restricted collections list (should be ignored)",
Method: http.MethodGet,
Url: "/my/test",
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.AddRoute(echo.Route{
Method: http.MethodGet,
Path: "/my/test",
Handler: func(c echo.Context) error {
return c.String(200, "test123")
},
Middlewares: []echo.MiddlewareFunc{
apis.RequireAdminOrRecordAuth("demo1", "demo2"),
},
})
},
@@ -509,7 +735,7 @@ func TestRequireAdminOrOwnerAuth(t *testing.T) {
{
Name: "guest",
Method: http.MethodGet,
Url: "/my/test/4d0197cc-2b4a-3f83-a26b-d77bc8423d3c",
Url: "/my/test/4q1xlclmfloku33",
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.AddRoute(echo.Route{
Method: http.MethodGet,
@@ -528,9 +754,9 @@ func TestRequireAdminOrOwnerAuth(t *testing.T) {
{
Name: "expired/invalid token",
Method: http.MethodGet,
Url: "/my/test/4d0197cc-2b4a-3f83-a26b-d77bc8423d3c",
Url: "/my/test/4q1xlclmfloku33",
RequestHeaders: map[string]string{
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxNjQwOTkxNjYxfQ.HkAldxpbn0EybkMfFGQKEJUIYKE5UJA0AjcsrV7Q6Io",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoxNjQwOTkxNjYxfQ.HqvpCpM0RAk3Qu9PfCMuZsk_DKh9UYuzFLwXBMTZd1w",
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.AddRoute(echo.Route{
@@ -548,12 +774,11 @@ func TestRequireAdminOrOwnerAuth(t *testing.T) {
ExpectedContent: []string{`"data":{}`},
},
{
Name: "valid user token (different user)",
Name: "valid record token (different user)",
Method: http.MethodGet,
Url: "/my/test/4d0197cc-2b4a-3f83-a26b-d77bc8423d3c",
Url: "/my/test/4q1xlclmfloku33",
RequestHeaders: map[string]string{
// test3@example.com
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0eXBlIjoidXNlciIsImVtYWlsIjoidGVzdDNAZXhhbXBsZS5jb20iLCJpZCI6Ijk3Y2MzZDNkLTZiYTItMzgzZi1iNDJhLTdiYzg0ZDI3NDEwYyIsImV4cCI6MTg5MzUxNTU3Nn0.Q965uvlTxxOsZbACXSgJQNXykYK0TKZ87nyPzemvN4E",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6ImJnczgyMG4zNjF2ajFxZCIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.tW4NZWZ0mHBgvSZsQ0OOQhWajpUNFPCvNrOF9aCZLZs",
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.AddRoute(echo.Route{
@@ -571,11 +796,33 @@ func TestRequireAdminOrOwnerAuth(t *testing.T) {
ExpectedContent: []string{`"data":{}`},
},
{
Name: "valid user token (owner)",
Name: "valid record token (different collection)",
Method: http.MethodGet,
Url: "/my/test/4d0197cc-2b4a-3f83-a26b-d77bc8423d3c",
Url: "/my/test/4q1xlclmfloku33",
RequestHeaders: map[string]string{
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6ImdrMzkwcWVnczR5NDd3biIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoidjg1MXE0cjc5MHJoa25sIiwiZXhwIjoyMjA4OTg1MjYxfQ.q34IWXrRWsjLvbbVNRfAs_J4SoTHloNBfdGEiLmy-D8",
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.AddRoute(echo.Route{
Method: http.MethodGet,
Path: "/my/test/:id",
Handler: func(c echo.Context) error {
return c.String(200, "test123")
},
Middlewares: []echo.MiddlewareFunc{
apis.RequireAdminOrOwnerAuth(""),
},
})
},
ExpectedStatus: 403,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "valid record token (owner)",
Method: http.MethodGet,
Url: "/my/test/4q1xlclmfloku33",
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.AddRoute(echo.Route{
@@ -595,9 +842,9 @@ func TestRequireAdminOrOwnerAuth(t *testing.T) {
{
Name: "valid admin token",
Method: http.MethodGet,
Url: "/my/test/2b4a97cc-3f83-4d01-a26b-3d77bc842d3c",
Url: "/my/test/4q1xlclmfloku33",
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.AddRoute(echo.Route{
@@ -620,3 +867,132 @@ func TestRequireAdminOrOwnerAuth(t *testing.T) {
scenario.Test(t)
}
}
func TestLoadCollectionContext(t *testing.T) {
scenarios := []tests.ApiScenario{
{
Name: "missing collection",
Method: http.MethodGet,
Url: "/my/missing",
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.AddRoute(echo.Route{
Method: http.MethodGet,
Path: "/my/:collection",
Handler: func(c echo.Context) error {
return c.String(200, "test123")
},
Middlewares: []echo.MiddlewareFunc{
apis.LoadCollectionContext(app),
},
})
},
ExpectedStatus: 404,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "guest",
Method: http.MethodGet,
Url: "/my/demo1",
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.AddRoute(echo.Route{
Method: http.MethodGet,
Path: "/my/:collection",
Handler: func(c echo.Context) error {
return c.String(200, "test123")
},
Middlewares: []echo.MiddlewareFunc{
apis.LoadCollectionContext(app),
},
})
},
ExpectedStatus: 200,
ExpectedContent: []string{"test123"},
},
{
Name: "valid record token",
Method: http.MethodGet,
Url: "/my/demo1",
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.AddRoute(echo.Route{
Method: http.MethodGet,
Path: "/my/:collection",
Handler: func(c echo.Context) error {
return c.String(200, "test123")
},
Middlewares: []echo.MiddlewareFunc{
apis.LoadCollectionContext(app),
},
})
},
ExpectedStatus: 200,
ExpectedContent: []string{"test123"},
},
{
Name: "valid admin token",
Method: http.MethodGet,
Url: "/my/demo1",
RequestHeaders: map[string]string{
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.AddRoute(echo.Route{
Method: http.MethodGet,
Path: "/my/:collection",
Handler: func(c echo.Context) error {
return c.String(200, "test123")
},
Middlewares: []echo.MiddlewareFunc{
apis.LoadCollectionContext(app),
},
})
},
ExpectedStatus: 200,
ExpectedContent: []string{"test123"},
},
{
Name: "mismatched type",
Method: http.MethodGet,
Url: "/my/demo1",
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.AddRoute(echo.Route{
Method: http.MethodGet,
Path: "/my/:collection",
Handler: func(c echo.Context) error {
return c.String(200, "test123")
},
Middlewares: []echo.MiddlewareFunc{
apis.LoadCollectionContext(app, "auth"),
},
})
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "matched type",
Method: http.MethodGet,
Url: "/my/users",
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
e.AddRoute(echo.Route{
Method: http.MethodGet,
Path: "/my/:collection",
Handler: func(c echo.Context) error {
return c.String(200, "test123")
},
Middlewares: []echo.MiddlewareFunc{
apis.LoadCollectionContext(app, "auth"),
},
})
},
ExpectedStatus: 200,
ExpectedContent: []string{"test123"},
},
}
for _, scenario := range scenarios {
scenario.Test(t)
}
}
+71 -55
View File
@@ -15,13 +15,12 @@ import (
"github.com/pocketbase/pocketbase/forms"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/resolvers"
"github.com/pocketbase/pocketbase/tools/rest"
"github.com/pocketbase/pocketbase/tools/search"
"github.com/pocketbase/pocketbase/tools/subscriptions"
)
// BindRealtimeApi registers the realtime api endpoints.
func BindRealtimeApi(app core.App, rg *echo.Group) {
// bindRealtimeApi registers the realtime api endpoints.
func bindRealtimeApi(app core.App, rg *echo.Group) {
api := realtimeApi{app: app}
subGroup := rg.Group("/realtime", ActivityLogger(app))
@@ -113,25 +112,25 @@ func (api *realtimeApi) setSubscriptions(c echo.Context) error {
// read request data
if err := c.Bind(form); err != nil {
return rest.NewBadRequestError("", err)
return NewBadRequestError("", err)
}
// validate request data
if err := form.Validate(); err != nil {
return rest.NewBadRequestError("", err)
return NewBadRequestError("", err)
}
// find subscription client
client, err := api.app.SubscriptionsBroker().ClientById(form.ClientId)
if err != nil {
return rest.NewNotFoundError("Missing or invalid client id.", err)
return NewNotFoundError("Missing or invalid client id.", err)
}
// check if the previous request was authorized
oldAuthId := extractAuthIdFromGetter(client)
newAuthId := extractAuthIdFromGetter(c)
if oldAuthId != "" && oldAuthId != newAuthId {
return rest.NewForbiddenError("The current and the previous request authorization don't match.", nil)
return NewForbiddenError("The current and the previous request authorization don't match.", nil)
}
event := &core.RealtimeSubscribeEvent{
@@ -143,7 +142,7 @@ func (api *realtimeApi) setSubscriptions(c echo.Context) error {
handlerErr := api.app.OnRealtimeBeforeSubscribeRequest().Trigger(event, func(e *core.RealtimeSubscribeEvent) error {
// update auth state
e.Client.Set(ContextAdminKey, e.HttpContext.Get(ContextAdminKey))
e.Client.Set(ContextUserKey, e.HttpContext.Get(ContextUserKey))
e.Client.Set(ContextAuthRecordKey, e.HttpContext.Get(ContextAuthRecordKey))
// unsubscribe from any previous existing subscriptions
e.Client.Unsubscribe()
@@ -161,53 +160,52 @@ func (api *realtimeApi) setSubscriptions(c echo.Context) error {
return handlerErr
}
// updateClientsAuthModel updates the existing clients auth model with the new one (matched by ID).
func (api *realtimeApi) updateClientsAuthModel(contextKey string, newModel models.Model) error {
for _, client := range api.app.SubscriptionsBroker().Clients() {
clientModel, _ := client.Get(contextKey).(models.Model)
if clientModel != nil && clientModel.GetId() == newModel.GetId() {
client.Set(contextKey, newModel)
}
}
return nil
}
// unregisterClientsByAuthModel unregister all clients that has the provided auth model.
func (api *realtimeApi) unregisterClientsByAuthModel(contextKey string, model models.Model) error {
for _, client := range api.app.SubscriptionsBroker().Clients() {
clientModel, _ := client.Get(contextKey).(models.Model)
if clientModel != nil && clientModel.GetId() == model.GetId() {
api.app.SubscriptionsBroker().Unregister(client.Id())
}
}
return nil
}
func (api *realtimeApi) bindEvents() {
userTable := (&models.User{}).TableName()
adminTable := (&models.Admin{}).TableName()
// update user/admin auth state
// update the clients that has admin or auth record association
api.app.OnModelAfterUpdate().PreAdd(func(e *core.ModelEvent) error {
modelTable := e.Model.TableName()
var contextKey string
switch modelTable {
case userTable:
contextKey = ContextUserKey
case adminTable:
contextKey = ContextAdminKey
default:
return nil
if record, ok := e.Model.(*models.Record); ok && record != nil && record.Collection().IsAuth() {
return api.updateClientsAuthModel(ContextAuthRecordKey, record)
}
for _, client := range api.app.SubscriptionsBroker().Clients() {
model, _ := client.Get(contextKey).(models.Model)
if model != nil && model.GetId() == e.Model.GetId() {
client.Set(contextKey, e.Model)
}
if admin, ok := e.Model.(*models.Admin); ok && admin != nil {
return api.updateClientsAuthModel(ContextAdminKey, admin)
}
return nil
})
// remove user/admin client(s)
// remove the client(s) associated to the deleted admin or auth record
api.app.OnModelAfterDelete().PreAdd(func(e *core.ModelEvent) error {
modelTable := e.Model.TableName()
var contextKey string
switch modelTable {
case userTable:
contextKey = ContextUserKey
case adminTable:
contextKey = ContextAdminKey
default:
return nil
if record, ok := e.Model.(*models.Record); ok && record != nil && record.Collection().IsAuth() {
return api.unregisterClientsByAuthModel(ContextAuthRecordKey, record)
}
for _, client := range api.app.SubscriptionsBroker().Clients() {
model, _ := client.Get(contextKey).(models.Model)
if model != nil && model.GetId() == e.Model.GetId() {
api.app.SubscriptionsBroker().Unregister(client.Id())
}
if admin, ok := e.Model.(*models.Admin); ok && admin != nil {
return api.unregisterClientsByAuthModel(ContextAdminKey, admin)
}
return nil
@@ -254,17 +252,17 @@ func (api *realtimeApi) canAccessRecord(client subscriptions.Client, record *mod
// emulate request data
requestData := map[string]any{
"method": "get",
"method": "GET",
"query": map[string]any{},
"data": map[string]any{},
"user": nil,
"auth": nil,
}
user, _ := client.Get(ContextUserKey).(*models.User)
if user != nil {
requestData["user"], _ = user.AsMap()
authRecord, _ := client.Get(ContextAuthRecordKey).(*models.Record)
if authRecord != nil {
requestData["auth"] = authRecord.PublicExport()
}
resolver := resolvers.NewRecordFieldResolver(api.app.Dao(), record.Collection(), requestData)
resolver := resolvers.NewRecordFieldResolver(api.app.Dao(), record.Collection(), requestData, true)
expr, err := search.FilterData(*accessRule).BuildExpr(resolver)
if err != nil {
return err
@@ -275,7 +273,7 @@ func (api *realtimeApi) canAccessRecord(client subscriptions.Client, record *mod
return nil
}
foundRecord, err := api.app.Dao().FindRecordById(record.Collection(), record.Id, ruleFunc)
foundRecord, err := api.app.Dao().FindRecordById(record.Collection().Id, record.Id, ruleFunc)
if err == nil && foundRecord != nil {
return true
}
@@ -303,6 +301,8 @@ func (api *realtimeApi) broadcastRecord(action string, record *models.Record) er
// know if the clients have access to view the expanded records
cleanRecord := *record
cleanRecord.SetExpand(nil)
cleanRecord.WithUnkownData(false)
cleanRecord.IgnoreEmailVisibility(false)
subscriptionRuleMap := map[string]*string{
(collection.Name + "/" + cleanRecord.Id): collection.ViewRule,
@@ -316,7 +316,7 @@ func (api *realtimeApi) broadcastRecord(action string, record *models.Record) er
Record: &cleanRecord,
}
serializedData, err := json.Marshal(data)
dataBytes, err := json.Marshal(data)
if err != nil {
if api.app.IsDebug() {
log.Println(err)
@@ -324,6 +324,8 @@ func (api *realtimeApi) broadcastRecord(action string, record *models.Record) er
return err
}
encodedData := string(dataBytes)
for _, client := range clients {
for subscription, rule := range subscriptionRuleMap {
if !client.HasSubscription(subscription) {
@@ -336,7 +338,21 @@ func (api *realtimeApi) broadcastRecord(action string, record *models.Record) er
msg := subscriptions.Message{
Name: subscription,
Data: string(serializedData),
Data: encodedData,
}
// ignore the auth record email visibility checks for
// auth owner, admin or manager
if collection.IsAuth() {
authId := extractAuthIdFromGetter(client)
if authId == data.Record.Id ||
api.canAccessRecord(client, data.Record, collection.AuthOptions().ManageRule) {
data.Record.IgnoreEmailVisibility(true) // ignore
if newData, err := json.Marshal(data); err == nil {
msg.Data = string(newData)
}
data.Record.IgnoreEmailVisibility(false) // restore
}
}
client.Channel() <- msg
@@ -351,9 +367,9 @@ type getter interface {
}
func extractAuthIdFromGetter(val getter) string {
user, _ := val.Get(ContextUserKey).(*models.User)
if user != nil {
return user.Id
record, _ := val.Get(ContextAuthRecordKey).(*models.Record)
if record != nil {
return record.Id
}
admin, _ := val.Get(ContextAdminKey).(*models.Admin)
+29 -29
View File
@@ -46,7 +46,7 @@ func TestRealtimeSubscribe(t *testing.T) {
resetClient := func() {
client.Unsubscribe()
client.Set(apis.ContextAdminKey, nil)
client.Set(apis.ContextUserKey, nil)
client.Set(apis.ContextAuthRecordKey, nil)
}
scenarios := []tests.ApiScenario{
@@ -113,7 +113,7 @@ func TestRealtimeSubscribe(t *testing.T) {
Url: "/api/realtime",
Body: strings.NewReader(`{"clientId":"` + client.Id() + `","subscriptions":["test1", "test2"]}`),
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 204,
ExpectedEvents: map[string]int{
@@ -132,12 +132,12 @@ func TestRealtimeSubscribe(t *testing.T) {
},
},
{
Name: "existing client - authorized user",
Name: "existing client - authorized record",
Method: http.MethodPost,
Url: "/api/realtime",
Body: strings.NewReader(`{"clientId":"` + client.Id() + `","subscriptions":["test1", "test2"]}`),
RequestHeaders: map[string]string{
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
},
ExpectedStatus: 204,
ExpectedEvents: map[string]int{
@@ -148,9 +148,9 @@ func TestRealtimeSubscribe(t *testing.T) {
app.SubscriptionsBroker().Register(client)
},
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
user, _ := client.Get(apis.ContextUserKey).(*models.User)
if user == nil {
t.Errorf("Expected user auth model, got nil")
authRecord, _ := client.Get(apis.ContextAuthRecordKey).(*models.Record)
if authRecord == nil {
t.Errorf("Expected auth record model, got nil")
}
resetClient()
},
@@ -161,21 +161,21 @@ func TestRealtimeSubscribe(t *testing.T) {
Url: "/api/realtime",
Body: strings.NewReader(`{"clientId":"` + client.Id() + `","subscriptions":["test1", "test2"]}`),
RequestHeaders: map[string]string{
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
},
ExpectedStatus: 403,
ExpectedContent: []string{`"data":{}`},
BeforeTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
initialAuth := &models.User{}
initialAuth := &models.Record{}
initialAuth.RefreshId()
client.Set(apis.ContextUserKey, initialAuth)
client.Set(apis.ContextAuthRecordKey, initialAuth)
app.SubscriptionsBroker().Register(client)
},
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
user, _ := client.Get(apis.ContextUserKey).(*models.User)
if user == nil {
t.Errorf("Expected user auth model, got nil")
authRecord, _ := client.Get(apis.ContextAuthRecordKey).(*models.Record)
if authRecord == nil {
t.Errorf("Expected auth record model, got nil")
}
resetClient()
},
@@ -187,55 +187,55 @@ func TestRealtimeSubscribe(t *testing.T) {
}
}
func TestRealtimeUserDeleteEvent(t *testing.T) {
func TestRealtimeAuthRecordDeleteEvent(t *testing.T) {
testApp, _ := tests.NewTestApp()
defer testApp.Cleanup()
apis.InitApi(testApp)
user, err := testApp.Dao().FindUserByEmail("test@example.com")
authRecord, err := testApp.Dao().FindFirstRecordByData("users", "email", "test@example.com")
if err != nil {
t.Fatal(err)
}
client := subscriptions.NewDefaultClient()
client.Set(apis.ContextUserKey, user)
client.Set(apis.ContextAuthRecordKey, authRecord)
testApp.SubscriptionsBroker().Register(client)
testApp.OnModelAfterDelete().Trigger(&core.ModelEvent{Dao: testApp.Dao(), Model: user})
testApp.OnModelAfterDelete().Trigger(&core.ModelEvent{Dao: testApp.Dao(), Model: authRecord})
if len(testApp.SubscriptionsBroker().Clients()) != 0 {
t.Fatalf("Expected no subscription clients, found %d", len(testApp.SubscriptionsBroker().Clients()))
}
}
func TestRealtimeUserUpdateEvent(t *testing.T) {
func TestRealtimeAuthRecordUpdateEvent(t *testing.T) {
testApp, _ := tests.NewTestApp()
defer testApp.Cleanup()
apis.InitApi(testApp)
user1, err := testApp.Dao().FindUserByEmail("test@example.com")
authRecord1, err := testApp.Dao().FindFirstRecordByData("users", "email", "test@example.com")
if err != nil {
t.Fatal(err)
}
client := subscriptions.NewDefaultClient()
client.Set(apis.ContextUserKey, user1)
client.Set(apis.ContextAuthRecordKey, authRecord1)
testApp.SubscriptionsBroker().Register(client)
// refetch the user and change its email
user2, err := testApp.Dao().FindUserByEmail("test@example.com")
// refetch the authRecord and change its email
authRecord2, err := testApp.Dao().FindFirstRecordByData("users", "email", "test@example.com")
if err != nil {
t.Fatal(err)
}
user2.Email = "new@example.com"
authRecord2.SetEmail("new@example.com")
testApp.OnModelAfterUpdate().Trigger(&core.ModelEvent{Dao: testApp.Dao(), Model: user2})
testApp.OnModelAfterUpdate().Trigger(&core.ModelEvent{Dao: testApp.Dao(), Model: authRecord2})
clientUser, _ := client.Get(apis.ContextUserKey).(*models.User)
if clientUser.Email != user2.Email {
t.Fatalf("Expected user with email %q, got %q", user2.Email, clientUser.Email)
clientAuthRecord, _ := client.Get(apis.ContextAuthRecordKey).(*models.Record)
if clientAuthRecord.Email() != authRecord2.Email() {
t.Fatalf("Expected authRecord with email %q, got %q", authRecord2.Email(), clientAuthRecord.Email())
}
}
@@ -276,7 +276,7 @@ func TestRealtimeAdminUpdateEvent(t *testing.T) {
client.Set(apis.ContextAdminKey, admin1)
testApp.SubscriptionsBroker().Register(client)
// refetch the user and change its email
// refetch the authRecord and change its email
admin2, err := testApp.Dao().FindAdminByEmail("test@example.com")
if err != nil {
t.Fatal(err)
@@ -287,6 +287,6 @@ func TestRealtimeAdminUpdateEvent(t *testing.T) {
clientAdmin, _ := client.Get(apis.ContextAdminKey).(*models.Admin)
if clientAdmin.Email != admin2.Email {
t.Fatalf("Expected user with email %q, got %q", admin2.Email, clientAdmin.Email)
t.Fatalf("Expected authRecord with email %q, got %q", admin2.Email, clientAdmin.Email)
}
}
+477
View File
@@ -0,0 +1,477 @@
package apis
import (
"errors"
"fmt"
"log"
"net/http"
"strings"
"github.com/labstack/echo/v5"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/forms"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/resolvers"
"github.com/pocketbase/pocketbase/tokens"
"github.com/pocketbase/pocketbase/tools/auth"
"github.com/pocketbase/pocketbase/tools/routine"
"github.com/pocketbase/pocketbase/tools/search"
"github.com/pocketbase/pocketbase/tools/security"
"golang.org/x/oauth2"
)
// bindRecordAuthApi registers the auth record api endpoints and
// the corresponding handlers.
func bindRecordAuthApi(app core.App, rg *echo.Group) {
api := recordAuthApi{app: app}
subGroup := rg.Group(
"/collections/:collection",
ActivityLogger(app),
LoadCollectionContext(app, models.CollectionTypeAuth),
)
subGroup.GET("/auth-methods", api.authMethods)
subGroup.POST("/auth-refresh", api.authRefresh, RequireSameContextRecordAuth())
subGroup.POST("/auth-with-oauth2", api.authWithOAuth2) // allow anyone so that we can link the OAuth2 profile with the authenticated record
subGroup.POST("/auth-with-password", api.authWithPassword, RequireGuestOnly())
subGroup.POST("/request-password-reset", api.requestPasswordReset)
subGroup.POST("/confirm-password-reset", api.confirmPasswordReset)
subGroup.POST("/request-verification", api.requestVerification)
subGroup.POST("/confirm-verification", api.confirmVerification)
subGroup.POST("/request-email-change", api.requestEmailChange, RequireSameContextRecordAuth())
subGroup.POST("/confirm-email-change", api.confirmEmailChange)
subGroup.GET("/records/:id/external-auths", api.listExternalAuths, RequireAdminOrOwnerAuth("id"))
subGroup.DELETE("/records/:id/external-auths/:provider", api.unlinkExternalAuth, RequireAdminOrOwnerAuth("id"))
}
type recordAuthApi struct {
app core.App
}
func (api *recordAuthApi) authResponse(c echo.Context, authRecord *models.Record, meta any) error {
token, tokenErr := tokens.NewRecordAuthToken(api.app, authRecord)
if tokenErr != nil {
return NewBadRequestError("Failed to create auth token.", tokenErr)
}
event := &core.RecordAuthEvent{
HttpContext: c,
Record: authRecord,
Token: token,
Meta: meta,
}
return api.app.OnRecordAuthRequest().Trigger(event, func(e *core.RecordAuthEvent) error {
admin, _ := e.HttpContext.Get(ContextAdminKey).(*models.Admin)
// allow always returning the email address of the authenticated account
e.Record.IgnoreEmailVisibility(true)
// expand record relations
expands := strings.Split(c.QueryParam(expandQueryParam), ",")
if len(expands) > 0 {
requestData := exportRequestData(e.HttpContext)
requestData["auth"] = e.Record.PublicExport()
failed := api.app.Dao().ExpandRecord(
e.Record,
expands,
expandFetch(api.app.Dao(), admin != nil, requestData),
)
if len(failed) > 0 && api.app.IsDebug() {
log.Println("Failed to expand relations: ", failed)
}
}
result := map[string]any{
"token": e.Token,
"record": e.Record,
}
if e.Meta != nil {
result["meta"] = e.Meta
}
return e.HttpContext.JSON(http.StatusOK, result)
})
}
func (api *recordAuthApi) authRefresh(c echo.Context) error {
record, _ := c.Get(ContextAuthRecordKey).(*models.Record)
if record == nil {
return NewNotFoundError("Missing auth record context.", nil)
}
return api.authResponse(c, record, nil)
}
type providerInfo struct {
Name string `json:"name"`
State string `json:"state"`
CodeVerifier string `json:"codeVerifier"`
CodeChallenge string `json:"codeChallenge"`
CodeChallengeMethod string `json:"codeChallengeMethod"`
AuthUrl string `json:"authUrl"`
}
func (api *recordAuthApi) authMethods(c echo.Context) error {
collection, _ := c.Get(ContextCollectionKey).(*models.Collection)
if collection == nil {
return NewNotFoundError("Missing collection context.", nil)
}
authOptions := collection.AuthOptions()
result := struct {
UsernamePassword bool `json:"usernamePassword"`
EmailPassword bool `json:"emailPassword"`
AuthProviders []providerInfo `json:"authProviders"`
}{
UsernamePassword: authOptions.AllowUsernameAuth,
EmailPassword: authOptions.AllowEmailAuth,
AuthProviders: []providerInfo{},
}
if !authOptions.AllowOAuth2Auth {
return c.JSON(http.StatusOK, result)
}
nameConfigMap := api.app.Settings().NamedAuthProviderConfigs()
for name, config := range nameConfigMap {
if !config.Enabled {
continue
}
provider, err := auth.NewProviderByName(name)
if err != nil {
if api.app.IsDebug() {
log.Println(err)
}
continue // skip provider
}
if err := config.SetupProvider(provider); err != nil {
if api.app.IsDebug() {
log.Println(err)
}
continue // skip provider
}
state := security.RandomString(30)
codeVerifier := security.RandomString(43)
codeChallenge := security.S256Challenge(codeVerifier)
codeChallengeMethod := "S256"
result.AuthProviders = append(result.AuthProviders, providerInfo{
Name: name,
State: state,
CodeVerifier: codeVerifier,
CodeChallenge: codeChallenge,
CodeChallengeMethod: codeChallengeMethod,
AuthUrl: provider.BuildAuthUrl(
state,
oauth2.SetAuthURLParam("code_challenge", codeChallenge),
oauth2.SetAuthURLParam("code_challenge_method", codeChallengeMethod),
) + "&redirect_uri=", // empty redirect_uri so that users can append their url
})
}
return c.JSON(http.StatusOK, result)
}
func (api *recordAuthApi) authWithOAuth2(c echo.Context) error {
collection, _ := c.Get(ContextCollectionKey).(*models.Collection)
if collection == nil {
return NewNotFoundError("Missing collection context.", nil)
}
if !collection.AuthOptions().AllowOAuth2Auth {
return NewBadRequestError("The collection is not configured to allow OAuth2 authentication.", nil)
}
var fallbackAuthRecord *models.Record
loggedAuthRecord, _ := c.Get(ContextAuthRecordKey).(*models.Record)
if loggedAuthRecord != nil && loggedAuthRecord.Collection().Id == collection.Id {
fallbackAuthRecord = loggedAuthRecord
}
form := forms.NewRecordOAuth2Login(api.app, collection, fallbackAuthRecord)
if readErr := c.Bind(form); readErr != nil {
return NewBadRequestError("An error occurred while loading the submitted data.", readErr)
}
record, authData, submitErr := form.Submit(func(createForm *forms.RecordUpsert, authRecord *models.Record, authUser *auth.AuthUser) error {
return createForm.DrySubmit(func(txDao *daos.Dao) error {
requestData := exportRequestData(c)
requestData["data"] = form.CreateData
createRuleFunc := func(q *dbx.SelectQuery) error {
admin, _ := c.Get(ContextAdminKey).(*models.Admin)
if admin != nil {
return nil // either admin or the rule is empty
}
if collection.CreateRule == nil {
return errors.New("Only admins can create new accounts with OAuth2")
}
if *collection.CreateRule != "" {
resolver := resolvers.NewRecordFieldResolver(txDao, collection, requestData, true)
expr, err := search.FilterData(*collection.CreateRule).BuildExpr(resolver)
if err != nil {
return err
}
resolver.UpdateQuery(q)
q.AndWhere(expr)
}
return nil
}
if _, err := txDao.FindRecordById(collection.Id, createForm.Id, createRuleFunc); err != nil {
return fmt.Errorf("Failed create rule constraint: %v", err)
}
return nil
})
})
if submitErr != nil {
return NewBadRequestError("Failed to authenticate.", submitErr)
}
return api.authResponse(c, record, authData)
}
func (api *recordAuthApi) authWithPassword(c echo.Context) error {
collection, _ := c.Get(ContextCollectionKey).(*models.Collection)
if collection == nil {
return NewNotFoundError("Missing collection context.", nil)
}
form := forms.NewRecordPasswordLogin(api.app, collection)
if readErr := c.Bind(form); readErr != nil {
return NewBadRequestError("An error occurred while loading the submitted data.", readErr)
}
record, submitErr := form.Submit()
if submitErr != nil {
return NewBadRequestError("Failed to authenticate.", submitErr)
}
return api.authResponse(c, record, nil)
}
func (api *recordAuthApi) requestPasswordReset(c echo.Context) error {
collection, _ := c.Get(ContextCollectionKey).(*models.Collection)
if collection == nil {
return NewNotFoundError("Missing collection context.", nil)
}
authOptions := collection.AuthOptions()
if !authOptions.AllowUsernameAuth && !authOptions.AllowEmailAuth {
return NewBadRequestError("The collection is not configured to allow password authentication.", nil)
}
form := forms.NewRecordPasswordResetRequest(api.app, collection)
if err := c.Bind(form); err != nil {
return NewBadRequestError("An error occurred while loading the submitted data.", err)
}
if err := form.Validate(); err != nil {
return NewBadRequestError("An error occurred while validating the form.", err)
}
// run in background because we don't need to show
// the result to the user (prevents users enumeration)
routine.FireAndForget(func() {
if err := form.Submit(); err != nil && api.app.IsDebug() {
log.Println(err)
}
})
return c.NoContent(http.StatusNoContent)
}
func (api *recordAuthApi) confirmPasswordReset(c echo.Context) error {
collection, _ := c.Get(ContextCollectionKey).(*models.Collection)
if collection == nil {
return NewNotFoundError("Missing collection context.", nil)
}
form := forms.NewRecordPasswordResetConfirm(api.app, collection)
if readErr := c.Bind(form); readErr != nil {
return NewBadRequestError("An error occurred while loading the submitted data.", readErr)
}
record, submitErr := form.Submit()
if submitErr != nil {
return NewBadRequestError("Failed to set new password.", submitErr)
}
return api.authResponse(c, record, nil)
}
func (api *recordAuthApi) requestVerification(c echo.Context) error {
collection, _ := c.Get(ContextCollectionKey).(*models.Collection)
if collection == nil {
return NewNotFoundError("Missing collection context.", nil)
}
form := forms.NewRecordVerificationRequest(api.app, collection)
if err := c.Bind(form); err != nil {
return NewBadRequestError("An error occurred while loading the submitted data.", err)
}
if err := form.Validate(); err != nil {
return NewBadRequestError("An error occurred while validating the form.", err)
}
// run in background because we don't need to show
// the result to the user (prevents users enumeration)
routine.FireAndForget(func() {
if err := form.Submit(); err != nil && api.app.IsDebug() {
log.Println(err)
}
})
return c.NoContent(http.StatusNoContent)
}
func (api *recordAuthApi) confirmVerification(c echo.Context) error {
collection, _ := c.Get(ContextCollectionKey).(*models.Collection)
if collection == nil {
return NewNotFoundError("Missing collection context.", nil)
}
form := forms.NewRecordVerificationConfirm(api.app, collection)
if readErr := c.Bind(form); readErr != nil {
return NewBadRequestError("An error occurred while loading the submitted data.", readErr)
}
record, submitErr := form.Submit()
if submitErr != nil {
return NewBadRequestError("An error occurred while submitting the form.", submitErr)
}
// don't return an auth response if the collection doesn't allow email or username authentication
authOptions := collection.AuthOptions()
if !authOptions.AllowEmailAuth && !authOptions.AllowUsernameAuth {
return c.NoContent(http.StatusNoContent)
}
return api.authResponse(c, record, nil)
}
func (api *recordAuthApi) requestEmailChange(c echo.Context) error {
record, _ := c.Get(ContextAuthRecordKey).(*models.Record)
if record == nil {
return NewUnauthorizedError("The request requires valid auth record.", nil)
}
form := forms.NewRecordEmailChangeRequest(api.app, record)
if err := c.Bind(form); err != nil {
return NewBadRequestError("An error occurred while loading the submitted data.", err)
}
if err := form.Submit(); err != nil {
return NewBadRequestError("Failed to request email change.", err)
}
return c.NoContent(http.StatusNoContent)
}
func (api *recordAuthApi) confirmEmailChange(c echo.Context) error {
collection, _ := c.Get(ContextCollectionKey).(*models.Collection)
if collection == nil {
return NewNotFoundError("Missing collection context.", nil)
}
form := forms.NewRecordEmailChangeConfirm(api.app, collection)
if readErr := c.Bind(form); readErr != nil {
return NewBadRequestError("An error occurred while loading the submitted data.", readErr)
}
record, submitErr := form.Submit()
if submitErr != nil {
return NewBadRequestError("Failed to confirm email change.", submitErr)
}
return api.authResponse(c, record, nil)
}
func (api *recordAuthApi) listExternalAuths(c echo.Context) error {
collection, _ := c.Get(ContextCollectionKey).(*models.Collection)
if collection == nil {
return NewNotFoundError("Missing collection context.", nil)
}
id := c.PathParam("id")
if id == "" {
return NewNotFoundError("", nil)
}
record, err := api.app.Dao().FindRecordById(collection.Id, id)
if err != nil || record == nil {
return NewNotFoundError("", err)
}
externalAuths, err := api.app.Dao().FindAllExternalAuthsByRecord(record)
if err != nil {
return NewBadRequestError("Failed to fetch the external auths for the specified auth record.", err)
}
event := &core.RecordListExternalAuthsEvent{
HttpContext: c,
Record: record,
ExternalAuths: externalAuths,
}
return api.app.OnRecordListExternalAuths().Trigger(event, func(e *core.RecordListExternalAuthsEvent) error {
return e.HttpContext.JSON(http.StatusOK, e.ExternalAuths)
})
}
func (api *recordAuthApi) unlinkExternalAuth(c echo.Context) error {
collection, _ := c.Get(ContextCollectionKey).(*models.Collection)
if collection == nil {
return NewNotFoundError("Missing collection context.", nil)
}
id := c.PathParam("id")
provider := c.PathParam("provider")
if id == "" || provider == "" {
return NewNotFoundError("", nil)
}
record, err := api.app.Dao().FindRecordById(collection.Id, id)
if err != nil || record == nil {
return NewNotFoundError("", err)
}
externalAuth, err := api.app.Dao().FindExternalAuthByRecordAndProvider(record, provider)
if err != nil {
return NewNotFoundError("Missing external auth provider relation.", err)
}
event := &core.RecordUnlinkExternalAuthEvent{
HttpContext: c,
Record: record,
ExternalAuth: externalAuth,
}
handlerErr := api.app.OnRecordBeforeUnlinkExternalAuthRequest().Trigger(event, func(e *core.RecordUnlinkExternalAuthEvent) error {
if err := api.app.Dao().DeleteExternalAuth(externalAuth); err != nil {
return NewBadRequestError("Cannot unlink the external auth provider.", err)
}
return e.HttpContext.NoContent(http.StatusNoContent)
})
if handlerErr == nil {
api.app.OnRecordAfterUnlinkExternalAuthRequest().Trigger(event)
}
return handlerErr
}
File diff suppressed because it is too large Load Diff
+115 -111
View File
@@ -13,27 +13,27 @@ import (
"github.com/pocketbase/pocketbase/forms"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/resolvers"
"github.com/pocketbase/pocketbase/tools/rest"
"github.com/pocketbase/pocketbase/tools/search"
)
const expandQueryParam = "expand"
// BindRecordApi registers the record api endpoints and the corresponding handlers.
func BindRecordApi(app core.App, rg *echo.Group) {
// bindRecordCrudApi registers the record crud api endpoints and
// the corresponding handlers.
func bindRecordCrudApi(app core.App, rg *echo.Group) {
api := recordApi{app: app}
subGroup := rg.Group(
"/collections/:collection/records",
"/collections/:collection",
ActivityLogger(app),
LoadCollectionContext(app),
)
subGroup.GET("", api.list)
subGroup.POST("", api.create)
subGroup.GET("/:id", api.view)
subGroup.PATCH("/:id", api.update)
subGroup.DELETE("/:id", api.delete)
subGroup.GET("/records", api.list)
subGroup.POST("/records", api.create)
subGroup.GET("/records/:id", api.view)
subGroup.PATCH("/records/:id", api.update)
subGroup.DELETE("/records/:id", api.delete)
}
type recordApi struct {
@@ -43,13 +43,13 @@ type recordApi struct {
func (api *recordApi) list(c echo.Context) error {
collection, _ := c.Get(ContextCollectionKey).(*models.Collection)
if collection == nil {
return rest.NewNotFoundError("", "Missing collection context.")
return NewNotFoundError("", "Missing collection context.")
}
admin, _ := c.Get(ContextAdminKey).(*models.Admin)
if admin == nil && collection.ListRule == nil {
// only admins can access if the rule is nil
return rest.NewForbiddenError("Only admins can perform this action.", nil)
return NewForbiddenError("Only admins can perform this action.", nil)
}
// forbid users and guests to query special filter/sort fields
@@ -57,13 +57,18 @@ func (api *recordApi) list(c echo.Context) error {
return err
}
requestData := api.exportRequestData(c)
requestData := exportRequestData(c)
fieldsResolver := resolvers.NewRecordFieldResolver(api.app.Dao(), collection, requestData)
fieldsResolver := resolvers.NewRecordFieldResolver(
api.app.Dao(),
collection,
requestData,
// hidden fields are searchable only by admins
admin != nil,
)
searchProvider := search.NewProvider(fieldsResolver).
Query(api.app.Dao().RecordQuery(collection)).
CountColumn(fmt.Sprintf("%s.id", api.app.Dao().DB().QuoteSimpleColumnName(collection.Name)))
Query(api.app.Dao().RecordQuery(collection))
if admin == nil && collection.ListRule != nil {
searchProvider.AddFilter(search.FilterData(*collection.ListRule))
@@ -72,7 +77,7 @@ func (api *recordApi) list(c echo.Context) error {
var rawRecords = []dbx.NullStringMap{}
result, err := searchProvider.ParseAndExec(c.QueryString(), &rawRecords)
if err != nil {
return rest.NewBadRequestError("Invalid filter parameters.", err)
return NewBadRequestError("Invalid filter parameters.", err)
}
records := models.NewRecordsFromNullStringMaps(collection, rawRecords)
@@ -83,13 +88,22 @@ func (api *recordApi) list(c echo.Context) error {
failed := api.app.Dao().ExpandRecords(
records,
expands,
api.expandFunc(c, requestData),
expandFetch(api.app.Dao(), admin != nil, requestData),
)
if len(failed) > 0 && api.app.IsDebug() {
log.Println("Failed to expand relations: ", failed)
}
}
if collection.IsAuth() {
err := autoIgnoreAuthRecordsEmailVisibility(
api.app.Dao(), records, admin != nil, requestData,
)
if err != nil && api.app.IsDebug() {
log.Println("IgnoreEmailVisibility failure:", err)
}
}
result.Items = records
event := &core.RecordsListEvent{
@@ -107,25 +121,25 @@ func (api *recordApi) list(c echo.Context) error {
func (api *recordApi) view(c echo.Context) error {
collection, _ := c.Get(ContextCollectionKey).(*models.Collection)
if collection == nil {
return rest.NewNotFoundError("", "Missing collection context.")
return NewNotFoundError("", "Missing collection context.")
}
admin, _ := c.Get(ContextAdminKey).(*models.Admin)
if admin == nil && collection.ViewRule == nil {
// only admins can access if the rule is nil
return rest.NewForbiddenError("Only admins can perform this action.", nil)
return NewForbiddenError("Only admins can perform this action.", nil)
}
recordId := c.PathParam("id")
if recordId == "" {
return rest.NewNotFoundError("", nil)
return NewNotFoundError("", nil)
}
requestData := api.exportRequestData(c)
requestData := exportRequestData(c)
ruleFunc := func(q *dbx.SelectQuery) error {
if admin == nil && collection.ViewRule != nil && *collection.ViewRule != "" {
resolver := resolvers.NewRecordFieldResolver(api.app.Dao(), collection, requestData)
resolver := resolvers.NewRecordFieldResolver(api.app.Dao(), collection, requestData, true)
expr, err := search.FilterData(*collection.ViewRule).BuildExpr(resolver)
if err != nil {
return err
@@ -136,21 +150,30 @@ func (api *recordApi) view(c echo.Context) error {
return nil
}
record, fetchErr := api.app.Dao().FindRecordById(collection, recordId, ruleFunc)
record, fetchErr := api.app.Dao().FindRecordById(collection.Id, recordId, ruleFunc)
if fetchErr != nil || record == nil {
return rest.NewNotFoundError("", fetchErr)
return NewNotFoundError("", fetchErr)
}
// expand record relations
failed := api.app.Dao().ExpandRecord(
record,
strings.Split(c.QueryParam(expandQueryParam), ","),
api.expandFunc(c, requestData),
expandFetch(api.app.Dao(), admin != nil, requestData),
)
if len(failed) > 0 && api.app.IsDebug() {
log.Println("Failed to expand relations: ", failed)
}
if collection.IsAuth() {
err := autoIgnoreAuthRecordsEmailVisibility(
api.app.Dao(), []*models.Record{record}, admin != nil, requestData,
)
if err != nil && api.app.IsDebug() {
log.Println("IgnoreEmailVisibility failure:", err)
}
}
event := &core.RecordViewEvent{
HttpContext: c,
Record: record,
@@ -164,21 +187,27 @@ func (api *recordApi) view(c echo.Context) error {
func (api *recordApi) create(c echo.Context) error {
collection, _ := c.Get(ContextCollectionKey).(*models.Collection)
if collection == nil {
return rest.NewNotFoundError("", "Missing collection context.")
return NewNotFoundError("", "Missing collection context.")
}
admin, _ := c.Get(ContextAdminKey).(*models.Admin)
if admin == nil && collection.CreateRule == nil {
// only admins can access if the rule is nil
return rest.NewForbiddenError("Only admins can perform this action.", nil)
return NewForbiddenError("Only admins can perform this action.", nil)
}
requestData := api.exportRequestData(c)
requestData := exportRequestData(c)
hasFullManageAccess := admin != nil
// temporary save the record and check it against the create rule
if admin == nil && collection.CreateRule != nil && *collection.CreateRule != "" {
ruleFunc := func(q *dbx.SelectQuery) error {
resolver := resolvers.NewRecordFieldResolver(api.app.Dao(), collection, requestData)
if admin == nil && collection.CreateRule != nil {
createRuleFunc := func(q *dbx.SelectQuery) error {
if *collection.CreateRule == "" {
return nil // no create rule to resolve
}
resolver := resolvers.NewRecordFieldResolver(api.app.Dao(), collection, requestData, true)
expr, err := search.FilterData(*collection.CreateRule).BuildExpr(resolver)
if err != nil {
return err
@@ -190,25 +219,32 @@ func (api *recordApi) create(c echo.Context) error {
testRecord := models.NewRecord(collection)
testForm := forms.NewRecordUpsert(api.app, testRecord)
if err := testForm.LoadData(c.Request()); err != nil {
return rest.NewBadRequestError("Failed to load the submitted data due to invalid formatting.", err)
testForm.SetFullManageAccess(true)
if err := testForm.LoadRequest(c.Request(), ""); err != nil {
return NewBadRequestError("Failed to load the submitted data due to invalid formatting.", err)
}
testErr := testForm.DrySubmit(func(txDao *daos.Dao) error {
_, fetchErr := txDao.FindRecordById(collection, testRecord.Id, ruleFunc)
return fetchErr
foundRecord, err := txDao.FindRecordById(collection.Id, testRecord.Id, createRuleFunc)
if err != nil {
return err
}
hasFullManageAccess = hasAuthManageAccess(txDao, foundRecord, requestData)
return nil
})
if testErr != nil {
return rest.NewBadRequestError("Failed to create record.", testErr)
return NewBadRequestError("Failed to create record.", fmt.Errorf("DrySubmit error: %v", testErr))
}
}
record := models.NewRecord(collection)
form := forms.NewRecordUpsert(api.app, record)
form.SetFullManageAccess(hasFullManageAccess)
// load request
if err := form.LoadData(c.Request()); err != nil {
return rest.NewBadRequestError("Failed to load the submitted data due to invalid formatting.", err)
if err := form.LoadRequest(c.Request(), ""); err != nil {
return NewBadRequestError("Failed to load the submitted data due to invalid formatting.", err)
}
event := &core.RecordCreateEvent{
@@ -221,19 +257,28 @@ func (api *recordApi) create(c echo.Context) error {
return func() error {
return api.app.OnRecordBeforeCreateRequest().Trigger(event, func(e *core.RecordCreateEvent) error {
if err := next(); err != nil {
return rest.NewBadRequestError("Failed to create record.", err)
return NewBadRequestError("Failed to create record.", err)
}
// expand record relations
failed := api.app.Dao().ExpandRecord(
e.Record,
strings.Split(e.HttpContext.QueryParam(expandQueryParam), ","),
api.expandFunc(e.HttpContext, requestData),
expandFetch(api.app.Dao(), admin != nil, requestData),
)
if len(failed) > 0 && api.app.IsDebug() {
log.Println("Failed to expand relations: ", failed)
}
if collection.IsAuth() {
err := autoIgnoreAuthRecordsEmailVisibility(
api.app.Dao(), []*models.Record{e.Record}, admin != nil, requestData,
)
if err != nil && api.app.IsDebug() {
log.Println("IgnoreEmailVisibility failure:", err)
}
}
return e.HttpContext.JSON(http.StatusOK, e.Record)
})
}
@@ -249,25 +294,25 @@ func (api *recordApi) create(c echo.Context) error {
func (api *recordApi) update(c echo.Context) error {
collection, _ := c.Get(ContextCollectionKey).(*models.Collection)
if collection == nil {
return rest.NewNotFoundError("", "Missing collection context.")
return NewNotFoundError("", "Missing collection context.")
}
admin, _ := c.Get(ContextAdminKey).(*models.Admin)
if admin == nil && collection.UpdateRule == nil {
// only admins can access if the rule is nil
return rest.NewForbiddenError("Only admins can perform this action.", nil)
return NewForbiddenError("Only admins can perform this action.", nil)
}
recordId := c.PathParam("id")
if recordId == "" {
return rest.NewNotFoundError("", nil)
return NewNotFoundError("", nil)
}
requestData := api.exportRequestData(c)
requestData := exportRequestData(c)
ruleFunc := func(q *dbx.SelectQuery) error {
if admin == nil && collection.UpdateRule != nil && *collection.UpdateRule != "" {
resolver := resolvers.NewRecordFieldResolver(api.app.Dao(), collection, requestData)
resolver := resolvers.NewRecordFieldResolver(api.app.Dao(), collection, requestData, true)
expr, err := search.FilterData(*collection.UpdateRule).BuildExpr(resolver)
if err != nil {
return err
@@ -279,16 +324,17 @@ func (api *recordApi) update(c echo.Context) error {
}
// fetch record
record, fetchErr := api.app.Dao().FindRecordById(collection, recordId, ruleFunc)
record, fetchErr := api.app.Dao().FindRecordById(collection.Id, recordId, ruleFunc)
if fetchErr != nil || record == nil {
return rest.NewNotFoundError("", fetchErr)
return NewNotFoundError("", fetchErr)
}
form := forms.NewRecordUpsert(api.app, record)
form.SetFullManageAccess(admin != nil || hasAuthManageAccess(api.app.Dao(), record, requestData))
// load request
if err := form.LoadData(c.Request()); err != nil {
return rest.NewBadRequestError("Failed to load the submitted data due to invalid formatting.", err)
if err := form.LoadRequest(c.Request(), ""); err != nil {
return NewBadRequestError("Failed to load the submitted data due to invalid formatting.", err)
}
event := &core.RecordUpdateEvent{
@@ -301,19 +347,28 @@ func (api *recordApi) update(c echo.Context) error {
return func() error {
return api.app.OnRecordBeforeUpdateRequest().Trigger(event, func(e *core.RecordUpdateEvent) error {
if err := next(); err != nil {
return rest.NewBadRequestError("Failed to update record.", err)
return NewBadRequestError("Failed to update record.", err)
}
// expand record relations
failed := api.app.Dao().ExpandRecord(
e.Record,
strings.Split(e.HttpContext.QueryParam(expandQueryParam), ","),
api.expandFunc(e.HttpContext, requestData),
expandFetch(api.app.Dao(), admin != nil, requestData),
)
if len(failed) > 0 && api.app.IsDebug() {
log.Println("Failed to expand relations: ", failed)
}
if collection.IsAuth() {
err := autoIgnoreAuthRecordsEmailVisibility(
api.app.Dao(), []*models.Record{e.Record}, admin != nil, requestData,
)
if err != nil && api.app.IsDebug() {
log.Println("IgnoreEmailVisibility failure:", err)
}
}
return e.HttpContext.JSON(http.StatusOK, e.Record)
})
}
@@ -329,25 +384,25 @@ func (api *recordApi) update(c echo.Context) error {
func (api *recordApi) delete(c echo.Context) error {
collection, _ := c.Get(ContextCollectionKey).(*models.Collection)
if collection == nil {
return rest.NewNotFoundError("", "Missing collection context.")
return NewNotFoundError("", "Missing collection context.")
}
admin, _ := c.Get(ContextAdminKey).(*models.Admin)
if admin == nil && collection.DeleteRule == nil {
// only admins can access if the rule is nil
return rest.NewForbiddenError("Only admins can perform this action.", nil)
return NewForbiddenError("Only admins can perform this action.", nil)
}
recordId := c.PathParam("id")
if recordId == "" {
return rest.NewNotFoundError("", nil)
return NewNotFoundError("", nil)
}
requestData := api.exportRequestData(c)
requestData := exportRequestData(c)
ruleFunc := func(q *dbx.SelectQuery) error {
if admin == nil && collection.DeleteRule != nil && *collection.DeleteRule != "" {
resolver := resolvers.NewRecordFieldResolver(api.app.Dao(), collection, requestData)
resolver := resolvers.NewRecordFieldResolver(api.app.Dao(), collection, requestData, true)
expr, err := search.FilterData(*collection.DeleteRule).BuildExpr(resolver)
if err != nil {
return err
@@ -358,9 +413,9 @@ func (api *recordApi) delete(c echo.Context) error {
return nil
}
record, fetchErr := api.app.Dao().FindRecordById(collection, recordId, ruleFunc)
record, fetchErr := api.app.Dao().FindRecordById(collection.Id, recordId, ruleFunc)
if fetchErr != nil || record == nil {
return rest.NewNotFoundError("", fetchErr)
return NewNotFoundError("", fetchErr)
}
event := &core.RecordDeleteEvent{
@@ -371,7 +426,7 @@ func (api *recordApi) delete(c echo.Context) error {
handlerErr := api.app.OnRecordBeforeDeleteRequest().Trigger(event, func(e *core.RecordDeleteEvent) error {
// delete the record
if err := api.app.Dao().DeleteRecord(e.Record); err != nil {
return rest.NewBadRequestError("Failed to delete record. Make sure that the record is not part of a required relation reference.", err)
return NewBadRequestError("Failed to delete record. Make sure that the record is not part of a required relation reference.", err)
}
return e.HttpContext.NoContent(http.StatusNoContent)
@@ -384,29 +439,6 @@ func (api *recordApi) delete(c echo.Context) error {
return handlerErr
}
func (api *recordApi) exportRequestData(c echo.Context) map[string]any {
result := map[string]any{}
queryParams := map[string]any{}
bodyData := map[string]any{}
method := c.Request().Method
echo.BindQueryParams(c, &queryParams)
rest.BindBody(c, &bodyData)
result["method"] = method
result["query"] = queryParams
result["data"] = bodyData
result["user"] = nil
loggedUser, _ := c.Get(ContextUserKey).(*models.User)
if loggedUser != nil {
result["user"], _ = loggedUser.AsMap()
}
return result
}
func (api *recordApi) checkForForbiddenQueryFields(c echo.Context) error {
admin, _ := c.Get(ContextAdminKey).(*models.Admin)
if admin != nil {
@@ -418,37 +450,9 @@ func (api *recordApi) checkForForbiddenQueryFields(c echo.Context) error {
for _, field := range forbiddenFields {
if strings.Contains(decodedQuery, field) {
return rest.NewForbiddenError("Only admins can filter by @collection and @request query params", nil)
return NewForbiddenError("Only admins can filter by @collection and @request query params", nil)
}
}
return nil
}
func (api *recordApi) expandFunc(c echo.Context, requestData map[string]any) daos.ExpandFetchFunc {
admin, _ := c.Get(ContextAdminKey).(*models.Admin)
return func(relCollection *models.Collection, relIds []string) ([]*models.Record, error) {
return api.app.Dao().FindRecordsByIds(relCollection, relIds, func(q *dbx.SelectQuery) error {
if admin != nil {
return nil // admin can access everything
}
if relCollection.ViewRule == nil {
return fmt.Errorf("Only admins can view collection %q records", relCollection.Name)
}
if *relCollection.ViewRule != "" {
resolver := resolvers.NewRecordFieldResolver(api.app.Dao(), relCollection, requestData)
expr, err := search.FilterData(*(relCollection.ViewRule)).BuildExpr(resolver)
if err != nil {
return err
}
resolver.UpdateQuery(q)
q.AndWhere(expr)
}
return nil
})
}
}
File diff suppressed because it is too large Load Diff
+186
View File
@@ -0,0 +1,186 @@
package apis
import (
"fmt"
"github.com/labstack/echo/v5"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/resolvers"
"github.com/pocketbase/pocketbase/tools/rest"
"github.com/pocketbase/pocketbase/tools/search"
"github.com/spf13/cast"
)
// exportRequestData exports a map with common request fields.
//
// @todo consider changing the map to a typed struct after v0.8 and the
// IN operator support.
func exportRequestData(c echo.Context) map[string]any {
result := map[string]any{}
queryParams := map[string]any{}
bodyData := map[string]any{}
method := c.Request().Method
echo.BindQueryParams(c, &queryParams)
rest.BindBody(c, &bodyData)
result["method"] = method
result["query"] = queryParams
result["data"] = bodyData
result["auth"] = nil
auth, _ := c.Get(ContextAuthRecordKey).(*models.Record)
if auth != nil {
result["auth"] = auth.PublicExport()
}
return result
}
// expandFetch is the records fetch function that is used to expand related records.
func expandFetch(
dao *daos.Dao,
isAdmin bool,
requestData map[string]any,
) daos.ExpandFetchFunc {
return func(relCollection *models.Collection, relIds []string) ([]*models.Record, error) {
records, err := dao.FindRecordsByIds(relCollection.Id, relIds, func(q *dbx.SelectQuery) error {
if isAdmin {
return nil // admins can access everything
}
if relCollection.ViewRule == nil {
return fmt.Errorf("Only admins can view collection %q records", relCollection.Name)
}
if *relCollection.ViewRule != "" {
resolver := resolvers.NewRecordFieldResolver(dao, relCollection, requestData, true)
expr, err := search.FilterData(*(relCollection.ViewRule)).BuildExpr(resolver)
if err != nil {
return err
}
resolver.UpdateQuery(q)
q.AndWhere(expr)
}
return nil
})
if err == nil && len(records) > 0 {
autoIgnoreAuthRecordsEmailVisibility(dao, records, isAdmin, requestData)
}
return records, err
}
}
// autoIgnoreAuthRecordsEmailVisibility ignores the email visibility check for
// the provided record if the current auth model is admin, owner or a "manager".
//
// Note: Expects all records to be from the same auth collection!
func autoIgnoreAuthRecordsEmailVisibility(
dao *daos.Dao,
records []*models.Record,
isAdmin bool,
requestData map[string]any,
) error {
if len(records) == 0 || !records[0].Collection().IsAuth() {
return nil // nothing to check
}
if isAdmin {
for _, rec := range records {
rec.IgnoreEmailVisibility(true)
}
return nil
}
collection := records[0].Collection()
mappedRecords := make(map[string]*models.Record, len(records))
recordIds := make([]any, 0, len(records))
for _, rec := range records {
mappedRecords[rec.Id] = rec
recordIds = append(recordIds, rec.Id)
}
if auth, ok := requestData["auth"].(map[string]any); ok && mappedRecords[cast.ToString(auth["id"])] != nil {
mappedRecords[cast.ToString(auth["id"])].IgnoreEmailVisibility(true)
}
authOptions := collection.AuthOptions()
if authOptions.ManageRule == nil || *authOptions.ManageRule == "" {
return nil // no manage rule to check
}
// fetch the ids of the managed records
// ---
managedIds := []string{}
query := dao.RecordQuery(collection).
Select(dao.DB().QuoteSimpleColumnName(collection.Name) + ".id").
AndWhere(dbx.In(dao.DB().QuoteSimpleColumnName(collection.Name)+".id", recordIds...))
resolver := resolvers.NewRecordFieldResolver(dao, collection, requestData, true)
expr, err := search.FilterData(*authOptions.ManageRule).BuildExpr(resolver)
if err != nil {
return err
}
resolver.UpdateQuery(query)
query.AndWhere(expr)
if err := query.Column(&managedIds); err != nil {
return err
}
// ---
// ignore the email visibility check for the managed records
for _, id := range managedIds {
if rec, ok := mappedRecords[id]; ok {
rec.IgnoreEmailVisibility(true)
}
}
return nil
}
// hasAuthManageAccess checks whether the client is allowed to have full
// [forms.RecordUpsert] auth management permissions
// (aka. allowing to change system auth fields without oldPassword).
func hasAuthManageAccess(
dao *daos.Dao,
record *models.Record,
requestData map[string]any,
) bool {
if !record.Collection().IsAuth() {
return false
}
manageRule := record.Collection().AuthOptions().ManageRule
if manageRule == nil || *manageRule == "" {
return false // only for admins (manageRule can't be empty)
}
if auth, ok := requestData["auth"].(map[string]any); !ok || cast.ToString(auth["id"]) == "" {
return false // no auth record
}
ruleFunc := func(q *dbx.SelectQuery) error {
resolver := resolvers.NewRecordFieldResolver(dao, record.Collection(), requestData, true)
expr, err := search.FilterData(*manageRule).BuildExpr(resolver)
if err != nil {
return err
}
resolver.UpdateQuery(q)
q.AndWhere(expr)
return nil
}
_, findErr := dao.FindRecordById(record.Collection().Id, record.Id, ruleFunc)
return findErr == nil
}
-1052
View File
File diff suppressed because it is too large Load Diff
+13 -14
View File
@@ -7,12 +7,11 @@ import (
"github.com/labstack/echo/v5"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/forms"
"github.com/pocketbase/pocketbase/tools/rest"
"github.com/pocketbase/pocketbase/tools/security"
)
// BindSettingsApi registers the settings api endpoints.
func BindSettingsApi(app core.App, rg *echo.Group) {
// bindSettingsApi registers the settings api endpoints.
func bindSettingsApi(app core.App, rg *echo.Group) {
api := settingsApi{app: app}
subGroup := rg.Group("/settings", ActivityLogger(app), RequireAdminAuth())
@@ -29,7 +28,7 @@ type settingsApi struct {
func (api *settingsApi) list(c echo.Context) error {
settings, err := api.app.Settings().RedactClone()
if err != nil {
return rest.NewBadRequestError("", err)
return NewBadRequestError("", err)
}
event := &core.SettingsListEvent{
@@ -47,7 +46,7 @@ func (api *settingsApi) set(c echo.Context) error {
// load request
if err := c.Bind(form); err != nil {
return rest.NewBadRequestError("An error occurred while loading the submitted data.", err)
return NewBadRequestError("An error occurred while loading the submitted data.", err)
}
event := &core.SettingsUpdateEvent{
@@ -61,12 +60,12 @@ func (api *settingsApi) set(c echo.Context) error {
return func() error {
return api.app.OnSettingsBeforeUpdateRequest().Trigger(event, func(e *core.SettingsUpdateEvent) error {
if err := next(); err != nil {
return rest.NewBadRequestError("An error occurred while submitting the form.", err)
return NewBadRequestError("An error occurred while submitting the form.", err)
}
redactedSettings, err := api.app.Settings().RedactClone()
if err != nil {
return rest.NewBadRequestError("", err)
return NewBadRequestError("", err)
}
return e.HttpContext.JSON(http.StatusOK, redactedSettings)
@@ -83,23 +82,23 @@ func (api *settingsApi) set(c echo.Context) error {
func (api *settingsApi) testS3(c echo.Context) error {
if !api.app.Settings().S3.Enabled {
return rest.NewBadRequestError("S3 storage is not enabled.", nil)
return NewBadRequestError("S3 storage is not enabled.", nil)
}
fs, err := api.app.NewFilesystem()
if err != nil {
return rest.NewBadRequestError("Failed to initialize the S3 storage. Raw error: \n"+err.Error(), nil)
return NewBadRequestError("Failed to initialize the S3 storage. Raw error: \n"+err.Error(), nil)
}
defer fs.Close()
testFileKey := "pb_test_" + security.RandomString(5) + "/test.txt"
if err := fs.Upload([]byte("test"), testFileKey); err != nil {
return rest.NewBadRequestError("Failed to upload a test file. Raw error: \n"+err.Error(), nil)
return NewBadRequestError("Failed to upload a test file. Raw error: \n"+err.Error(), nil)
}
if err := fs.Delete(testFileKey); err != nil {
return rest.NewBadRequestError("Failed to delete a test file. Raw error: \n"+err.Error(), nil)
return NewBadRequestError("Failed to delete a test file. Raw error: \n"+err.Error(), nil)
}
return c.NoContent(http.StatusNoContent)
@@ -110,18 +109,18 @@ func (api *settingsApi) testEmail(c echo.Context) error {
// load request
if err := c.Bind(form); err != nil {
return rest.NewBadRequestError("An error occurred while loading the submitted data.", err)
return NewBadRequestError("An error occurred while loading the submitted data.", err)
}
// send
if err := form.Submit(); err != nil {
if fErr, ok := err.(validation.Errors); ok {
// form error
return rest.NewBadRequestError("Failed to send the test email.", fErr)
return NewBadRequestError("Failed to send the test email.", fErr)
}
// mailer error
return rest.NewBadRequestError("Failed to send the test email. Raw error: \n"+err.Error(), nil)
return NewBadRequestError("Failed to send the test email. Raw error: \n"+err.Error(), nil)
}
return c.NoContent(http.StatusNoContent)
+41 -43
View File
@@ -19,11 +19,11 @@ func TestSettingsList(t *testing.T) {
ExpectedContent: []string{`"data":{}`},
},
{
Name: "authorized as user",
Name: "authorized as auth record",
Method: http.MethodGet,
Url: "/api/settings",
RequestHeaders: map[string]string{
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
},
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
@@ -33,7 +33,7 @@ func TestSettingsList(t *testing.T) {
Method: http.MethodGet,
Url: "/api/settings",
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 200,
ExpectedContent: []string{
@@ -43,15 +43,16 @@ func TestSettingsList(t *testing.T) {
`"s3":{`,
`"adminAuthToken":{`,
`"adminPasswordResetToken":{`,
`"userAuthToken":{`,
`"userPasswordResetToken":{`,
`"userEmailChangeToken":{`,
`"userVerificationToken":{`,
`"recordAuthToken":{`,
`"recordPasswordResetToken":{`,
`"recordEmailChangeToken":{`,
`"recordVerificationToken":{`,
`"emailAuth":{`,
`"googleAuth":{`,
`"facebookAuth":{`,
`"githubAuth":{`,
`"gitlabAuth":{`,
`"twitterAuth":{`,
`"discordAuth":{`,
`"secret":"******"`,
`"clientSecret":"******"`,
@@ -68,7 +69,7 @@ func TestSettingsList(t *testing.T) {
}
func TestSettingsSet(t *testing.T) {
validData := `{"meta":{"appName":"update_test"},"emailAuth":{"minPasswordLength": 12}}`
validData := `{"meta":{"appName":"update_test"}}`
scenarios := []tests.ApiScenario{
{
@@ -80,12 +81,12 @@ func TestSettingsSet(t *testing.T) {
ExpectedContent: []string{`"data":{}`},
},
{
Name: "authorized as user",
Name: "authorized as auth record",
Method: http.MethodPatch,
Url: "/api/settings",
Body: strings.NewReader(validData),
RequestHeaders: map[string]string{
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
},
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
@@ -96,7 +97,7 @@ func TestSettingsSet(t *testing.T) {
Url: "/api/settings",
Body: strings.NewReader(``),
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 200,
ExpectedContent: []string{
@@ -106,10 +107,10 @@ func TestSettingsSet(t *testing.T) {
`"s3":{`,
`"adminAuthToken":{`,
`"adminPasswordResetToken":{`,
`"userAuthToken":{`,
`"userPasswordResetToken":{`,
`"userEmailChangeToken":{`,
`"userVerificationToken":{`,
`"recordAuthToken":{`,
`"recordPasswordResetToken":{`,
`"recordEmailChangeToken":{`,
`"recordVerificationToken":{`,
`"emailAuth":{`,
`"googleAuth":{`,
`"facebookAuth":{`,
@@ -119,7 +120,6 @@ func TestSettingsSet(t *testing.T) {
`"secret":"******"`,
`"clientSecret":"******"`,
`"appName":"Acme"`,
`"minPasswordLength":8`,
},
ExpectedEvents: map[string]int{
"OnModelBeforeUpdate": 1,
@@ -132,15 +132,14 @@ func TestSettingsSet(t *testing.T) {
Name: "authorized as admin submitting invalid data",
Method: http.MethodPatch,
Url: "/api/settings",
Body: strings.NewReader(`{"meta":{"appName":""},"emailAuth":{"minPasswordLength": 3}}`),
Body: strings.NewReader(`{"meta":{"appName":""}}`),
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 400,
ExpectedContent: []string{
`"data":{`,
`"emailAuth":{"minPasswordLength":{"code":"validation_min_greater_equal_than_required","message":"Must be no less than 5."}}`,
`"meta":{"appName":{"code":"validation_required","message":"Cannot be blank."}}`,
`"meta":{"appName":{"code":"validation_required"`,
},
},
{
@@ -149,7 +148,7 @@ func TestSettingsSet(t *testing.T) {
Url: "/api/settings",
Body: strings.NewReader(validData),
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 200,
ExpectedContent: []string{
@@ -159,20 +158,20 @@ func TestSettingsSet(t *testing.T) {
`"s3":{`,
`"adminAuthToken":{`,
`"adminPasswordResetToken":{`,
`"userAuthToken":{`,
`"userPasswordResetToken":{`,
`"userEmailChangeToken":{`,
`"userVerificationToken":{`,
`"recordAuthToken":{`,
`"recordPasswordResetToken":{`,
`"recordEmailChangeToken":{`,
`"recordVerificationToken":{`,
`"emailAuth":{`,
`"googleAuth":{`,
`"facebookAuth":{`,
`"githubAuth":{`,
`"gitlabAuth":{`,
`"twitterAuth":{`,
`"discordAuth":{`,
`"secret":"******"`,
`"clientSecret":"******"`,
`"appName":"update_test"`,
`"minPasswordLength":12`,
},
ExpectedEvents: map[string]int{
"OnModelBeforeUpdate": 1,
@@ -198,11 +197,11 @@ func TestSettingsTestS3(t *testing.T) {
ExpectedContent: []string{`"data":{}`},
},
{
Name: "authorized as user",
Name: "authorized as auth record",
Method: http.MethodPost,
Url: "/api/settings/test/s3",
RequestHeaders: map[string]string{
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
},
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
@@ -212,12 +211,11 @@ func TestSettingsTestS3(t *testing.T) {
Method: http.MethodPost,
Url: "/api/settings/test/s3",
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
},
// @todo consider creating a test S3 filesystem
}
for _, scenario := range scenarios {
@@ -239,7 +237,7 @@ func TestSettingsTestEmail(t *testing.T) {
ExpectedContent: []string{`"data":{}`},
},
{
Name: "authorized as user",
Name: "authorized as auth record",
Method: http.MethodPost,
Url: "/api/settings/test/email",
Body: strings.NewReader(`{
@@ -247,7 +245,7 @@ func TestSettingsTestEmail(t *testing.T) {
"email": "test@example.com"
}`),
RequestHeaders: map[string]string{
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
},
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
@@ -258,7 +256,7 @@ func TestSettingsTestEmail(t *testing.T) {
Url: "/api/settings/test/email",
Body: strings.NewReader(`{`),
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 400,
ExpectedContent: []string{`"data":{}`},
@@ -269,7 +267,7 @@ func TestSettingsTestEmail(t *testing.T) {
Url: "/api/settings/test/email",
Body: strings.NewReader(`{}`),
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
ExpectedStatus: 400,
ExpectedContent: []string{
@@ -286,7 +284,7 @@ func TestSettingsTestEmail(t *testing.T) {
"email": "test@example.com"
}`),
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
if app.TestMailer.TotalSend != 1 {
@@ -304,8 +302,8 @@ func TestSettingsTestEmail(t *testing.T) {
ExpectedStatus: 204,
ExpectedContent: []string{},
ExpectedEvents: map[string]int{
"OnMailerBeforeUserVerificationSend": 1,
"OnMailerAfterUserVerificationSend": 1,
"OnMailerBeforeRecordVerificationSend": 1,
"OnMailerAfterRecordVerificationSend": 1,
},
},
{
@@ -317,7 +315,7 @@ func TestSettingsTestEmail(t *testing.T) {
"email": "test@example.com"
}`),
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
if app.TestMailer.TotalSend != 1 {
@@ -335,8 +333,8 @@ func TestSettingsTestEmail(t *testing.T) {
ExpectedStatus: 204,
ExpectedContent: []string{},
ExpectedEvents: map[string]int{
"OnMailerBeforeUserResetPasswordSend": 1,
"OnMailerAfterUserResetPasswordSend": 1,
"OnMailerBeforeRecordResetPasswordSend": 1,
"OnMailerAfterRecordResetPasswordSend": 1,
},
},
{
@@ -348,7 +346,7 @@ func TestSettingsTestEmail(t *testing.T) {
"email": "test@example.com"
}`),
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
"Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
},
AfterTestFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
if app.TestMailer.TotalSend != 1 {
@@ -366,8 +364,8 @@ func TestSettingsTestEmail(t *testing.T) {
ExpectedStatus: 204,
ExpectedContent: []string{},
ExpectedEvents: map[string]int{
"OnMailerBeforeUserChangeEmailSend": 1,
"OnMailerAfterUserChangeEmailSend": 1,
"OnMailerBeforeRecordChangeEmailSend": 1,
"OnMailerAfterRecordChangeEmailSend": 1,
},
},
}
-519
View File
@@ -1,519 +0,0 @@
package apis
import (
"log"
"net/http"
"github.com/labstack/echo/v5"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/forms"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/tokens"
"github.com/pocketbase/pocketbase/tools/auth"
"github.com/pocketbase/pocketbase/tools/rest"
"github.com/pocketbase/pocketbase/tools/routine"
"github.com/pocketbase/pocketbase/tools/search"
"github.com/pocketbase/pocketbase/tools/security"
"golang.org/x/oauth2"
)
// BindUserApi registers the user api endpoints and the corresponding handlers.
func BindUserApi(app core.App, rg *echo.Group) {
api := userApi{app: app}
subGroup := rg.Group("/users", ActivityLogger(app))
subGroup.GET("/auth-methods", api.authMethods)
subGroup.POST("/auth-via-oauth2", api.oauth2Auth, RequireGuestOnly())
subGroup.POST("/auth-via-email", api.emailAuth, RequireGuestOnly())
subGroup.POST("/request-password-reset", api.requestPasswordReset)
subGroup.POST("/confirm-password-reset", api.confirmPasswordReset)
subGroup.POST("/request-verification", api.requestVerification)
subGroup.POST("/confirm-verification", api.confirmVerification)
subGroup.POST("/request-email-change", api.requestEmailChange, RequireUserAuth())
subGroup.POST("/confirm-email-change", api.confirmEmailChange)
subGroup.POST("/refresh", api.refresh, RequireUserAuth())
// crud
subGroup.GET("", api.list, RequireAdminAuth())
subGroup.POST("", api.create)
subGroup.GET("/:id", api.view, RequireAdminOrOwnerAuth("id"))
subGroup.PATCH("/:id", api.update, RequireAdminAuth())
subGroup.DELETE("/:id", api.delete, RequireAdminOrOwnerAuth("id"))
subGroup.GET("/:id/external-auths", api.listExternalAuths, RequireAdminOrOwnerAuth("id"))
subGroup.DELETE("/:id/external-auths/:provider", api.unlinkExternalAuth, RequireAdminOrOwnerAuth("id"))
}
type userApi struct {
app core.App
}
func (api *userApi) authResponse(c echo.Context, user *models.User, meta any) error {
token, tokenErr := tokens.NewUserAuthToken(api.app, user)
if tokenErr != nil {
return rest.NewBadRequestError("Failed to create auth token.", tokenErr)
}
event := &core.UserAuthEvent{
HttpContext: c,
User: user,
Token: token,
Meta: meta,
}
return api.app.OnUserAuthRequest().Trigger(event, func(e *core.UserAuthEvent) error {
result := map[string]any{
"token": e.Token,
"user": e.User,
}
if e.Meta != nil {
result["meta"] = e.Meta
}
return e.HttpContext.JSON(http.StatusOK, result)
})
}
func (api *userApi) refresh(c echo.Context) error {
user, _ := c.Get(ContextUserKey).(*models.User)
if user == nil {
return rest.NewNotFoundError("Missing auth user context.", nil)
}
return api.authResponse(c, user, nil)
}
type providerInfo struct {
Name string `json:"name"`
State string `json:"state"`
CodeVerifier string `json:"codeVerifier"`
CodeChallenge string `json:"codeChallenge"`
CodeChallengeMethod string `json:"codeChallengeMethod"`
AuthUrl string `json:"authUrl"`
}
func (api *userApi) authMethods(c echo.Context) error {
result := struct {
EmailPassword bool `json:"emailPassword"`
AuthProviders []providerInfo `json:"authProviders"`
}{
EmailPassword: true,
AuthProviders: []providerInfo{},
}
settings := api.app.Settings()
result.EmailPassword = settings.EmailAuth.Enabled
nameConfigMap := settings.NamedAuthProviderConfigs()
for name, config := range nameConfigMap {
if !config.Enabled {
continue
}
provider, err := auth.NewProviderByName(name)
if err != nil {
if api.app.IsDebug() {
log.Println(err)
}
// skip provider
continue
}
if err := config.SetupProvider(provider); err != nil {
if api.app.IsDebug() {
log.Println(err)
}
// skip provider
continue
}
state := security.RandomString(30)
codeVerifier := security.RandomString(43)
codeChallenge := security.S256Challenge(codeVerifier)
codeChallengeMethod := "S256"
result.AuthProviders = append(result.AuthProviders, providerInfo{
Name: name,
State: state,
CodeVerifier: codeVerifier,
CodeChallenge: codeChallenge,
CodeChallengeMethod: codeChallengeMethod,
AuthUrl: provider.BuildAuthUrl(
state,
oauth2.SetAuthURLParam("code_challenge", codeChallenge),
oauth2.SetAuthURLParam("code_challenge_method", codeChallengeMethod),
) + "&redirect_uri=", // empty redirect_uri so that users can append their url
})
}
return c.JSON(http.StatusOK, result)
}
func (api *userApi) oauth2Auth(c echo.Context) error {
form := forms.NewUserOauth2Login(api.app)
if readErr := c.Bind(form); readErr != nil {
return rest.NewBadRequestError("An error occurred while loading the submitted data.", readErr)
}
user, authData, submitErr := form.Submit()
if submitErr != nil {
return rest.NewBadRequestError("Failed to authenticate.", submitErr)
}
return api.authResponse(c, user, authData)
}
func (api *userApi) emailAuth(c echo.Context) error {
if !api.app.Settings().EmailAuth.Enabled {
return rest.NewBadRequestError("Email/Password authentication is not enabled.", nil)
}
form := forms.NewUserEmailLogin(api.app)
if readErr := c.Bind(form); readErr != nil {
return rest.NewBadRequestError("An error occurred while loading the submitted data.", readErr)
}
user, submitErr := form.Submit()
if submitErr != nil {
return rest.NewBadRequestError("Failed to authenticate.", submitErr)
}
return api.authResponse(c, user, nil)
}
func (api *userApi) requestPasswordReset(c echo.Context) error {
form := forms.NewUserPasswordResetRequest(api.app)
if err := c.Bind(form); err != nil {
return rest.NewBadRequestError("An error occurred while loading the submitted data.", err)
}
if err := form.Validate(); err != nil {
return rest.NewBadRequestError("An error occurred while validating the form.", err)
}
// run in background because we don't need to show
// the result to the user (prevents users enumeration)
routine.FireAndForget(func() {
if err := form.Submit(); err != nil && api.app.IsDebug() {
log.Println(err)
}
})
return c.NoContent(http.StatusNoContent)
}
func (api *userApi) confirmPasswordReset(c echo.Context) error {
form := forms.NewUserPasswordResetConfirm(api.app)
if readErr := c.Bind(form); readErr != nil {
return rest.NewBadRequestError("An error occurred while loading the submitted data.", readErr)
}
user, submitErr := form.Submit()
if submitErr != nil {
return rest.NewBadRequestError("Failed to set new password.", submitErr)
}
return api.authResponse(c, user, nil)
}
func (api *userApi) requestEmailChange(c echo.Context) error {
loggedUser, _ := c.Get(ContextUserKey).(*models.User)
if loggedUser == nil {
return rest.NewUnauthorizedError("The request requires valid authorized user.", nil)
}
form := forms.NewUserEmailChangeRequest(api.app, loggedUser)
if err := c.Bind(form); err != nil {
return rest.NewBadRequestError("An error occurred while loading the submitted data.", err)
}
if err := form.Submit(); err != nil {
return rest.NewBadRequestError("Failed to request email change.", err)
}
return c.NoContent(http.StatusNoContent)
}
func (api *userApi) confirmEmailChange(c echo.Context) error {
form := forms.NewUserEmailChangeConfirm(api.app)
if readErr := c.Bind(form); readErr != nil {
return rest.NewBadRequestError("An error occurred while loading the submitted data.", readErr)
}
user, submitErr := form.Submit()
if submitErr != nil {
return rest.NewBadRequestError("Failed to confirm email change.", submitErr)
}
return api.authResponse(c, user, nil)
}
func (api *userApi) requestVerification(c echo.Context) error {
form := forms.NewUserVerificationRequest(api.app)
if err := c.Bind(form); err != nil {
return rest.NewBadRequestError("An error occurred while loading the submitted data.", err)
}
if err := form.Validate(); err != nil {
return rest.NewBadRequestError("An error occurred while validating the form.", err)
}
// run in background because we don't need to show
// the result to the user (prevents users enumeration)
routine.FireAndForget(func() {
if err := form.Submit(); err != nil && api.app.IsDebug() {
log.Println(err)
}
})
return c.NoContent(http.StatusNoContent)
}
func (api *userApi) confirmVerification(c echo.Context) error {
form := forms.NewUserVerificationConfirm(api.app)
if readErr := c.Bind(form); readErr != nil {
return rest.NewBadRequestError("An error occurred while loading the submitted data.", readErr)
}
user, submitErr := form.Submit()
if submitErr != nil {
return rest.NewBadRequestError("An error occurred while submitting the form.", submitErr)
}
return api.authResponse(c, user, nil)
}
// -------------------------------------------------------------------
// CRUD
// -------------------------------------------------------------------
func (api *userApi) list(c echo.Context) error {
fieldResolver := search.NewSimpleFieldResolver(
"id", "created", "updated", "email", "verified",
)
users := []*models.User{}
result, searchErr := search.NewProvider(fieldResolver).
Query(api.app.Dao().UserQuery()).
ParseAndExec(c.QueryString(), &users)
if searchErr != nil {
return rest.NewBadRequestError("", searchErr)
}
// eager load user profiles (if any)
if err := api.app.Dao().LoadProfiles(users); err != nil {
return rest.NewBadRequestError("", err)
}
event := &core.UsersListEvent{
HttpContext: c,
Users: users,
Result: result,
}
return api.app.OnUsersListRequest().Trigger(event, func(e *core.UsersListEvent) error {
return e.HttpContext.JSON(http.StatusOK, e.Result)
})
}
func (api *userApi) view(c echo.Context) error {
id := c.PathParam("id")
if id == "" {
return rest.NewNotFoundError("", nil)
}
user, err := api.app.Dao().FindUserById(id)
if err != nil || user == nil {
return rest.NewNotFoundError("", err)
}
event := &core.UserViewEvent{
HttpContext: c,
User: user,
}
return api.app.OnUserViewRequest().Trigger(event, func(e *core.UserViewEvent) error {
return e.HttpContext.JSON(http.StatusOK, e.User)
})
}
func (api *userApi) create(c echo.Context) error {
if !api.app.Settings().EmailAuth.Enabled {
return rest.NewBadRequestError("Email/Password authentication is not enabled.", nil)
}
user := &models.User{}
form := forms.NewUserUpsert(api.app, user)
// load request
if err := c.Bind(form); err != nil {
return rest.NewBadRequestError("Failed to load the submitted data due to invalid formatting.", err)
}
event := &core.UserCreateEvent{
HttpContext: c,
User: user,
}
// create the user
submitErr := form.Submit(func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
return func() error {
return api.app.OnUserBeforeCreateRequest().Trigger(event, func(e *core.UserCreateEvent) error {
if err := next(); err != nil {
return rest.NewBadRequestError("Failed to create user.", err)
}
return e.HttpContext.JSON(http.StatusOK, e.User)
})
}
})
if submitErr == nil {
api.app.OnUserAfterCreateRequest().Trigger(event)
}
return submitErr
}
func (api *userApi) update(c echo.Context) error {
id := c.PathParam("id")
if id == "" {
return rest.NewNotFoundError("", nil)
}
user, err := api.app.Dao().FindUserById(id)
if err != nil || user == nil {
return rest.NewNotFoundError("", err)
}
form := forms.NewUserUpsert(api.app, user)
// load request
if err := c.Bind(form); err != nil {
return rest.NewBadRequestError("Failed to load the submitted data due to invalid formatting.", err)
}
event := &core.UserUpdateEvent{
HttpContext: c,
User: user,
}
// update the user
submitErr := form.Submit(func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
return func() error {
return api.app.OnUserBeforeUpdateRequest().Trigger(event, func(e *core.UserUpdateEvent) error {
if err := next(); err != nil {
return rest.NewBadRequestError("Failed to update user.", err)
}
return e.HttpContext.JSON(http.StatusOK, e.User)
})
}
})
if submitErr == nil {
api.app.OnUserAfterUpdateRequest().Trigger(event)
}
return submitErr
}
func (api *userApi) delete(c echo.Context) error {
id := c.PathParam("id")
if id == "" {
return rest.NewNotFoundError("", nil)
}
user, err := api.app.Dao().FindUserById(id)
if err != nil || user == nil {
return rest.NewNotFoundError("", err)
}
event := &core.UserDeleteEvent{
HttpContext: c,
User: user,
}
handlerErr := api.app.OnUserBeforeDeleteRequest().Trigger(event, func(e *core.UserDeleteEvent) error {
// delete the user model
if err := api.app.Dao().DeleteUser(e.User); err != nil {
return rest.NewBadRequestError("Failed to delete user. Make sure that the user is not part of a required relation reference.", err)
}
return e.HttpContext.NoContent(http.StatusNoContent)
})
if handlerErr == nil {
api.app.OnUserAfterDeleteRequest().Trigger(event)
}
return handlerErr
}
func (api *userApi) listExternalAuths(c echo.Context) error {
id := c.PathParam("id")
if id == "" {
return rest.NewNotFoundError("", nil)
}
user, err := api.app.Dao().FindUserById(id)
if err != nil || user == nil {
return rest.NewNotFoundError("", err)
}
externalAuths, err := api.app.Dao().FindAllExternalAuthsByUserId(user.Id)
if err != nil {
return rest.NewBadRequestError("Failed to fetch the external auths for the specified user.", err)
}
event := &core.UserListExternalAuthsEvent{
HttpContext: c,
User: user,
ExternalAuths: externalAuths,
}
return api.app.OnUserListExternalAuths().Trigger(event, func(e *core.UserListExternalAuthsEvent) error {
return e.HttpContext.JSON(http.StatusOK, e.ExternalAuths)
})
}
func (api *userApi) unlinkExternalAuth(c echo.Context) error {
id := c.PathParam("id")
provider := c.PathParam("provider")
if id == "" || provider == "" {
return rest.NewNotFoundError("", nil)
}
user, err := api.app.Dao().FindUserById(id)
if err != nil || user == nil {
return rest.NewNotFoundError("", err)
}
externalAuth, err := api.app.Dao().FindExternalAuthByUserIdAndProvider(user.Id, provider)
if err != nil {
return rest.NewNotFoundError("Missing external auth provider relation.", err)
}
event := &core.UserUnlinkExternalAuthEvent{
HttpContext: c,
User: user,
ExternalAuth: externalAuth,
}
handlerErr := api.app.OnUserBeforeUnlinkExternalAuthRequest().Trigger(event, func(e *core.UserUnlinkExternalAuthEvent) error {
if err := api.app.Dao().DeleteExternalAuth(externalAuth); err != nil {
return rest.NewBadRequestError("Cannot unlink the external auth provider. Make sure that the user has other linked auth providers OR has an email address.", err)
}
return e.HttpContext.NoContent(http.StatusNoContent)
})
if handlerErr == nil {
api.app.OnUserAfterUnlinkExternalAuthRequest().Trigger(event)
}
return handlerErr
}
-1113
View File
File diff suppressed because it is too large Load Diff