mirror of
https://github.com/pocketbase/pocketbase.git
synced 2024-11-21 13:35:49 +02:00
initial v0.8 pre-release
This commit is contained in:
parent
9cbb2e750e
commit
90dba45d7c
4
Makefile
4
Makefile
@ -2,8 +2,8 @@ lint:
|
||||
golangci-lint run -c ./golangci.yml ./...
|
||||
|
||||
test:
|
||||
go test -v --cover ./...
|
||||
go test ./... -v --cover
|
||||
|
||||
test-report:
|
||||
go test -v --cover -coverprofile=coverage.out ./...
|
||||
go test ./... -v --cover -coverprofile=coverage.out
|
||||
go tool cover -html=coverage.out
|
||||
|
@ -72,7 +72,7 @@ func main() {
|
||||
return c.String(200, "Hello world!")
|
||||
},
|
||||
Middlewares: []echo.MiddlewareFunc{
|
||||
apis.RequireAdminOrUserAuth(),
|
||||
apis.RequireAdminOrRecordAuth(),
|
||||
},
|
||||
})
|
||||
|
||||
|
@ -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)
|
||||
|
@ -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,
|
||||
|
@ -1,4 +1,4 @@
|
||||
package rest
|
||||
package apis
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
@ -8,7 +8,7 @@ import (
|
||||
"github.com/pocketbase/pocketbase/tools/inflector"
|
||||
)
|
||||
|
||||
// ApiError defines the properties for a basic api error response.
|
||||
// ApiError defines the struct for a basic api error response.
|
||||
type ApiError struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
@ -23,6 +23,7 @@ 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
|
||||
}
|
@ -1,4 +1,4 @@
|
||||
package rest_test
|
||||
package apis_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
@ -6,11 +6,11 @@ import (
|
||||
"testing"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
"github.com/pocketbase/pocketbase/tools/rest"
|
||||
"github.com/pocketbase/pocketbase/apis"
|
||||
)
|
||||
|
||||
func TestNewApiErrorWithRawData(t *testing.T) {
|
||||
e := rest.NewApiError(
|
||||
e := apis.NewApiError(
|
||||
300,
|
||||
"message_test",
|
||||
"rawData_test",
|
||||
@ -33,7 +33,7 @@ func TestNewApiErrorWithRawData(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestNewApiErrorWithValidationData(t *testing.T) {
|
||||
e := rest.NewApiError(
|
||||
e := apis.NewApiError(
|
||||
300,
|
||||
"message_test",
|
||||
validation.Errors{
|
||||
@ -77,7 +77,7 @@ func TestNewNotFoundError(t *testing.T) {
|
||||
}
|
||||
|
||||
for i, scenario := range scenarios {
|
||||
e := rest.NewNotFoundError(scenario.message, scenario.data)
|
||||
e := apis.NewNotFoundError(scenario.message, scenario.data)
|
||||
result, _ := json.Marshal(e)
|
||||
|
||||
if string(result) != scenario.expected {
|
||||
@ -98,7 +98,7 @@ func TestNewBadRequestError(t *testing.T) {
|
||||
}
|
||||
|
||||
for i, scenario := range scenarios {
|
||||
e := rest.NewBadRequestError(scenario.message, scenario.data)
|
||||
e := apis.NewBadRequestError(scenario.message, scenario.data)
|
||||
result, _ := json.Marshal(e)
|
||||
|
||||
if string(result) != scenario.expected {
|
||||
@ -119,7 +119,7 @@ func TestNewForbiddenError(t *testing.T) {
|
||||
}
|
||||
|
||||
for i, scenario := range scenarios {
|
||||
e := rest.NewForbiddenError(scenario.message, scenario.data)
|
||||
e := apis.NewForbiddenError(scenario.message, scenario.data)
|
||||
result, _ := json.Marshal(e)
|
||||
|
||||
if string(result) != scenario.expected {
|
||||
@ -140,7 +140,7 @@ func TestNewUnauthorizedError(t *testing.T) {
|
||||
}
|
||||
|
||||
for i, scenario := range scenarios {
|
||||
e := rest.NewUnauthorizedError(scenario.message, scenario.data)
|
||||
e := apis.NewUnauthorizedError(scenario.message, scenario.data)
|
||||
result, _ := json.Marshal(e)
|
||||
|
||||
if string(result) != scenario.expected {
|
51
apis/base.go
51
apis/base.go
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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"))
|
||||
},
|
||||
})
|
||||
},
|
||||
|
@ -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)
|
||||
|
@ -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))
|
||||
}
|
||||
},
|
||||
},
|
||||
|
23
apis/file.go
23
apis/file.go
@ -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
|
||||
|
@ -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{
|
||||
|
15
apis/logs.go
15
apis/logs.go
@ -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)
|
||||
|
@ -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"}]`,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
126
apis/realtime.go
126
apis/realtime.go
@ -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)
|
||||
|
@ -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
apis/record_auth.go
Normal file
477
apis/record_auth.go
Normal 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
|
||||
}
|
1115
apis/record_auth_test.go
Normal file
1115
apis/record_auth_test.go
Normal file
File diff suppressed because it is too large
Load Diff
@ -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
|
||||
})
|
||||
}
|
||||
}
|
1725
apis/record_crud_test.go
Normal file
1725
apis/record_crud_test.go
Normal file
File diff suppressed because it is too large
Load Diff
186
apis/record_helpers.go
Normal file
186
apis/record_helpers.go
Normal 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
apis/record_test.go
1052
apis/record_test.go
File diff suppressed because it is too large
Load Diff
@ -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)
|
||||
|
@ -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
apis/user.go
519
apis/user.go
@ -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
apis/user_test.go
1113
apis/user_test.go
File diff suppressed because it is too large
Load Diff
444
cmd/temp_upgrade.go
Normal file
444
cmd/temp_upgrade.go
Normal file
@ -0,0 +1,444 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/fatih/color"
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/daos"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/models/schema"
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// Temporary console command to update the pb_data structure to be compatible with the v0.8.0 changes.
|
||||
//
|
||||
// NB! It will be removed in v0.9.0!
|
||||
func NewTempUpgradeCommand(app core.App) *cobra.Command {
|
||||
command := &cobra.Command{
|
||||
Use: "upgrade",
|
||||
Short: "Upgrades your existing pb_data to be compatible with the v0.8.x changes",
|
||||
Long: `
|
||||
Upgrades your existing pb_data to be compatible with the v0.8.x changes
|
||||
Prerequisites and caveats:
|
||||
- already upgraded to v0.7.*
|
||||
- no existing users collection
|
||||
- existing profiles collection fields like email, username, verified, etc. will be renamed to username2, email2, etc.
|
||||
`,
|
||||
Run: func(command *cobra.Command, args []string) {
|
||||
if err := upgrade(app); err != nil {
|
||||
color.Red("Error: %v", err)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
return command
|
||||
}
|
||||
|
||||
func upgrade(app core.App) error {
|
||||
if _, err := app.Dao().FindCollectionByNameOrId("users"); err == nil {
|
||||
return errors.New("It seems that you've already upgraded or have an existing 'users' collection.")
|
||||
}
|
||||
|
||||
return app.Dao().RunInTransaction(func(txDao *daos.Dao) error {
|
||||
if err := migrateCollections(txDao); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := migrateUsers(app, txDao); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := resetMigrationsTable(txDao); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
bold := color.New(color.Bold).Add(color.FgGreen)
|
||||
bold.Println("The pb_data upgrade completed successfully!")
|
||||
bold.Println("You can now start the application as usual with the 'serve' command.")
|
||||
bold.Println("Please review the migrated collection API rules and fields in the Admin UI and apply the necessary changes in your client-side code.")
|
||||
fmt.Println()
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
func migrateCollections(txDao *daos.Dao) error {
|
||||
// add new collection columns
|
||||
if _, err := txDao.DB().AddColumn("_collections", "type", "TEXT DEFAULT 'base' NOT NULL").Execute(); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := txDao.DB().AddColumn("_collections", "options", "JSON DEFAULT '{}' NOT NULL").Execute(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ruleReplacements := []struct {
|
||||
old string
|
||||
new string
|
||||
}{
|
||||
{"expand", "expand2"},
|
||||
{"collecitonId", "collectionId2"},
|
||||
{"collecitonName", "collectionName2"},
|
||||
{"profile.userId", "profile.id"},
|
||||
|
||||
// @collection.*
|
||||
{"@collection.profiles.userId", "@collection.users.id"},
|
||||
{"@collection.profiles.username", "@collection.users.username2"},
|
||||
{"@collection.profiles.email", "@collection.users.email2"},
|
||||
{"@collection.profiles.emailVisibility", "@collection.users.emailVisibility2"},
|
||||
{"@collection.profiles.verified", "@collection.users.verified2"},
|
||||
{"@collection.profiles.tokenKey", "@collection.users.tokenKey2"},
|
||||
{"@collection.profiles.passwordHash", "@collection.users.passwordHash2"},
|
||||
{"@collection.profiles.lastResetSentAt", "@collection.users.lastResetSentAt2"},
|
||||
{"@collection.profiles.lastVerificationSentAt", "@collection.users.lastVerificationSentAt2"},
|
||||
{"@collection.profiles.", "@collection.users."},
|
||||
|
||||
// @request.*
|
||||
{"@request.user.profile.userId", "@request.auth.id"},
|
||||
{"@request.user.profile.username", "@request.auth.username2"},
|
||||
{"@request.user.profile.email", "@request.auth.email2"},
|
||||
{"@request.user.profile.emailVisibility", "@request.auth.emailVisibility2"},
|
||||
{"@request.user.profile.verified", "@request.auth.verified2"},
|
||||
{"@request.user.profile.tokenKey", "@request.auth.tokenKey2"},
|
||||
{"@request.user.profile.passwordHash", "@request.auth.passwordHash2"},
|
||||
{"@request.user.profile.lastResetSentAt", "@request.auth.lastResetSentAt2"},
|
||||
{"@request.user.profile.lastVerificationSentAt", "@request.auth.lastVerificationSentAt2"},
|
||||
{"@request.user.profile.", "@request.auth."},
|
||||
{"@request.user", "@request.auth"},
|
||||
}
|
||||
|
||||
collections := []*models.Collection{}
|
||||
if err := txDao.CollectionQuery().All(&collections); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, collection := range collections {
|
||||
collection.Type = models.CollectionTypeBase
|
||||
collection.NormalizeOptions()
|
||||
|
||||
// rename profile fields
|
||||
// ---
|
||||
fieldsToRename := []string{
|
||||
"collectionId",
|
||||
"collectionName",
|
||||
"expand",
|
||||
}
|
||||
if collection.Name == "profiles" {
|
||||
fieldsToRename = append(fieldsToRename,
|
||||
"username",
|
||||
"email",
|
||||
"emailVisibility",
|
||||
"verified",
|
||||
"tokenKey",
|
||||
"passwordHash",
|
||||
"lastResetSentAt",
|
||||
"lastVerificationSentAt",
|
||||
)
|
||||
}
|
||||
for _, name := range fieldsToRename {
|
||||
f := collection.Schema.GetFieldByName(name)
|
||||
if f != nil {
|
||||
color.Blue("[%s - renamed field]", collection.Name)
|
||||
color.Yellow(" - old: %s", f.Name)
|
||||
color.Green(" - new: %s2", f.Name)
|
||||
fmt.Println()
|
||||
f.Name += "2"
|
||||
}
|
||||
}
|
||||
// ---
|
||||
|
||||
// replace rule fields
|
||||
// ---
|
||||
rules := map[string]*string{
|
||||
"ListRule": collection.ListRule,
|
||||
"ViewRule": collection.ViewRule,
|
||||
"CreateRule": collection.CreateRule,
|
||||
"UpdateRule": collection.UpdateRule,
|
||||
"DeleteRule": collection.DeleteRule,
|
||||
}
|
||||
|
||||
for ruleKey, rule := range rules {
|
||||
if rule == nil || *rule == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
originalRule := *rule
|
||||
|
||||
for _, replacement := range ruleReplacements {
|
||||
re := regexp.MustCompile(regexp.QuoteMeta(replacement.old) + `\b`)
|
||||
*rule = re.ReplaceAllString(*rule, replacement.new)
|
||||
}
|
||||
|
||||
*rule = replaceReversedLikes(*rule)
|
||||
|
||||
if originalRule != *rule {
|
||||
color.Blue("[%s - replaced %s]:", collection.Name, ruleKey)
|
||||
color.Yellow(" - old: %s", strings.TrimSpace(originalRule))
|
||||
color.Green(" - new: %s", strings.TrimSpace(*rule))
|
||||
fmt.Println()
|
||||
}
|
||||
}
|
||||
// ---
|
||||
|
||||
if err := txDao.SaveCollection(collection); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func migrateUsers(app core.App, txDao *daos.Dao) error {
|
||||
color.Blue(`[merging "_users" and "profiles"]:`)
|
||||
|
||||
profilesCollection, err := txDao.FindCollectionByNameOrId("profiles")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
originalProfilesCollectionId := profilesCollection.Id
|
||||
|
||||
// change the profiles collection id to something else since we will be using
|
||||
// it for the new users collection in order to avoid renaming the storage dir
|
||||
_, idRenameErr := txDao.DB().NewQuery(fmt.Sprintf(
|
||||
`UPDATE {{_collections}}
|
||||
SET id = '%s'
|
||||
WHERE id = '%s';
|
||||
`,
|
||||
(originalProfilesCollectionId + "__old__"),
|
||||
originalProfilesCollectionId,
|
||||
)).Execute()
|
||||
if idRenameErr != nil {
|
||||
return idRenameErr
|
||||
}
|
||||
|
||||
// refresh profiles collection
|
||||
profilesCollection, err = txDao.FindCollectionByNameOrId("profiles")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
usersSchema, _ := profilesCollection.Schema.Clone()
|
||||
userIdField := usersSchema.GetFieldByName("userId")
|
||||
if userIdField != nil {
|
||||
usersSchema.RemoveField(userIdField.Id)
|
||||
}
|
||||
|
||||
usersCollection := &models.Collection{}
|
||||
usersCollection.MarkAsNew()
|
||||
usersCollection.Id = originalProfilesCollectionId
|
||||
usersCollection.Name = "users"
|
||||
usersCollection.Type = models.CollectionTypeAuth
|
||||
usersCollection.Schema = *usersSchema
|
||||
usersCollection.CreateRule = types.Pointer("")
|
||||
if profilesCollection.ListRule != nil && *profilesCollection.ListRule != "" {
|
||||
*profilesCollection.ListRule = strings.ReplaceAll(*profilesCollection.ListRule, "userId", "id")
|
||||
usersCollection.ListRule = profilesCollection.ListRule
|
||||
}
|
||||
if profilesCollection.ViewRule != nil && *profilesCollection.ViewRule != "" {
|
||||
*profilesCollection.ViewRule = strings.ReplaceAll(*profilesCollection.ViewRule, "userId", "id")
|
||||
usersCollection.ViewRule = profilesCollection.ViewRule
|
||||
}
|
||||
if profilesCollection.UpdateRule != nil && *profilesCollection.UpdateRule != "" {
|
||||
*profilesCollection.UpdateRule = strings.ReplaceAll(*profilesCollection.UpdateRule, "userId", "id")
|
||||
usersCollection.UpdateRule = profilesCollection.UpdateRule
|
||||
}
|
||||
if profilesCollection.DeleteRule != nil && *profilesCollection.DeleteRule != "" {
|
||||
*profilesCollection.DeleteRule = strings.ReplaceAll(*profilesCollection.DeleteRule, "userId", "id")
|
||||
usersCollection.DeleteRule = profilesCollection.DeleteRule
|
||||
}
|
||||
|
||||
// set auth options
|
||||
settings := app.Settings()
|
||||
authOptions := usersCollection.AuthOptions()
|
||||
authOptions.ManageRule = nil
|
||||
authOptions.AllowOAuth2Auth = true
|
||||
authOptions.AllowUsernameAuth = false
|
||||
authOptions.AllowEmailAuth = settings.EmailAuth.Enabled
|
||||
authOptions.MinPasswordLength = settings.EmailAuth.MinPasswordLength
|
||||
authOptions.OnlyEmailDomains = settings.EmailAuth.OnlyDomains
|
||||
authOptions.ExceptEmailDomains = settings.EmailAuth.ExceptDomains
|
||||
// twitter currently is the only provider that doesn't return an email
|
||||
authOptions.RequireEmail = !settings.TwitterAuth.Enabled
|
||||
|
||||
usersCollection.SetOptions(authOptions)
|
||||
|
||||
if err := txDao.SaveCollection(usersCollection); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// copy the original users
|
||||
_, usersErr := txDao.DB().NewQuery(`
|
||||
INSERT INTO {{users}} (id, created, updated, username, email, emailVisibility, verified, tokenKey, passwordHash, lastResetSentAt, lastVerificationSentAt)
|
||||
SELECT id, created, updated, ("u_" || id), email, false, verified, tokenKey, passwordHash, lastResetSentAt, lastVerificationSentAt
|
||||
FROM {{_users}};
|
||||
`).Execute()
|
||||
if usersErr != nil {
|
||||
return usersErr
|
||||
}
|
||||
|
||||
// generate the profile fields copy statements
|
||||
sets := []string{"id = p.id"}
|
||||
for _, f := range usersSchema.Fields() {
|
||||
sets = append(sets, fmt.Sprintf("%s = p.%s", f.Name, f.Name))
|
||||
}
|
||||
|
||||
// copy profile fields
|
||||
_, copyProfileErr := txDao.DB().NewQuery(fmt.Sprintf(`
|
||||
UPDATE {{users}} as u
|
||||
SET %s
|
||||
FROM {{profiles}} as p
|
||||
WHERE u.id = p.userId;
|
||||
`, strings.Join(sets, ", "))).Execute()
|
||||
if copyProfileErr != nil {
|
||||
return copyProfileErr
|
||||
}
|
||||
|
||||
profileRecords, err := txDao.FindRecordsByExpr("profiles")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// update all profiles and users fields to point to the new users collection
|
||||
collections := []*models.Collection{}
|
||||
if err := txDao.CollectionQuery().All(&collections); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, collection := range collections {
|
||||
var hasChanges bool
|
||||
|
||||
for _, f := range collection.Schema.Fields() {
|
||||
f.InitOptions()
|
||||
|
||||
if f.Type == schema.FieldTypeUser {
|
||||
if collection.Name == "profiles" && f.Name == "userId" {
|
||||
continue
|
||||
}
|
||||
|
||||
hasChanges = true
|
||||
|
||||
// change the user field to a relation field
|
||||
options, _ := f.Options.(*schema.UserOptions)
|
||||
f.Type = schema.FieldTypeRelation
|
||||
f.Options = &schema.RelationOptions{
|
||||
CollectionId: usersCollection.Id,
|
||||
MaxSelect: &options.MaxSelect,
|
||||
CascadeDelete: options.CascadeDelete,
|
||||
}
|
||||
|
||||
for _, p := range profileRecords {
|
||||
pId := p.Id
|
||||
pUserId := p.GetString("userId")
|
||||
// replace all user record id references with the profile id
|
||||
_, replaceErr := txDao.DB().NewQuery(fmt.Sprintf(`
|
||||
UPDATE %s
|
||||
SET [[%s]] = REPLACE([[%s]], '%s', '%s')
|
||||
WHERE [[%s]] LIKE ('%%%s%%');
|
||||
`, collection.Name, f.Name, f.Name, pUserId, pId, f.Name, pUserId)).Execute()
|
||||
if replaceErr != nil {
|
||||
return replaceErr
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if hasChanges {
|
||||
if err := txDao.Save(collection); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := migrateExternalAuths(txDao, originalProfilesCollectionId); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// drop _users table
|
||||
if _, err := txDao.DB().DropTable("_users").Execute(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// drop profiles table
|
||||
if _, err := txDao.DB().DropTable("profiles").Execute(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// delete profiles collection
|
||||
if err := txDao.Delete(profilesCollection); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
color.Green(` - Successfully merged "_users" and "profiles" into a new collection "users".`)
|
||||
fmt.Println()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func migrateExternalAuths(txDao *daos.Dao, userCollectionId string) error {
|
||||
_, alterErr := txDao.DB().NewQuery(`
|
||||
-- crate new externalAuths table
|
||||
CREATE TABLE {{_newExternalAuths}} (
|
||||
[[id]] TEXT PRIMARY KEY,
|
||||
[[collectionId]] TEXT NOT NULL,
|
||||
[[recordId]] TEXT NOT NULL,
|
||||
[[provider]] TEXT NOT NULL,
|
||||
[[providerId]] TEXT NOT NULL,
|
||||
[[created]] TEXT DEFAULT "" NOT NULL,
|
||||
[[updated]] TEXT DEFAULT "" NOT NULL,
|
||||
---
|
||||
FOREIGN KEY ([[collectionId]]) REFERENCES {{_collections}} ([[id]]) ON UPDATE CASCADE ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- copy all data from the old table to the new one
|
||||
INSERT INTO {{_newExternalAuths}}
|
||||
SELECT auth.id, "` + userCollectionId + `" as collectionId, [[profiles.id]] as recordId, auth.provider, auth.providerId, auth.created, auth.updated
|
||||
FROM {{_externalAuths}} auth
|
||||
INNER JOIN {{profiles}} on [[profiles.userId]] = [[auth.userId]];
|
||||
|
||||
-- drop old table
|
||||
DROP TABLE {{_externalAuths}};
|
||||
|
||||
-- rename new table
|
||||
ALTER TABLE {{_newExternalAuths}} RENAME TO {{_externalAuths}};
|
||||
|
||||
-- create named indexes
|
||||
CREATE UNIQUE INDEX _externalAuths_record_provider_idx on {{_externalAuths}} ([[collectionId]], [[recordId]], [[provider]]);
|
||||
CREATE UNIQUE INDEX _externalAuths_provider_providerId_idx on {{_externalAuths}} ([[provider]], [[providerId]]);
|
||||
`).Execute()
|
||||
|
||||
return alterErr
|
||||
}
|
||||
|
||||
func resetMigrationsTable(txDao *daos.Dao) error {
|
||||
// reset the migration state to the new init
|
||||
_, err := txDao.DB().Delete("_migrations", dbx.HashExp{
|
||||
"file": "1661586591_add_externalAuths_table.go",
|
||||
}).Execute()
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
var reverseLikeRegex = regexp.MustCompile(`(['"]\w*['"])\s*(\~|!~)\s*([\w\@\.]*)`)
|
||||
|
||||
func replaceReversedLikes(rule string) string {
|
||||
parts := reverseLikeRegex.FindAllStringSubmatch(rule, -1)
|
||||
|
||||
for _, p := range parts {
|
||||
if len(p) != 4 {
|
||||
continue
|
||||
}
|
||||
|
||||
newPart := fmt.Sprintf("%s %s %s", p[3], p[2], p[1])
|
||||
|
||||
rule = strings.ReplaceAll(rule, p[0], newPart)
|
||||
}
|
||||
|
||||
return rule
|
||||
}
|
109
core/app.go
109
core/app.go
@ -126,38 +126,38 @@ type App interface {
|
||||
// admin password reset email was successfully sent.
|
||||
OnMailerAfterAdminResetPasswordSend() *hook.Hook[*MailerAdminEvent]
|
||||
|
||||
// OnMailerBeforeUserResetPasswordSend hook is triggered right before
|
||||
// sending a password reset email to a user.
|
||||
// OnMailerBeforeRecordResetPasswordSend hook is triggered right before
|
||||
// sending a password reset email to an auth record.
|
||||
//
|
||||
// Could be used to send your own custom email template if
|
||||
// [hook.StopPropagation] is returned in one of its listeners.
|
||||
OnMailerBeforeUserResetPasswordSend() *hook.Hook[*MailerUserEvent]
|
||||
OnMailerBeforeRecordResetPasswordSend() *hook.Hook[*MailerRecordEvent]
|
||||
|
||||
// OnMailerAfterUserResetPasswordSend hook is triggered after
|
||||
// a user password reset email was successfully sent.
|
||||
OnMailerAfterUserResetPasswordSend() *hook.Hook[*MailerUserEvent]
|
||||
// OnMailerAfterRecordResetPasswordSend hook is triggered after
|
||||
// an auth record password reset email was successfully sent.
|
||||
OnMailerAfterRecordResetPasswordSend() *hook.Hook[*MailerRecordEvent]
|
||||
|
||||
// OnMailerBeforeUserVerificationSend hook is triggered right before
|
||||
// sending a verification email to a user.
|
||||
// OnMailerBeforeRecordVerificationSend hook is triggered right before
|
||||
// sending a verification email to an auth record.
|
||||
//
|
||||
// Could be used to send your own custom email template if
|
||||
// [hook.StopPropagation] is returned in one of its listeners.
|
||||
OnMailerBeforeUserVerificationSend() *hook.Hook[*MailerUserEvent]
|
||||
OnMailerBeforeRecordVerificationSend() *hook.Hook[*MailerRecordEvent]
|
||||
|
||||
// OnMailerAfterUserVerificationSend hook is triggered after a user
|
||||
// verification email was successfully sent.
|
||||
OnMailerAfterUserVerificationSend() *hook.Hook[*MailerUserEvent]
|
||||
// OnMailerAfterRecordVerificationSend hook is triggered after a
|
||||
// verification email was successfully sent to an auth record.
|
||||
OnMailerAfterRecordVerificationSend() *hook.Hook[*MailerRecordEvent]
|
||||
|
||||
// OnMailerBeforeUserChangeEmailSend hook is triggered right before
|
||||
// sending a confirmation new address email to a a user.
|
||||
// OnMailerBeforeRecordChangeEmailSend hook is triggered right before
|
||||
// sending a confirmation new address email to an auth record.
|
||||
//
|
||||
// Could be used to send your own custom email template if
|
||||
// [hook.StopPropagation] is returned in one of its listeners.
|
||||
OnMailerBeforeUserChangeEmailSend() *hook.Hook[*MailerUserEvent]
|
||||
OnMailerBeforeRecordChangeEmailSend() *hook.Hook[*MailerRecordEvent]
|
||||
|
||||
// OnMailerAfterUserChangeEmailSend hook is triggered after a user
|
||||
// change address email was successfully sent.
|
||||
OnMailerAfterUserChangeEmailSend() *hook.Hook[*MailerUserEvent]
|
||||
// OnMailerAfterRecordChangeEmailSend hook is triggered after a
|
||||
// verification email was successfully sent to an auth record.
|
||||
OnMailerAfterRecordChangeEmailSend() *hook.Hook[*MailerRecordEvent]
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Realtime API event hooks
|
||||
@ -264,74 +264,31 @@ type App interface {
|
||||
OnAdminAuthRequest() *hook.Hook[*AdminAuthEvent]
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// User API event hooks
|
||||
// Auth Record API event hooks
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
// OnUsersListRequest hook is triggered on each API Users list request.
|
||||
// OnRecordAuthRequest hook is triggered on each successful API
|
||||
// record authentication request (sign-in, token refresh, etc.).
|
||||
//
|
||||
// Could be used to additionally validate or modify the authenticated
|
||||
// record data and token.
|
||||
OnRecordAuthRequest() *hook.Hook[*RecordAuthEvent]
|
||||
|
||||
// OnRecordListExternalAuths hook is triggered on each API record external auths list request.
|
||||
//
|
||||
// Could be used to validate or modify the response before returning it to the client.
|
||||
OnUsersListRequest() *hook.Hook[*UsersListEvent]
|
||||
OnRecordListExternalAuths() *hook.Hook[*RecordListExternalAuthsEvent]
|
||||
|
||||
// OnUserViewRequest hook is triggered on each API User view request.
|
||||
//
|
||||
// Could be used to validate or modify the response before returning it to the client.
|
||||
OnUserViewRequest() *hook.Hook[*UserViewEvent]
|
||||
|
||||
// OnUserBeforeCreateRequest hook is triggered before each API User
|
||||
// create request (after request data load and before model persistence).
|
||||
//
|
||||
// Could be used to additionally validate the request data or implement
|
||||
// completely different persistence behavior (returning [hook.StopPropagation]).
|
||||
OnUserBeforeCreateRequest() *hook.Hook[*UserCreateEvent]
|
||||
|
||||
// OnUserAfterCreateRequest hook is triggered after each
|
||||
// successful API User create request.
|
||||
OnUserAfterCreateRequest() *hook.Hook[*UserCreateEvent]
|
||||
|
||||
// OnUserBeforeUpdateRequest hook is triggered before each API User
|
||||
// update request (after request data load and before model persistence).
|
||||
//
|
||||
// Could be used to additionally validate the request data or implement
|
||||
// completely different persistence behavior (returning [hook.StopPropagation]).
|
||||
OnUserBeforeUpdateRequest() *hook.Hook[*UserUpdateEvent]
|
||||
|
||||
// OnUserAfterUpdateRequest hook is triggered after each
|
||||
// successful API User update request.
|
||||
OnUserAfterUpdateRequest() *hook.Hook[*UserUpdateEvent]
|
||||
|
||||
// OnUserBeforeDeleteRequest hook is triggered before each API User
|
||||
// delete request (after model load and before actual deletion).
|
||||
//
|
||||
// Could be used to additionally validate the request data or implement
|
||||
// completely different delete behavior (returning [hook.StopPropagation]).
|
||||
OnUserBeforeDeleteRequest() *hook.Hook[*UserDeleteEvent]
|
||||
|
||||
// OnUserAfterDeleteRequest hook is triggered after each
|
||||
// successful API User delete request.
|
||||
OnUserAfterDeleteRequest() *hook.Hook[*UserDeleteEvent]
|
||||
|
||||
// OnUserAuthRequest hook is triggered on each successful API User
|
||||
// authentication request (sign-in, token refresh, etc.).
|
||||
//
|
||||
// Could be used to additionally validate or modify the
|
||||
// authenticated user data and token.
|
||||
OnUserAuthRequest() *hook.Hook[*UserAuthEvent]
|
||||
|
||||
// OnUserListExternalAuths hook is triggered on each API user's external auths list request.
|
||||
//
|
||||
// Could be used to validate or modify the response before returning it to the client.
|
||||
OnUserListExternalAuths() *hook.Hook[*UserListExternalAuthsEvent]
|
||||
|
||||
// OnUserBeforeUnlinkExternalAuthRequest hook is triggered before each API user's
|
||||
// OnRecordBeforeUnlinkExternalAuthRequest hook is triggered before each API record
|
||||
// external auth unlink request (after models load and before the actual relation deletion).
|
||||
//
|
||||
// Could be used to additionally validate the request data or implement
|
||||
// completely different delete behavior (returning [hook.StopPropagation]).
|
||||
OnUserBeforeUnlinkExternalAuthRequest() *hook.Hook[*UserUnlinkExternalAuthEvent]
|
||||
OnRecordBeforeUnlinkExternalAuthRequest() *hook.Hook[*RecordUnlinkExternalAuthEvent]
|
||||
|
||||
// OnUserAfterUnlinkExternalAuthRequest hook is triggered after each
|
||||
// successful API user's external auth unlink request.
|
||||
OnUserAfterUnlinkExternalAuthRequest() *hook.Hook[*UserUnlinkExternalAuthEvent]
|
||||
// OnRecordAfterUnlinkExternalAuthRequest hook is triggered after each
|
||||
// successful API record external auth unlink request.
|
||||
OnRecordAfterUnlinkExternalAuthRequest() *hook.Hook[*RecordUnlinkExternalAuthEvent]
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Record API event hooks
|
||||
|
140
core/base.go
140
core/base.go
@ -52,14 +52,14 @@ type BaseApp struct {
|
||||
onModelAfterDelete *hook.Hook[*ModelEvent]
|
||||
|
||||
// mailer event hooks
|
||||
onMailerBeforeAdminResetPasswordSend *hook.Hook[*MailerAdminEvent]
|
||||
onMailerAfterAdminResetPasswordSend *hook.Hook[*MailerAdminEvent]
|
||||
onMailerBeforeUserResetPasswordSend *hook.Hook[*MailerUserEvent]
|
||||
onMailerAfterUserResetPasswordSend *hook.Hook[*MailerUserEvent]
|
||||
onMailerBeforeUserVerificationSend *hook.Hook[*MailerUserEvent]
|
||||
onMailerAfterUserVerificationSend *hook.Hook[*MailerUserEvent]
|
||||
onMailerBeforeUserChangeEmailSend *hook.Hook[*MailerUserEvent]
|
||||
onMailerAfterUserChangeEmailSend *hook.Hook[*MailerUserEvent]
|
||||
onMailerBeforeAdminResetPasswordSend *hook.Hook[*MailerAdminEvent]
|
||||
onMailerAfterAdminResetPasswordSend *hook.Hook[*MailerAdminEvent]
|
||||
onMailerBeforeRecordResetPasswordSend *hook.Hook[*MailerRecordEvent]
|
||||
onMailerAfterRecordResetPasswordSend *hook.Hook[*MailerRecordEvent]
|
||||
onMailerBeforeRecordVerificationSend *hook.Hook[*MailerRecordEvent]
|
||||
onMailerAfterRecordVerificationSend *hook.Hook[*MailerRecordEvent]
|
||||
onMailerBeforeRecordChangeEmailSend *hook.Hook[*MailerRecordEvent]
|
||||
onMailerAfterRecordChangeEmailSend *hook.Hook[*MailerRecordEvent]
|
||||
|
||||
// realtime api event hooks
|
||||
onRealtimeConnectRequest *hook.Hook[*RealtimeConnectEvent]
|
||||
@ -85,19 +85,11 @@ type BaseApp struct {
|
||||
onAdminAfterDeleteRequest *hook.Hook[*AdminDeleteEvent]
|
||||
onAdminAuthRequest *hook.Hook[*AdminAuthEvent]
|
||||
|
||||
// user api event hooks
|
||||
onUsersListRequest *hook.Hook[*UsersListEvent]
|
||||
onUserViewRequest *hook.Hook[*UserViewEvent]
|
||||
onUserBeforeCreateRequest *hook.Hook[*UserCreateEvent]
|
||||
onUserAfterCreateRequest *hook.Hook[*UserCreateEvent]
|
||||
onUserBeforeUpdateRequest *hook.Hook[*UserUpdateEvent]
|
||||
onUserAfterUpdateRequest *hook.Hook[*UserUpdateEvent]
|
||||
onUserBeforeDeleteRequest *hook.Hook[*UserDeleteEvent]
|
||||
onUserAfterDeleteRequest *hook.Hook[*UserDeleteEvent]
|
||||
onUserAuthRequest *hook.Hook[*UserAuthEvent]
|
||||
onUserListExternalAuths *hook.Hook[*UserListExternalAuthsEvent]
|
||||
onUserBeforeUnlinkExternalAuthRequest *hook.Hook[*UserUnlinkExternalAuthEvent]
|
||||
onUserAfterUnlinkExternalAuthRequest *hook.Hook[*UserUnlinkExternalAuthEvent]
|
||||
// user api event hooks
|
||||
onRecordAuthRequest *hook.Hook[*RecordAuthEvent]
|
||||
onRecordListExternalAuths *hook.Hook[*RecordListExternalAuthsEvent]
|
||||
onRecordBeforeUnlinkExternalAuthRequest *hook.Hook[*RecordUnlinkExternalAuthEvent]
|
||||
onRecordAfterUnlinkExternalAuthRequest *hook.Hook[*RecordUnlinkExternalAuthEvent]
|
||||
|
||||
// record api event hooks
|
||||
onRecordsListRequest *hook.Hook[*RecordsListEvent]
|
||||
@ -147,14 +139,14 @@ func NewBaseApp(dataDir string, encryptionEnv string, isDebug bool) *BaseApp {
|
||||
onModelAfterDelete: &hook.Hook[*ModelEvent]{},
|
||||
|
||||
// mailer event hooks
|
||||
onMailerBeforeAdminResetPasswordSend: &hook.Hook[*MailerAdminEvent]{},
|
||||
onMailerAfterAdminResetPasswordSend: &hook.Hook[*MailerAdminEvent]{},
|
||||
onMailerBeforeUserResetPasswordSend: &hook.Hook[*MailerUserEvent]{},
|
||||
onMailerAfterUserResetPasswordSend: &hook.Hook[*MailerUserEvent]{},
|
||||
onMailerBeforeUserVerificationSend: &hook.Hook[*MailerUserEvent]{},
|
||||
onMailerAfterUserVerificationSend: &hook.Hook[*MailerUserEvent]{},
|
||||
onMailerBeforeUserChangeEmailSend: &hook.Hook[*MailerUserEvent]{},
|
||||
onMailerAfterUserChangeEmailSend: &hook.Hook[*MailerUserEvent]{},
|
||||
onMailerBeforeAdminResetPasswordSend: &hook.Hook[*MailerAdminEvent]{},
|
||||
onMailerAfterAdminResetPasswordSend: &hook.Hook[*MailerAdminEvent]{},
|
||||
onMailerBeforeRecordResetPasswordSend: &hook.Hook[*MailerRecordEvent]{},
|
||||
onMailerAfterRecordResetPasswordSend: &hook.Hook[*MailerRecordEvent]{},
|
||||
onMailerBeforeRecordVerificationSend: &hook.Hook[*MailerRecordEvent]{},
|
||||
onMailerAfterRecordVerificationSend: &hook.Hook[*MailerRecordEvent]{},
|
||||
onMailerBeforeRecordChangeEmailSend: &hook.Hook[*MailerRecordEvent]{},
|
||||
onMailerAfterRecordChangeEmailSend: &hook.Hook[*MailerRecordEvent]{},
|
||||
|
||||
// realtime API event hooks
|
||||
onRealtimeConnectRequest: &hook.Hook[*RealtimeConnectEvent]{},
|
||||
@ -181,18 +173,10 @@ func NewBaseApp(dataDir string, encryptionEnv string, isDebug bool) *BaseApp {
|
||||
onAdminAuthRequest: &hook.Hook[*AdminAuthEvent]{},
|
||||
|
||||
// user API event hooks
|
||||
onUsersListRequest: &hook.Hook[*UsersListEvent]{},
|
||||
onUserViewRequest: &hook.Hook[*UserViewEvent]{},
|
||||
onUserBeforeCreateRequest: &hook.Hook[*UserCreateEvent]{},
|
||||
onUserAfterCreateRequest: &hook.Hook[*UserCreateEvent]{},
|
||||
onUserBeforeUpdateRequest: &hook.Hook[*UserUpdateEvent]{},
|
||||
onUserAfterUpdateRequest: &hook.Hook[*UserUpdateEvent]{},
|
||||
onUserBeforeDeleteRequest: &hook.Hook[*UserDeleteEvent]{},
|
||||
onUserAfterDeleteRequest: &hook.Hook[*UserDeleteEvent]{},
|
||||
onUserAuthRequest: &hook.Hook[*UserAuthEvent]{},
|
||||
onUserListExternalAuths: &hook.Hook[*UserListExternalAuthsEvent]{},
|
||||
onUserBeforeUnlinkExternalAuthRequest: &hook.Hook[*UserUnlinkExternalAuthEvent]{},
|
||||
onUserAfterUnlinkExternalAuthRequest: &hook.Hook[*UserUnlinkExternalAuthEvent]{},
|
||||
onRecordAuthRequest: &hook.Hook[*RecordAuthEvent]{},
|
||||
onRecordListExternalAuths: &hook.Hook[*RecordListExternalAuthsEvent]{},
|
||||
onRecordBeforeUnlinkExternalAuthRequest: &hook.Hook[*RecordUnlinkExternalAuthEvent]{},
|
||||
onRecordAfterUnlinkExternalAuthRequest: &hook.Hook[*RecordUnlinkExternalAuthEvent]{},
|
||||
|
||||
// record API event hooks
|
||||
onRecordsListRequest: &hook.Hook[*RecordsListEvent]{},
|
||||
@ -469,28 +453,28 @@ func (app *BaseApp) OnMailerAfterAdminResetPasswordSend() *hook.Hook[*MailerAdmi
|
||||
return app.onMailerAfterAdminResetPasswordSend
|
||||
}
|
||||
|
||||
func (app *BaseApp) OnMailerBeforeUserResetPasswordSend() *hook.Hook[*MailerUserEvent] {
|
||||
return app.onMailerBeforeUserResetPasswordSend
|
||||
func (app *BaseApp) OnMailerBeforeRecordResetPasswordSend() *hook.Hook[*MailerRecordEvent] {
|
||||
return app.onMailerBeforeRecordResetPasswordSend
|
||||
}
|
||||
|
||||
func (app *BaseApp) OnMailerAfterUserResetPasswordSend() *hook.Hook[*MailerUserEvent] {
|
||||
return app.onMailerAfterUserResetPasswordSend
|
||||
func (app *BaseApp) OnMailerAfterRecordResetPasswordSend() *hook.Hook[*MailerRecordEvent] {
|
||||
return app.onMailerAfterRecordResetPasswordSend
|
||||
}
|
||||
|
||||
func (app *BaseApp) OnMailerBeforeUserVerificationSend() *hook.Hook[*MailerUserEvent] {
|
||||
return app.onMailerBeforeUserVerificationSend
|
||||
func (app *BaseApp) OnMailerBeforeRecordVerificationSend() *hook.Hook[*MailerRecordEvent] {
|
||||
return app.onMailerBeforeRecordVerificationSend
|
||||
}
|
||||
|
||||
func (app *BaseApp) OnMailerAfterUserVerificationSend() *hook.Hook[*MailerUserEvent] {
|
||||
return app.onMailerAfterUserVerificationSend
|
||||
func (app *BaseApp) OnMailerAfterRecordVerificationSend() *hook.Hook[*MailerRecordEvent] {
|
||||
return app.onMailerAfterRecordVerificationSend
|
||||
}
|
||||
|
||||
func (app *BaseApp) OnMailerBeforeUserChangeEmailSend() *hook.Hook[*MailerUserEvent] {
|
||||
return app.onMailerBeforeUserChangeEmailSend
|
||||
func (app *BaseApp) OnMailerBeforeRecordChangeEmailSend() *hook.Hook[*MailerRecordEvent] {
|
||||
return app.onMailerBeforeRecordChangeEmailSend
|
||||
}
|
||||
|
||||
func (app *BaseApp) OnMailerAfterUserChangeEmailSend() *hook.Hook[*MailerUserEvent] {
|
||||
return app.onMailerAfterUserChangeEmailSend
|
||||
func (app *BaseApp) OnMailerAfterRecordChangeEmailSend() *hook.Hook[*MailerRecordEvent] {
|
||||
return app.onMailerAfterRecordChangeEmailSend
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
@ -574,55 +558,23 @@ func (app *BaseApp) OnAdminAuthRequest() *hook.Hook[*AdminAuthEvent] {
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// User API event hooks
|
||||
// Auth Record API event hooks
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
func (app *BaseApp) OnUsersListRequest() *hook.Hook[*UsersListEvent] {
|
||||
return app.onUsersListRequest
|
||||
func (app *BaseApp) OnRecordAuthRequest() *hook.Hook[*RecordAuthEvent] {
|
||||
return app.onRecordAuthRequest
|
||||
}
|
||||
|
||||
func (app *BaseApp) OnUserViewRequest() *hook.Hook[*UserViewEvent] {
|
||||
return app.onUserViewRequest
|
||||
func (app *BaseApp) OnRecordListExternalAuths() *hook.Hook[*RecordListExternalAuthsEvent] {
|
||||
return app.onRecordListExternalAuths
|
||||
}
|
||||
|
||||
func (app *BaseApp) OnUserBeforeCreateRequest() *hook.Hook[*UserCreateEvent] {
|
||||
return app.onUserBeforeCreateRequest
|
||||
func (app *BaseApp) OnRecordBeforeUnlinkExternalAuthRequest() *hook.Hook[*RecordUnlinkExternalAuthEvent] {
|
||||
return app.onRecordBeforeUnlinkExternalAuthRequest
|
||||
}
|
||||
|
||||
func (app *BaseApp) OnUserAfterCreateRequest() *hook.Hook[*UserCreateEvent] {
|
||||
return app.onUserAfterCreateRequest
|
||||
}
|
||||
|
||||
func (app *BaseApp) OnUserBeforeUpdateRequest() *hook.Hook[*UserUpdateEvent] {
|
||||
return app.onUserBeforeUpdateRequest
|
||||
}
|
||||
|
||||
func (app *BaseApp) OnUserAfterUpdateRequest() *hook.Hook[*UserUpdateEvent] {
|
||||
return app.onUserAfterUpdateRequest
|
||||
}
|
||||
|
||||
func (app *BaseApp) OnUserBeforeDeleteRequest() *hook.Hook[*UserDeleteEvent] {
|
||||
return app.onUserBeforeDeleteRequest
|
||||
}
|
||||
|
||||
func (app *BaseApp) OnUserAfterDeleteRequest() *hook.Hook[*UserDeleteEvent] {
|
||||
return app.onUserAfterDeleteRequest
|
||||
}
|
||||
|
||||
func (app *BaseApp) OnUserAuthRequest() *hook.Hook[*UserAuthEvent] {
|
||||
return app.onUserAuthRequest
|
||||
}
|
||||
|
||||
func (app *BaseApp) OnUserListExternalAuths() *hook.Hook[*UserListExternalAuthsEvent] {
|
||||
return app.onUserListExternalAuths
|
||||
}
|
||||
|
||||
func (app *BaseApp) OnUserBeforeUnlinkExternalAuthRequest() *hook.Hook[*UserUnlinkExternalAuthEvent] {
|
||||
return app.onUserBeforeUnlinkExternalAuthRequest
|
||||
}
|
||||
|
||||
func (app *BaseApp) OnUserAfterUnlinkExternalAuthRequest() *hook.Hook[*UserUnlinkExternalAuthEvent] {
|
||||
return app.onUserAfterUnlinkExternalAuthRequest
|
||||
func (app *BaseApp) OnRecordAfterUnlinkExternalAuthRequest() *hook.Hook[*RecordUnlinkExternalAuthEvent] {
|
||||
return app.onRecordAfterUnlinkExternalAuthRequest
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
|
@ -195,28 +195,28 @@ func TestBaseAppGetters(t *testing.T) {
|
||||
t.Fatalf("Getter app.OnMailerAfterAdminResetPasswordSend does not match or nil (%v vs %v)", app.OnMailerAfterAdminResetPasswordSend(), app.onMailerAfterAdminResetPasswordSend)
|
||||
}
|
||||
|
||||
if app.onMailerBeforeUserResetPasswordSend != app.OnMailerBeforeUserResetPasswordSend() || app.OnMailerBeforeUserResetPasswordSend() == nil {
|
||||
t.Fatalf("Getter app.OnMailerBeforeUserResetPasswordSend does not match or nil (%v vs %v)", app.OnMailerBeforeUserResetPasswordSend(), app.onMailerBeforeUserResetPasswordSend)
|
||||
if app.onMailerBeforeRecordResetPasswordSend != app.OnMailerBeforeRecordResetPasswordSend() || app.OnMailerBeforeRecordResetPasswordSend() == nil {
|
||||
t.Fatalf("Getter app.OnMailerBeforeRecordResetPasswordSend does not match or nil (%v vs %v)", app.OnMailerBeforeRecordResetPasswordSend(), app.onMailerBeforeRecordResetPasswordSend)
|
||||
}
|
||||
|
||||
if app.onMailerAfterUserResetPasswordSend != app.OnMailerAfterUserResetPasswordSend() || app.OnMailerAfterUserResetPasswordSend() == nil {
|
||||
t.Fatalf("Getter app.OnMailerAfterUserResetPasswordSend does not match or nil (%v vs %v)", app.OnMailerAfterUserResetPasswordSend(), app.onMailerAfterUserResetPasswordSend)
|
||||
if app.onMailerAfterRecordResetPasswordSend != app.OnMailerAfterRecordResetPasswordSend() || app.OnMailerAfterRecordResetPasswordSend() == nil {
|
||||
t.Fatalf("Getter app.OnMailerAfterRecordResetPasswordSend does not match or nil (%v vs %v)", app.OnMailerAfterRecordResetPasswordSend(), app.onMailerAfterRecordResetPasswordSend)
|
||||
}
|
||||
|
||||
if app.onMailerBeforeUserVerificationSend != app.OnMailerBeforeUserVerificationSend() || app.OnMailerBeforeUserVerificationSend() == nil {
|
||||
t.Fatalf("Getter app.OnMailerBeforeUserVerificationSend does not match or nil (%v vs %v)", app.OnMailerBeforeUserVerificationSend(), app.onMailerBeforeUserVerificationSend)
|
||||
if app.onMailerBeforeRecordVerificationSend != app.OnMailerBeforeRecordVerificationSend() || app.OnMailerBeforeRecordVerificationSend() == nil {
|
||||
t.Fatalf("Getter app.OnMailerBeforeRecordVerificationSend does not match or nil (%v vs %v)", app.OnMailerBeforeRecordVerificationSend(), app.onMailerBeforeRecordVerificationSend)
|
||||
}
|
||||
|
||||
if app.onMailerAfterUserVerificationSend != app.OnMailerAfterUserVerificationSend() || app.OnMailerAfterUserVerificationSend() == nil {
|
||||
t.Fatalf("Getter app.OnMailerAfterUserVerificationSend does not match or nil (%v vs %v)", app.OnMailerAfterUserVerificationSend(), app.onMailerAfterUserVerificationSend)
|
||||
if app.onMailerAfterRecordVerificationSend != app.OnMailerAfterRecordVerificationSend() || app.OnMailerAfterRecordVerificationSend() == nil {
|
||||
t.Fatalf("Getter app.OnMailerAfterRecordVerificationSend does not match or nil (%v vs %v)", app.OnMailerAfterRecordVerificationSend(), app.onMailerAfterRecordVerificationSend)
|
||||
}
|
||||
|
||||
if app.onMailerBeforeUserChangeEmailSend != app.OnMailerBeforeUserChangeEmailSend() || app.OnMailerBeforeUserChangeEmailSend() == nil {
|
||||
t.Fatalf("Getter app.OnMailerBeforeUserChangeEmailSend does not match or nil (%v vs %v)", app.OnMailerBeforeUserChangeEmailSend(), app.onMailerBeforeUserChangeEmailSend)
|
||||
if app.onMailerBeforeRecordChangeEmailSend != app.OnMailerBeforeRecordChangeEmailSend() || app.OnMailerBeforeRecordChangeEmailSend() == nil {
|
||||
t.Fatalf("Getter app.OnMailerBeforeRecordChangeEmailSend does not match or nil (%v vs %v)", app.OnMailerBeforeRecordChangeEmailSend(), app.onMailerBeforeRecordChangeEmailSend)
|
||||
}
|
||||
|
||||
if app.onMailerAfterUserChangeEmailSend != app.OnMailerAfterUserChangeEmailSend() || app.OnMailerAfterUserChangeEmailSend() == nil {
|
||||
t.Fatalf("Getter app.OnMailerAfterUserChangeEmailSend does not match or nil (%v vs %v)", app.OnMailerAfterUserChangeEmailSend(), app.onMailerAfterUserChangeEmailSend)
|
||||
if app.onMailerAfterRecordChangeEmailSend != app.OnMailerAfterRecordChangeEmailSend() || app.OnMailerAfterRecordChangeEmailSend() == nil {
|
||||
t.Fatalf("Getter app.OnMailerAfterRecordChangeEmailSend does not match or nil (%v vs %v)", app.OnMailerAfterRecordChangeEmailSend(), app.onMailerAfterRecordChangeEmailSend)
|
||||
}
|
||||
|
||||
if app.onRealtimeConnectRequest != app.OnRealtimeConnectRequest() || app.OnRealtimeConnectRequest() == nil {
|
||||
@ -283,52 +283,52 @@ func TestBaseAppGetters(t *testing.T) {
|
||||
t.Fatalf("Getter app.OnAdminAuthRequest does not match or nil (%v vs %v)", app.OnAdminAuthRequest(), app.onAdminAuthRequest)
|
||||
}
|
||||
|
||||
if app.onUsersListRequest != app.OnUsersListRequest() || app.OnUsersListRequest() == nil {
|
||||
t.Fatalf("Getter app.OnUsersListRequest does not match or nil (%v vs %v)", app.OnUsersListRequest(), app.onUsersListRequest)
|
||||
if app.onRecordsListRequest != app.OnRecordsListRequest() || app.OnRecordsListRequest() == nil {
|
||||
t.Fatalf("Getter app.OnRecordsListRequest does not match or nil (%v vs %v)", app.OnRecordsListRequest(), app.onRecordsListRequest)
|
||||
}
|
||||
|
||||
if app.onUserViewRequest != app.OnUserViewRequest() || app.OnUserViewRequest() == nil {
|
||||
t.Fatalf("Getter app.OnUserViewRequest does not match or nil (%v vs %v)", app.OnUserViewRequest(), app.onUserViewRequest)
|
||||
if app.onRecordViewRequest != app.OnRecordViewRequest() || app.OnRecordViewRequest() == nil {
|
||||
t.Fatalf("Getter app.OnRecordViewRequest does not match or nil (%v vs %v)", app.OnRecordViewRequest(), app.onRecordViewRequest)
|
||||
}
|
||||
|
||||
if app.onUserBeforeCreateRequest != app.OnUserBeforeCreateRequest() || app.OnUserBeforeCreateRequest() == nil {
|
||||
t.Fatalf("Getter app.OnUserBeforeCreateRequest does not match or nil (%v vs %v)", app.OnUserBeforeCreateRequest(), app.onUserBeforeCreateRequest)
|
||||
if app.onRecordBeforeCreateRequest != app.OnRecordBeforeCreateRequest() || app.OnRecordBeforeCreateRequest() == nil {
|
||||
t.Fatalf("Getter app.OnRecordBeforeCreateRequest does not match or nil (%v vs %v)", app.OnRecordBeforeCreateRequest(), app.onRecordBeforeCreateRequest)
|
||||
}
|
||||
|
||||
if app.onUserAfterCreateRequest != app.OnUserAfterCreateRequest() || app.OnUserAfterCreateRequest() == nil {
|
||||
t.Fatalf("Getter app.OnUserAfterCreateRequest does not match or nil (%v vs %v)", app.OnUserAfterCreateRequest(), app.onUserAfterCreateRequest)
|
||||
if app.onRecordAfterCreateRequest != app.OnRecordAfterCreateRequest() || app.OnRecordAfterCreateRequest() == nil {
|
||||
t.Fatalf("Getter app.OnRecordAfterCreateRequest does not match or nil (%v vs %v)", app.OnRecordAfterCreateRequest(), app.onRecordAfterCreateRequest)
|
||||
}
|
||||
|
||||
if app.onUserBeforeUpdateRequest != app.OnUserBeforeUpdateRequest() || app.OnUserBeforeUpdateRequest() == nil {
|
||||
t.Fatalf("Getter app.OnUserBeforeUpdateRequest does not match or nil (%v vs %v)", app.OnUserBeforeUpdateRequest(), app.onUserBeforeUpdateRequest)
|
||||
if app.onRecordBeforeUpdateRequest != app.OnRecordBeforeUpdateRequest() || app.OnRecordBeforeUpdateRequest() == nil {
|
||||
t.Fatalf("Getter app.OnRecordBeforeUpdateRequest does not match or nil (%v vs %v)", app.OnRecordBeforeUpdateRequest(), app.onRecordBeforeUpdateRequest)
|
||||
}
|
||||
|
||||
if app.onUserAfterUpdateRequest != app.OnUserAfterUpdateRequest() || app.OnUserAfterUpdateRequest() == nil {
|
||||
t.Fatalf("Getter app.OnUserAfterUpdateRequest does not match or nil (%v vs %v)", app.OnUserAfterUpdateRequest(), app.onUserAfterUpdateRequest)
|
||||
if app.onRecordAfterUpdateRequest != app.OnRecordAfterUpdateRequest() || app.OnRecordAfterUpdateRequest() == nil {
|
||||
t.Fatalf("Getter app.OnRecordAfterUpdateRequest does not match or nil (%v vs %v)", app.OnRecordAfterUpdateRequest(), app.onRecordAfterUpdateRequest)
|
||||
}
|
||||
|
||||
if app.onUserBeforeDeleteRequest != app.OnUserBeforeDeleteRequest() || app.OnUserBeforeDeleteRequest() == nil {
|
||||
t.Fatalf("Getter app.OnUserBeforeDeleteRequest does not match or nil (%v vs %v)", app.OnUserBeforeDeleteRequest(), app.onUserBeforeDeleteRequest)
|
||||
if app.onRecordBeforeDeleteRequest != app.OnRecordBeforeDeleteRequest() || app.OnRecordBeforeDeleteRequest() == nil {
|
||||
t.Fatalf("Getter app.OnRecordBeforeDeleteRequest does not match or nil (%v vs %v)", app.OnRecordBeforeDeleteRequest(), app.onRecordBeforeDeleteRequest)
|
||||
}
|
||||
|
||||
if app.onUserAfterDeleteRequest != app.OnUserAfterDeleteRequest() || app.OnUserAfterDeleteRequest() == nil {
|
||||
t.Fatalf("Getter app.OnUserAfterDeleteRequest does not match or nil (%v vs %v)", app.OnUserAfterDeleteRequest(), app.onUserAfterDeleteRequest)
|
||||
if app.onRecordAfterDeleteRequest != app.OnRecordAfterDeleteRequest() || app.OnRecordAfterDeleteRequest() == nil {
|
||||
t.Fatalf("Getter app.OnRecordAfterDeleteRequest does not match or nil (%v vs %v)", app.OnRecordAfterDeleteRequest(), app.onRecordAfterDeleteRequest)
|
||||
}
|
||||
|
||||
if app.onUserAuthRequest != app.OnUserAuthRequest() || app.OnUserAuthRequest() == nil {
|
||||
t.Fatalf("Getter app.OnUserAuthRequest does not match or nil (%v vs %v)", app.OnUserAuthRequest(), app.onUserAuthRequest)
|
||||
if app.onRecordAuthRequest != app.OnRecordAuthRequest() || app.OnRecordAuthRequest() == nil {
|
||||
t.Fatalf("Getter app.OnRecordAuthRequest does not match or nil (%v vs %v)", app.OnRecordAuthRequest(), app.onRecordAuthRequest)
|
||||
}
|
||||
|
||||
if app.onUserListExternalAuths != app.OnUserListExternalAuths() || app.OnUserListExternalAuths() == nil {
|
||||
t.Fatalf("Getter app.OnUserListExternalAuths does not match or nil (%v vs %v)", app.OnUserListExternalAuths(), app.onUserListExternalAuths)
|
||||
if app.onRecordListExternalAuths != app.OnRecordListExternalAuths() || app.OnRecordListExternalAuths() == nil {
|
||||
t.Fatalf("Getter app.OnRecordListExternalAuths does not match or nil (%v vs %v)", app.OnRecordListExternalAuths(), app.onRecordListExternalAuths)
|
||||
}
|
||||
|
||||
if app.onUserBeforeUnlinkExternalAuthRequest != app.OnUserBeforeUnlinkExternalAuthRequest() || app.OnUserBeforeUnlinkExternalAuthRequest() == nil {
|
||||
t.Fatalf("Getter app.OnUserBeforeUnlinkExternalAuthRequest does not match or nil (%v vs %v)", app.OnUserBeforeUnlinkExternalAuthRequest(), app.onUserBeforeUnlinkExternalAuthRequest)
|
||||
if app.onRecordBeforeUnlinkExternalAuthRequest != app.OnRecordBeforeUnlinkExternalAuthRequest() || app.OnRecordBeforeUnlinkExternalAuthRequest() == nil {
|
||||
t.Fatalf("Getter app.OnRecordBeforeUnlinkExternalAuthRequest does not match or nil (%v vs %v)", app.OnRecordBeforeUnlinkExternalAuthRequest(), app.onRecordBeforeUnlinkExternalAuthRequest)
|
||||
}
|
||||
|
||||
if app.onUserAfterUnlinkExternalAuthRequest != app.OnUserAfterUnlinkExternalAuthRequest() || app.OnUserAfterUnlinkExternalAuthRequest() == nil {
|
||||
t.Fatalf("Getter app.OnUserAfterUnlinkExternalAuthRequest does not match or nil (%v vs %v)", app.OnUserAfterUnlinkExternalAuthRequest(), app.onUserAfterUnlinkExternalAuthRequest)
|
||||
if app.onRecordAfterUnlinkExternalAuthRequest != app.OnRecordAfterUnlinkExternalAuthRequest() || app.OnRecordAfterUnlinkExternalAuthRequest() == nil {
|
||||
t.Fatalf("Getter app.OnRecordAfterUnlinkExternalAuthRequest does not match or nil (%v vs %v)", app.OnRecordAfterUnlinkExternalAuthRequest(), app.onRecordAfterUnlinkExternalAuthRequest)
|
||||
}
|
||||
|
||||
if app.onRecordsListRequest != app.OnRecordsListRequest() || app.OnRecordsListRequest() == nil {
|
||||
|
@ -33,9 +33,9 @@ type ModelEvent struct {
|
||||
// Mailer events data
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
type MailerUserEvent struct {
|
||||
type MailerRecordEvent struct {
|
||||
MailClient mailer.Mailer
|
||||
User *models.User
|
||||
Record *models.Record
|
||||
Meta map[string]any
|
||||
}
|
||||
|
||||
@ -143,51 +143,25 @@ type AdminAuthEvent struct {
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// User API events data
|
||||
// Auth Record API events data
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
type UsersListEvent struct {
|
||||
type RecordAuthEvent struct {
|
||||
HttpContext echo.Context
|
||||
Users []*models.User
|
||||
Result *search.Result
|
||||
}
|
||||
|
||||
type UserViewEvent struct {
|
||||
HttpContext echo.Context
|
||||
User *models.User
|
||||
}
|
||||
|
||||
type UserCreateEvent struct {
|
||||
HttpContext echo.Context
|
||||
User *models.User
|
||||
}
|
||||
|
||||
type UserUpdateEvent struct {
|
||||
HttpContext echo.Context
|
||||
User *models.User
|
||||
}
|
||||
|
||||
type UserDeleteEvent struct {
|
||||
HttpContext echo.Context
|
||||
User *models.User
|
||||
}
|
||||
|
||||
type UserAuthEvent struct {
|
||||
HttpContext echo.Context
|
||||
User *models.User
|
||||
Record *models.Record
|
||||
Token string
|
||||
Meta any
|
||||
}
|
||||
|
||||
type UserListExternalAuthsEvent struct {
|
||||
type RecordListExternalAuthsEvent struct {
|
||||
HttpContext echo.Context
|
||||
User *models.User
|
||||
Record *models.Record
|
||||
ExternalAuths []*models.ExternalAuth
|
||||
}
|
||||
|
||||
type UserUnlinkExternalAuthEvent struct {
|
||||
type RecordUnlinkExternalAuthEvent struct {
|
||||
HttpContext echo.Context
|
||||
User *models.User
|
||||
Record *models.Record
|
||||
ExternalAuth *models.ExternalAuth
|
||||
}
|
||||
|
||||
|
123
core/settings.go
123
core/settings.go
@ -23,14 +23,16 @@ type Settings struct {
|
||||
Smtp SmtpConfig `form:"smtp" json:"smtp"`
|
||||
S3 S3Config `form:"s3" json:"s3"`
|
||||
|
||||
AdminAuthToken TokenConfig `form:"adminAuthToken" json:"adminAuthToken"`
|
||||
AdminPasswordResetToken TokenConfig `form:"adminPasswordResetToken" json:"adminPasswordResetToken"`
|
||||
UserAuthToken TokenConfig `form:"userAuthToken" json:"userAuthToken"`
|
||||
UserPasswordResetToken TokenConfig `form:"userPasswordResetToken" json:"userPasswordResetToken"`
|
||||
UserEmailChangeToken TokenConfig `form:"userEmailChangeToken" json:"userEmailChangeToken"`
|
||||
UserVerificationToken TokenConfig `form:"userVerificationToken" json:"userVerificationToken"`
|
||||
AdminAuthToken TokenConfig `form:"adminAuthToken" json:"adminAuthToken"`
|
||||
AdminPasswordResetToken TokenConfig `form:"adminPasswordResetToken" json:"adminPasswordResetToken"`
|
||||
RecordAuthToken TokenConfig `form:"recordAuthToken" json:"recordAuthToken"`
|
||||
RecordPasswordResetToken TokenConfig `form:"recordPasswordResetToken" json:"recordPasswordResetToken"`
|
||||
RecordEmailChangeToken TokenConfig `form:"recordEmailChangeToken" json:"recordEmailChangeToken"`
|
||||
RecordVerificationToken TokenConfig `form:"recordVerificationToken" json:"recordVerificationToken"`
|
||||
|
||||
// Deprecated: Will be removed in v0.9!
|
||||
EmailAuth EmailAuthConfig `form:"emailAuth" json:"emailAuth"`
|
||||
|
||||
EmailAuth EmailAuthConfig `form:"emailAuth" json:"emailAuth"`
|
||||
GoogleAuth AuthProviderConfig `form:"googleAuth" json:"googleAuth"`
|
||||
FacebookAuth AuthProviderConfig `form:"facebookAuth" json:"facebookAuth"`
|
||||
GithubAuth AuthProviderConfig `form:"githubAuth" json:"githubAuth"`
|
||||
@ -52,9 +54,8 @@ func NewSettings() *Settings {
|
||||
ResetPasswordTemplate: defaultResetPasswordTemplate,
|
||||
ConfirmEmailChangeTemplate: defaultConfirmEmailChangeTemplate,
|
||||
},
|
||||
|
||||
Logs: LogsConfig{
|
||||
MaxDays: 7,
|
||||
MaxDays: 5,
|
||||
},
|
||||
Smtp: SmtpConfig{
|
||||
Enabled: false,
|
||||
@ -72,49 +73,39 @@ func NewSettings() *Settings {
|
||||
Secret: security.RandomString(50),
|
||||
Duration: 1800, // 30 minutes,
|
||||
},
|
||||
UserAuthToken: TokenConfig{
|
||||
RecordAuthToken: TokenConfig{
|
||||
Secret: security.RandomString(50),
|
||||
Duration: 1209600, // 14 days,
|
||||
},
|
||||
UserPasswordResetToken: TokenConfig{
|
||||
RecordPasswordResetToken: TokenConfig{
|
||||
Secret: security.RandomString(50),
|
||||
Duration: 1800, // 30 minutes,
|
||||
},
|
||||
UserVerificationToken: TokenConfig{
|
||||
RecordVerificationToken: TokenConfig{
|
||||
Secret: security.RandomString(50),
|
||||
Duration: 604800, // 7 days,
|
||||
},
|
||||
UserEmailChangeToken: TokenConfig{
|
||||
RecordEmailChangeToken: TokenConfig{
|
||||
Secret: security.RandomString(50),
|
||||
Duration: 1800, // 30 minutes,
|
||||
},
|
||||
EmailAuth: EmailAuthConfig{
|
||||
Enabled: true,
|
||||
MinPasswordLength: 8,
|
||||
},
|
||||
GoogleAuth: AuthProviderConfig{
|
||||
Enabled: false,
|
||||
AllowRegistrations: true,
|
||||
Enabled: false,
|
||||
},
|
||||
FacebookAuth: AuthProviderConfig{
|
||||
Enabled: false,
|
||||
AllowRegistrations: true,
|
||||
Enabled: false,
|
||||
},
|
||||
GithubAuth: AuthProviderConfig{
|
||||
Enabled: false,
|
||||
AllowRegistrations: true,
|
||||
Enabled: false,
|
||||
},
|
||||
GitlabAuth: AuthProviderConfig{
|
||||
Enabled: false,
|
||||
AllowRegistrations: true,
|
||||
Enabled: false,
|
||||
},
|
||||
DiscordAuth: AuthProviderConfig{
|
||||
Enabled: false,
|
||||
AllowRegistrations: true,
|
||||
Enabled: false,
|
||||
},
|
||||
TwitterAuth: AuthProviderConfig{
|
||||
Enabled: false,
|
||||
AllowRegistrations: true,
|
||||
Enabled: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
@ -129,13 +120,12 @@ func (s *Settings) Validate() error {
|
||||
validation.Field(&s.Logs),
|
||||
validation.Field(&s.AdminAuthToken),
|
||||
validation.Field(&s.AdminPasswordResetToken),
|
||||
validation.Field(&s.UserAuthToken),
|
||||
validation.Field(&s.UserPasswordResetToken),
|
||||
validation.Field(&s.UserEmailChangeToken),
|
||||
validation.Field(&s.UserVerificationToken),
|
||||
validation.Field(&s.RecordAuthToken),
|
||||
validation.Field(&s.RecordPasswordResetToken),
|
||||
validation.Field(&s.RecordEmailChangeToken),
|
||||
validation.Field(&s.RecordVerificationToken),
|
||||
validation.Field(&s.Smtp),
|
||||
validation.Field(&s.S3),
|
||||
validation.Field(&s.EmailAuth),
|
||||
validation.Field(&s.GoogleAuth),
|
||||
validation.Field(&s.FacebookAuth),
|
||||
validation.Field(&s.GithubAuth),
|
||||
@ -182,10 +172,10 @@ func (s *Settings) RedactClone() (*Settings, error) {
|
||||
&clone.S3.Secret,
|
||||
&clone.AdminAuthToken.Secret,
|
||||
&clone.AdminPasswordResetToken.Secret,
|
||||
&clone.UserAuthToken.Secret,
|
||||
&clone.UserPasswordResetToken.Secret,
|
||||
&clone.UserEmailChangeToken.Secret,
|
||||
&clone.UserVerificationToken.Secret,
|
||||
&clone.RecordAuthToken.Secret,
|
||||
&clone.RecordPasswordResetToken.Secret,
|
||||
&clone.RecordEmailChangeToken.Secret,
|
||||
&clone.RecordVerificationToken.Secret,
|
||||
&clone.GoogleAuth.ClientSecret,
|
||||
&clone.FacebookAuth.ClientSecret,
|
||||
&clone.GithubAuth.ClientSecret,
|
||||
@ -407,43 +397,13 @@ func (c LogsConfig) Validate() error {
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
type EmailAuthConfig struct {
|
||||
Enabled bool `form:"enabled" json:"enabled"`
|
||||
ExceptDomains []string `form:"exceptDomains" json:"exceptDomains"`
|
||||
OnlyDomains []string `form:"onlyDomains" json:"onlyDomains"`
|
||||
MinPasswordLength int `form:"minPasswordLength" json:"minPasswordLength"`
|
||||
}
|
||||
|
||||
// Validate makes `EmailAuthConfig` validatable by implementing [validation.Validatable] interface.
|
||||
func (c EmailAuthConfig) Validate() error {
|
||||
return validation.ValidateStruct(&c,
|
||||
validation.Field(
|
||||
&c.ExceptDomains,
|
||||
validation.When(len(c.OnlyDomains) > 0, validation.Empty).Else(validation.Each(is.Domain)),
|
||||
),
|
||||
validation.Field(
|
||||
&c.OnlyDomains,
|
||||
validation.When(len(c.ExceptDomains) > 0, validation.Empty).Else(validation.Each(is.Domain)),
|
||||
),
|
||||
validation.Field(
|
||||
&c.MinPasswordLength,
|
||||
validation.When(c.Enabled, validation.Required),
|
||||
validation.Min(5),
|
||||
validation.Max(100),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
type AuthProviderConfig struct {
|
||||
Enabled bool `form:"enabled" json:"enabled"`
|
||||
AllowRegistrations bool `form:"allowRegistrations" json:"allowRegistrations"`
|
||||
ClientId string `form:"clientId" json:"clientId,omitempty"`
|
||||
ClientSecret string `form:"clientSecret" json:"clientSecret,omitempty"`
|
||||
AuthUrl string `form:"authUrl" json:"authUrl,omitempty"`
|
||||
TokenUrl string `form:"tokenUrl" json:"tokenUrl,omitempty"`
|
||||
UserApiUrl string `form:"userApiUrl" json:"userApiUrl,omitempty"`
|
||||
Enabled bool `form:"enabled" json:"enabled"`
|
||||
ClientId string `form:"clientId" json:"clientId,omitempty"`
|
||||
ClientSecret string `form:"clientSecret" json:"clientSecret,omitempty"`
|
||||
AuthUrl string `form:"authUrl" json:"authUrl,omitempty"`
|
||||
TokenUrl string `form:"tokenUrl" json:"tokenUrl,omitempty"`
|
||||
UserApiUrl string `form:"userApiUrl" json:"userApiUrl,omitempty"`
|
||||
}
|
||||
|
||||
// Validate makes `ProviderConfig` validatable by implementing [validation.Validatable] interface.
|
||||
@ -485,3 +445,18 @@ func (c AuthProviderConfig) SetupProvider(provider auth.Provider) error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
// Deprecated: Will be removed in v0.9!
|
||||
type EmailAuthConfig struct {
|
||||
Enabled bool `form:"enabled" json:"enabled"`
|
||||
ExceptDomains []string `form:"exceptDomains" json:"exceptDomains"`
|
||||
OnlyDomains []string `form:"onlyDomains" json:"onlyDomains"`
|
||||
MinPasswordLength int `form:"minPasswordLength" json:"minPasswordLength"`
|
||||
}
|
||||
|
||||
// Deprecated: Will be removed in v0.9!
|
||||
func (c EmailAuthConfig) Validate() error {
|
||||
return nil
|
||||
}
|
||||
|
@ -20,7 +20,7 @@ var defaultVerificationTemplate = EmailTemplate{
|
||||
Thanks,<br/>
|
||||
` + EmailPlaceholderAppName + ` team
|
||||
</p>`,
|
||||
ActionUrl: EmailPlaceholderAppUrl + "/_/#/users/confirm-verification/" + EmailPlaceholderToken,
|
||||
ActionUrl: EmailPlaceholderAppUrl + "/_/#/auth/confirm-verification/" + EmailPlaceholderToken,
|
||||
}
|
||||
|
||||
var defaultResetPasswordTemplate = EmailTemplate{
|
||||
@ -35,7 +35,7 @@ var defaultResetPasswordTemplate = EmailTemplate{
|
||||
Thanks,<br/>
|
||||
` + EmailPlaceholderAppName + ` team
|
||||
</p>`,
|
||||
ActionUrl: EmailPlaceholderAppUrl + "/_/#/users/confirm-password-reset/" + EmailPlaceholderToken,
|
||||
ActionUrl: EmailPlaceholderAppUrl + "/_/#/auth/confirm-password-reset/" + EmailPlaceholderToken,
|
||||
}
|
||||
|
||||
var defaultConfirmEmailChangeTemplate = EmailTemplate{
|
||||
@ -50,5 +50,5 @@ var defaultConfirmEmailChangeTemplate = EmailTemplate{
|
||||
Thanks,<br/>
|
||||
` + EmailPlaceholderAppName + ` team
|
||||
</p>`,
|
||||
ActionUrl: EmailPlaceholderAppUrl + "/_/#/users/confirm-email-change/" + EmailPlaceholderToken,
|
||||
ActionUrl: EmailPlaceholderAppUrl + "/_/#/auth/confirm-email-change/" + EmailPlaceholderToken,
|
||||
}
|
||||
|
@ -23,12 +23,10 @@ func TestSettingsValidate(t *testing.T) {
|
||||
s.S3.Endpoint = "invalid"
|
||||
s.AdminAuthToken.Duration = -10
|
||||
s.AdminPasswordResetToken.Duration = -10
|
||||
s.UserAuthToken.Duration = -10
|
||||
s.UserPasswordResetToken.Duration = -10
|
||||
s.UserEmailChangeToken.Duration = -10
|
||||
s.UserVerificationToken.Duration = -10
|
||||
s.EmailAuth.Enabled = true
|
||||
s.EmailAuth.MinPasswordLength = -10
|
||||
s.RecordAuthToken.Duration = -10
|
||||
s.RecordPasswordResetToken.Duration = -10
|
||||
s.RecordEmailChangeToken.Duration = -10
|
||||
s.RecordVerificationToken.Duration = -10
|
||||
s.GoogleAuth.Enabled = true
|
||||
s.GoogleAuth.ClientId = ""
|
||||
s.FacebookAuth.Enabled = true
|
||||
@ -55,16 +53,16 @@ func TestSettingsValidate(t *testing.T) {
|
||||
`"s3":{`,
|
||||
`"adminAuthToken":{`,
|
||||
`"adminPasswordResetToken":{`,
|
||||
`"userAuthToken":{`,
|
||||
`"userPasswordResetToken":{`,
|
||||
`"userEmailChangeToken":{`,
|
||||
`"userVerificationToken":{`,
|
||||
`"emailAuth":{`,
|
||||
`"recordAuthToken":{`,
|
||||
`"recordPasswordResetToken":{`,
|
||||
`"recordEmailChangeToken":{`,
|
||||
`"recordVerificationToken":{`,
|
||||
`"googleAuth":{`,
|
||||
`"facebookAuth":{`,
|
||||
`"githubAuth":{`,
|
||||
`"gitlabAuth":{`,
|
||||
`"discordAuth":{`,
|
||||
`"twitterAuth":{`,
|
||||
}
|
||||
|
||||
errBytes, _ := json.Marshal(err)
|
||||
@ -89,12 +87,10 @@ func TestSettingsMerge(t *testing.T) {
|
||||
s2.S3.Endpoint = "test"
|
||||
s2.AdminAuthToken.Duration = 1
|
||||
s2.AdminPasswordResetToken.Duration = 2
|
||||
s2.UserAuthToken.Duration = 3
|
||||
s2.UserPasswordResetToken.Duration = 4
|
||||
s2.UserEmailChangeToken.Duration = 5
|
||||
s2.UserVerificationToken.Duration = 6
|
||||
s2.EmailAuth.Enabled = false
|
||||
s2.EmailAuth.MinPasswordLength = 30
|
||||
s2.RecordAuthToken.Duration = 3
|
||||
s2.RecordPasswordResetToken.Duration = 4
|
||||
s2.RecordEmailChangeToken.Duration = 5
|
||||
s2.RecordVerificationToken.Duration = 6
|
||||
s2.GoogleAuth.Enabled = true
|
||||
s2.GoogleAuth.ClientId = "google_test"
|
||||
s2.FacebookAuth.Enabled = true
|
||||
@ -164,10 +160,10 @@ func TestSettingsRedactClone(t *testing.T) {
|
||||
s1.S3.Secret = "test123"
|
||||
s1.AdminAuthToken.Secret = "test123"
|
||||
s1.AdminPasswordResetToken.Secret = "test123"
|
||||
s1.UserAuthToken.Secret = "test123"
|
||||
s1.UserPasswordResetToken.Secret = "test123"
|
||||
s1.UserEmailChangeToken.Secret = "test123"
|
||||
s1.UserVerificationToken.Secret = "test123"
|
||||
s1.RecordAuthToken.Secret = "test123"
|
||||
s1.RecordPasswordResetToken.Secret = "test123"
|
||||
s1.RecordEmailChangeToken.Secret = "test123"
|
||||
s1.RecordVerificationToken.Secret = "test123"
|
||||
s1.GoogleAuth.ClientSecret = "test123"
|
||||
s1.FacebookAuth.ClientSecret = "test123"
|
||||
s1.GithubAuth.ClientSecret = "test123"
|
||||
@ -185,10 +181,10 @@ func TestSettingsRedactClone(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
expected := `{"meta":{"appName":"test123","appUrl":"http://localhost:8090","hideControls":false,"senderName":"Support","senderAddress":"support@example.com","verificationTemplate":{"body":"\u003cp\u003eHello,\u003c/p\u003e\n\u003cp\u003eThank you for joining us at {APP_NAME}.\u003c/p\u003e\n\u003cp\u003eClick on the button below to verify your email address.\u003c/p\u003e\n\u003cp\u003e\n \u003ca class=\"btn\" href=\"{ACTION_URL}\" target=\"_blank\" rel=\"noopener\"\u003eVerify\u003c/a\u003e\n\u003c/p\u003e\n\u003cp\u003e\n Thanks,\u003cbr/\u003e\n {APP_NAME} team\n\u003c/p\u003e","subject":"Verify your {APP_NAME} email","actionUrl":"{APP_URL}/_/#/users/confirm-verification/{TOKEN}"},"resetPasswordTemplate":{"body":"\u003cp\u003eHello,\u003c/p\u003e\n\u003cp\u003eClick on the button below to reset your password.\u003c/p\u003e\n\u003cp\u003e\n \u003ca class=\"btn\" href=\"{ACTION_URL}\" target=\"_blank\" rel=\"noopener\"\u003eReset password\u003c/a\u003e\n\u003c/p\u003e\n\u003cp\u003e\u003ci\u003eIf you didn't ask to reset your password, you can ignore this email.\u003c/i\u003e\u003c/p\u003e\n\u003cp\u003e\n Thanks,\u003cbr/\u003e\n {APP_NAME} team\n\u003c/p\u003e","subject":"Reset your {APP_NAME} password","actionUrl":"{APP_URL}/_/#/users/confirm-password-reset/{TOKEN}"},"confirmEmailChangeTemplate":{"body":"\u003cp\u003eHello,\u003c/p\u003e\n\u003cp\u003eClick on the button below to confirm your new email address.\u003c/p\u003e\n\u003cp\u003e\n \u003ca class=\"btn\" href=\"{ACTION_URL}\" target=\"_blank\" rel=\"noopener\"\u003eConfirm new email\u003c/a\u003e\n\u003c/p\u003e\n\u003cp\u003e\u003ci\u003eIf you didn't ask to change your email address, you can ignore this email.\u003c/i\u003e\u003c/p\u003e\n\u003cp\u003e\n Thanks,\u003cbr/\u003e\n {APP_NAME} team\n\u003c/p\u003e","subject":"Confirm your {APP_NAME} new email address","actionUrl":"{APP_URL}/_/#/users/confirm-email-change/{TOKEN}"}},"logs":{"maxDays":7},"smtp":{"enabled":false,"host":"smtp.example.com","port":587,"username":"","password":"******","tls":true},"s3":{"enabled":false,"bucket":"","region":"","endpoint":"","accessKey":"","secret":"******","forcePathStyle":false},"adminAuthToken":{"secret":"******","duration":1209600},"adminPasswordResetToken":{"secret":"******","duration":1800},"userAuthToken":{"secret":"******","duration":1209600},"userPasswordResetToken":{"secret":"******","duration":1800},"userEmailChangeToken":{"secret":"******","duration":1800},"userVerificationToken":{"secret":"******","duration":604800},"emailAuth":{"enabled":true,"exceptDomains":null,"onlyDomains":null,"minPasswordLength":8},"googleAuth":{"enabled":false,"allowRegistrations":true,"clientSecret":"******"},"facebookAuth":{"enabled":false,"allowRegistrations":true,"clientSecret":"******"},"githubAuth":{"enabled":false,"allowRegistrations":true,"clientSecret":"******"},"gitlabAuth":{"enabled":false,"allowRegistrations":true,"clientSecret":"******"},"discordAuth":{"enabled":false,"allowRegistrations":true,"clientSecret":"******"},"twitterAuth":{"enabled":false,"allowRegistrations":true,"clientSecret":"******"}}`
|
||||
expected := `{"meta":{"appName":"test123","appUrl":"http://localhost:8090","hideControls":false,"senderName":"Support","senderAddress":"support@example.com","verificationTemplate":{"body":"\u003cp\u003eHello,\u003c/p\u003e\n\u003cp\u003eThank you for joining us at {APP_NAME}.\u003c/p\u003e\n\u003cp\u003eClick on the button below to verify your email address.\u003c/p\u003e\n\u003cp\u003e\n \u003ca class=\"btn\" href=\"{ACTION_URL}\" target=\"_blank\" rel=\"noopener\"\u003eVerify\u003c/a\u003e\n\u003c/p\u003e\n\u003cp\u003e\n Thanks,\u003cbr/\u003e\n {APP_NAME} team\n\u003c/p\u003e","subject":"Verify your {APP_NAME} email","actionUrl":"{APP_URL}/_/#/auth/confirm-verification/{TOKEN}"},"resetPasswordTemplate":{"body":"\u003cp\u003eHello,\u003c/p\u003e\n\u003cp\u003eClick on the button below to reset your password.\u003c/p\u003e\n\u003cp\u003e\n \u003ca class=\"btn\" href=\"{ACTION_URL}\" target=\"_blank\" rel=\"noopener\"\u003eReset password\u003c/a\u003e\n\u003c/p\u003e\n\u003cp\u003e\u003ci\u003eIf you didn't ask to reset your password, you can ignore this email.\u003c/i\u003e\u003c/p\u003e\n\u003cp\u003e\n Thanks,\u003cbr/\u003e\n {APP_NAME} team\n\u003c/p\u003e","subject":"Reset your {APP_NAME} password","actionUrl":"{APP_URL}/_/#/auth/confirm-password-reset/{TOKEN}"},"confirmEmailChangeTemplate":{"body":"\u003cp\u003eHello,\u003c/p\u003e\n\u003cp\u003eClick on the button below to confirm your new email address.\u003c/p\u003e\n\u003cp\u003e\n \u003ca class=\"btn\" href=\"{ACTION_URL}\" target=\"_blank\" rel=\"noopener\"\u003eConfirm new email\u003c/a\u003e\n\u003c/p\u003e\n\u003cp\u003e\u003ci\u003eIf you didn't ask to change your email address, you can ignore this email.\u003c/i\u003e\u003c/p\u003e\n\u003cp\u003e\n Thanks,\u003cbr/\u003e\n {APP_NAME} team\n\u003c/p\u003e","subject":"Confirm your {APP_NAME} new email address","actionUrl":"{APP_URL}/_/#/auth/confirm-email-change/{TOKEN}"}},"logs":{"maxDays":5},"smtp":{"enabled":false,"host":"smtp.example.com","port":587,"username":"","password":"******","tls":true},"s3":{"enabled":false,"bucket":"","region":"","endpoint":"","accessKey":"","secret":"******","forcePathStyle":false},"adminAuthToken":{"secret":"******","duration":1209600},"adminPasswordResetToken":{"secret":"******","duration":1800},"recordAuthToken":{"secret":"******","duration":1209600},"recordPasswordResetToken":{"secret":"******","duration":1800},"recordEmailChangeToken":{"secret":"******","duration":1800},"recordVerificationToken":{"secret":"******","duration":604800},"emailAuth":{"enabled":false,"exceptDomains":null,"onlyDomains":null,"minPasswordLength":0},"googleAuth":{"enabled":false,"clientSecret":"******"},"facebookAuth":{"enabled":false,"clientSecret":"******"},"githubAuth":{"enabled":false,"clientSecret":"******"},"gitlabAuth":{"enabled":false,"clientSecret":"******"},"discordAuth":{"enabled":false,"clientSecret":"******"},"twitterAuth":{"enabled":false,"clientSecret":"******"}}`
|
||||
|
||||
if encodedStr := string(encoded); encodedStr != expected {
|
||||
t.Fatalf("Expected %v, got \n%v", expected, encodedStr)
|
||||
t.Fatalf("Expected\n%v\ngot\n%v", expected, encodedStr)
|
||||
}
|
||||
}
|
||||
|
||||
@ -210,10 +206,10 @@ func TestNamedAuthProviderConfigs(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
expected := `{"discord":{"enabled":false,"allowRegistrations":true,"clientId":"discord_test"},"facebook":{"enabled":false,"allowRegistrations":true,"clientId":"facebook_test"},"github":{"enabled":false,"allowRegistrations":true,"clientId":"github_test"},"gitlab":{"enabled":true,"allowRegistrations":true,"clientId":"gitlab_test"},"google":{"enabled":false,"allowRegistrations":true,"clientId":"google_test"},"twitter":{"enabled":false,"allowRegistrations":true,"clientId":"twitter_test"}}`
|
||||
expected := `{"discord":{"enabled":false,"clientId":"discord_test"},"facebook":{"enabled":false,"clientId":"facebook_test"},"github":{"enabled":false,"clientId":"github_test"},"gitlab":{"enabled":true,"clientId":"gitlab_test"},"google":{"enabled":false,"clientId":"google_test"},"twitter":{"enabled":false,"clientId":"twitter_test"}}`
|
||||
|
||||
if encodedStr := string(encoded); encodedStr != expected {
|
||||
t.Fatalf("Expected the same serialization, got %v", encodedStr)
|
||||
t.Fatalf("Expected the same serialization, got \n%v", encodedStr)
|
||||
}
|
||||
}
|
||||
|
||||
@ -701,83 +697,24 @@ func TestAuthProviderConfigSetupProvider(t *testing.T) {
|
||||
if err := c2.SetupProvider(provider); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
encoded, _ := json.Marshal(c2)
|
||||
expected := `{"enabled":true,"allowRegistrations":false,"clientId":"test_ClientId","clientSecret":"test_ClientSecret","authUrl":"test_AuthUrl","tokenUrl":"test_TokenUrl","userApiUrl":"test_UserApiUrl"}`
|
||||
if string(encoded) != expected {
|
||||
t.Errorf("Expected %s, got %s", expected, string(encoded))
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmailAuthConfigValidate(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
config core.EmailAuthConfig
|
||||
expectError bool
|
||||
}{
|
||||
// zero values (disabled)
|
||||
{
|
||||
core.EmailAuthConfig{},
|
||||
false,
|
||||
},
|
||||
// zero values (enabled)
|
||||
{
|
||||
core.EmailAuthConfig{Enabled: true},
|
||||
true,
|
||||
},
|
||||
// invalid data (only the required)
|
||||
{
|
||||
core.EmailAuthConfig{
|
||||
Enabled: true,
|
||||
MinPasswordLength: 4,
|
||||
},
|
||||
true,
|
||||
},
|
||||
// valid data (only the required)
|
||||
{
|
||||
core.EmailAuthConfig{
|
||||
Enabled: true,
|
||||
MinPasswordLength: 5,
|
||||
},
|
||||
false,
|
||||
},
|
||||
// invalid data (both OnlyDomains and ExceptDomains set)
|
||||
{
|
||||
core.EmailAuthConfig{
|
||||
Enabled: true,
|
||||
MinPasswordLength: 5,
|
||||
OnlyDomains: []string{"example.com", "test.com"},
|
||||
ExceptDomains: []string{"example.com", "test.com"},
|
||||
},
|
||||
true,
|
||||
},
|
||||
// valid data (only onlyDomains set)
|
||||
{
|
||||
core.EmailAuthConfig{
|
||||
Enabled: true,
|
||||
MinPasswordLength: 5,
|
||||
OnlyDomains: []string{"example.com", "test.com"},
|
||||
},
|
||||
false,
|
||||
},
|
||||
// valid data (only exceptDomains set)
|
||||
{
|
||||
core.EmailAuthConfig{
|
||||
Enabled: true,
|
||||
MinPasswordLength: 5,
|
||||
ExceptDomains: []string{"example.com", "test.com"},
|
||||
},
|
||||
false,
|
||||
},
|
||||
}
|
||||
|
||||
for i, scenario := range scenarios {
|
||||
result := scenario.config.Validate()
|
||||
|
||||
if result != nil && !scenario.expectError {
|
||||
t.Errorf("(%d) Didn't expect error, got %v", i, result)
|
||||
}
|
||||
|
||||
if result == nil && scenario.expectError {
|
||||
t.Errorf("(%d) Expected error, got nil", i)
|
||||
}
|
||||
|
||||
if provider.ClientId() != c2.ClientId {
|
||||
t.Fatalf("Expected ClientId %s, got %s", c2.ClientId, provider.ClientId())
|
||||
}
|
||||
|
||||
if provider.ClientSecret() != c2.ClientSecret {
|
||||
t.Fatalf("Expected ClientSecret %s, got %s", c2.ClientSecret, provider.ClientSecret())
|
||||
}
|
||||
|
||||
if provider.AuthUrl() != c2.AuthUrl {
|
||||
t.Fatalf("Expected AuthUrl %s, got %s", c2.AuthUrl, provider.AuthUrl())
|
||||
}
|
||||
|
||||
if provider.UserApiUrl() != c2.UserApiUrl {
|
||||
t.Fatalf("Expected UserApiUrl %s, got %s", c2.UserApiUrl, provider.UserApiUrl())
|
||||
}
|
||||
|
||||
if provider.TokenUrl() != c2.TokenUrl {
|
||||
t.Fatalf("Expected TokenUrl %s, got %s", c2.TokenUrl, provider.TokenUrl())
|
||||
}
|
||||
}
|
||||
|
@ -5,6 +5,7 @@ import (
|
||||
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/tools/list"
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
)
|
||||
|
||||
@ -49,6 +50,7 @@ func (dao *Dao) FindAdminByEmail(email string) (*models.Admin, error) {
|
||||
//
|
||||
// Returns an error if the JWT token is invalid or expired.
|
||||
func (dao *Dao) FindAdminByToken(token string, baseTokenKey string) (*models.Admin, error) {
|
||||
// @todo consider caching the unverified claims
|
||||
unverifiedClaims, err := security.ParseUnverifiedJWT(token)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@ -86,20 +88,22 @@ func (dao *Dao) TotalAdmins() (int, error) {
|
||||
|
||||
// IsAdminEmailUnique checks if the provided email address is not
|
||||
// already in use by other admins.
|
||||
func (dao *Dao) IsAdminEmailUnique(email string, excludeId string) bool {
|
||||
func (dao *Dao) IsAdminEmailUnique(email string, excludeIds ...string) bool {
|
||||
if email == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
var exists bool
|
||||
err := dao.AdminQuery().
|
||||
Select("count(*)").
|
||||
AndWhere(dbx.Not(dbx.HashExp{"id": excludeId})).
|
||||
query := dao.AdminQuery().Select("count(*)").
|
||||
AndWhere(dbx.HashExp{"email": email}).
|
||||
Limit(1).
|
||||
Row(&exists)
|
||||
Limit(1)
|
||||
|
||||
return err == nil && !exists
|
||||
if len(excludeIds) > 0 {
|
||||
query.AndWhere(dbx.NotIn("id", list.ToInterfaceSlice(excludeIds)...))
|
||||
}
|
||||
|
||||
var exists bool
|
||||
|
||||
return query.Row(&exists) == nil && !exists
|
||||
}
|
||||
|
||||
// DeleteAdmin deletes the provided Admin model.
|
||||
|
@ -27,8 +27,9 @@ func TestFindAdminById(t *testing.T) {
|
||||
id string
|
||||
expectError bool
|
||||
}{
|
||||
{"00000000-2b4a-a26b-4d01-42d3c3d77bc8", true},
|
||||
{"3f8397cc-2b4a-a26b-4d01-42d3c3d77bc8", false},
|
||||
{" ", true},
|
||||
{"missing", true},
|
||||
{"9q2trqumvlyr3bd", false},
|
||||
}
|
||||
|
||||
for i, scenario := range scenarios {
|
||||
@ -53,6 +54,7 @@ func TestFindAdminByEmail(t *testing.T) {
|
||||
email string
|
||||
expectError bool
|
||||
}{
|
||||
{"", true},
|
||||
{"invalid", true},
|
||||
{"missing@example.com", true},
|
||||
{"test@example.com", false},
|
||||
@ -83,23 +85,30 @@ func TestFindAdminByToken(t *testing.T) {
|
||||
expectedEmail string
|
||||
expectError bool
|
||||
}{
|
||||
// invalid base key (password reset key for auth token)
|
||||
// invalid auth token
|
||||
{
|
||||
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
|
||||
app.Settings().AdminPasswordResetToken.Secret,
|
||||
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTY0MDk5MTY2MX0.qrbkI2TITtFKMP6vrATrBVKPGjEiDIBeQ0mlqPGMVeY",
|
||||
app.Settings().AdminAuthToken.Secret,
|
||||
"",
|
||||
true,
|
||||
},
|
||||
// expired token
|
||||
{
|
||||
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTY0MDk5MTY2MX0.uXZ_ywsZeRFSvDNQ9zBoYUXKXw7VEr48Fzx-E06OkS8",
|
||||
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTY0MDk5MTY2MX0.I7w8iktkleQvC7_UIRpD7rNzcU4OnF7i7SFIUu6lD_4",
|
||||
app.Settings().AdminAuthToken.Secret,
|
||||
"",
|
||||
true,
|
||||
},
|
||||
// wrong base token (password reset token secret instead of auth secret)
|
||||
{
|
||||
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
app.Settings().AdminPasswordResetToken.Secret,
|
||||
"",
|
||||
true,
|
||||
},
|
||||
// valid token
|
||||
{
|
||||
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
|
||||
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImV4cCI6MjIwODk4NTI2MX0.M1m--VOqGyv0d23eeUc0r9xE8ZzHaYVmVFw1VZW6gT8",
|
||||
app.Settings().AdminAuthToken.Secret,
|
||||
"test@example.com",
|
||||
false,
|
||||
@ -129,8 +138,8 @@ func TestTotalAdmins(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result1 != 2 {
|
||||
t.Fatalf("Expected 2 admins, got %d", result1)
|
||||
if result1 != 3 {
|
||||
t.Fatalf("Expected 3 admins, got %d", result1)
|
||||
}
|
||||
|
||||
// delete all
|
||||
@ -156,8 +165,10 @@ func TestIsAdminEmailUnique(t *testing.T) {
|
||||
}{
|
||||
{"", "", false},
|
||||
{"test@example.com", "", false},
|
||||
{"test2@example.com", "", false},
|
||||
{"test3@example.com", "", false},
|
||||
{"new@example.com", "", true},
|
||||
{"test@example.com", "2b4a97cc-3f83-4d01-a26b-3d77bc842d3c", true},
|
||||
{"test@example.com", "sywbhecnh46rhm0", true},
|
||||
}
|
||||
|
||||
for i, scenario := range scenarios {
|
||||
@ -186,15 +197,24 @@ func TestDeleteAdmin(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
admin3, err := app.Dao().FindAdminByEmail("test3@example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
deleteErr1 := app.Dao().DeleteAdmin(admin1)
|
||||
if deleteErr1 != nil {
|
||||
t.Fatal(deleteErr1)
|
||||
}
|
||||
|
||||
// cannot delete the only remaining admin
|
||||
deleteErr2 := app.Dao().DeleteAdmin(admin2)
|
||||
if deleteErr2 == nil {
|
||||
if deleteErr2 != nil {
|
||||
t.Fatal(deleteErr2)
|
||||
}
|
||||
|
||||
// cannot delete the only remaining admin
|
||||
deleteErr3 := app.Dao().DeleteAdmin(admin3)
|
||||
if deleteErr3 == nil {
|
||||
t.Fatal("Expected delete error, got nil")
|
||||
}
|
||||
|
||||
|
@ -35,8 +35,8 @@ func TestDaoModelQuery(t *testing.T) {
|
||||
"SELECT {{_collections}}.* FROM `_collections`",
|
||||
},
|
||||
{
|
||||
&models.User{},
|
||||
"SELECT {{_users}}.* FROM `_users`",
|
||||
&models.Admin{},
|
||||
"SELECT {{_admins}}.* FROM `_admins`",
|
||||
},
|
||||
{
|
||||
&models.Request{},
|
||||
@ -64,19 +64,19 @@ func TestDaoFindById(t *testing.T) {
|
||||
// missing id
|
||||
{
|
||||
&models.Collection{},
|
||||
"00000000-075d-49fe-9d09-ea7e951000dc",
|
||||
"missing",
|
||||
true,
|
||||
},
|
||||
// existing collection id
|
||||
{
|
||||
&models.Collection{},
|
||||
"3f2888f8-075d-49fe-9d09-ea7e951000dc",
|
||||
"wsmn24bux7wo113",
|
||||
false,
|
||||
},
|
||||
// existing user id
|
||||
// existing admin id
|
||||
{
|
||||
&models.User{},
|
||||
"97cc3d3d-6ba2-383f-b42a-7bc84d27410c",
|
||||
&models.Admin{},
|
||||
"sbmbsdb40jyxf7h",
|
||||
false,
|
||||
},
|
||||
}
|
||||
|
@ -8,6 +8,7 @@ import (
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/models/schema"
|
||||
"github.com/pocketbase/pocketbase/tools/list"
|
||||
)
|
||||
|
||||
// CollectionQuery returns a new Collection select query.
|
||||
@ -15,6 +16,22 @@ func (dao *Dao) CollectionQuery() *dbx.SelectQuery {
|
||||
return dao.ModelQuery(&models.Collection{})
|
||||
}
|
||||
|
||||
// FindCollectionsByType finds all collections by the given type
|
||||
func (dao *Dao) FindCollectionsByType(collectionType string) ([]*models.Collection, error) {
|
||||
models := []*models.Collection{}
|
||||
|
||||
err := dao.CollectionQuery().
|
||||
AndWhere(dbx.HashExp{"type": collectionType}).
|
||||
OrderBy("created ASC").
|
||||
All(&models)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return models, nil
|
||||
}
|
||||
|
||||
// FindCollectionByNameOrId finds the first collection by its name or id.
|
||||
func (dao *Dao) FindCollectionByNameOrId(nameOrId string) (*models.Collection, error) {
|
||||
model := &models.Collection{}
|
||||
@ -38,38 +55,24 @@ func (dao *Dao) FindCollectionByNameOrId(nameOrId string) (*models.Collection, e
|
||||
// with the provided name (case insensitive!).
|
||||
//
|
||||
// Note: case sensitive check because the name is used also as a table name for the records.
|
||||
func (dao *Dao) IsCollectionNameUnique(name string, excludeId string) bool {
|
||||
func (dao *Dao) IsCollectionNameUnique(name string, excludeIds ...string) bool {
|
||||
if name == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
var exists bool
|
||||
err := dao.CollectionQuery().
|
||||
query := dao.CollectionQuery().
|
||||
Select("count(*)").
|
||||
AndWhere(dbx.Not(dbx.HashExp{"id": excludeId})).
|
||||
AndWhere(dbx.NewExp("LOWER([[name]])={:name}", dbx.Params{"name": strings.ToLower(name)})).
|
||||
Limit(1).
|
||||
Row(&exists)
|
||||
Limit(1)
|
||||
|
||||
return err == nil && !exists
|
||||
}
|
||||
if len(excludeIds) > 0 {
|
||||
uniqueExcludeIds := list.NonzeroUniques(excludeIds)
|
||||
query.AndWhere(dbx.NotIn("id", list.ToInterfaceSlice(uniqueExcludeIds)...))
|
||||
}
|
||||
|
||||
// FindCollectionsWithUserFields finds all collections that has
|
||||
// at least one user schema field.
|
||||
func (dao *Dao) FindCollectionsWithUserFields() ([]*models.Collection, error) {
|
||||
result := []*models.Collection{}
|
||||
var exists bool
|
||||
|
||||
err := dao.CollectionQuery().
|
||||
InnerJoin(
|
||||
"json_each(schema) as jsonField",
|
||||
dbx.NewExp(
|
||||
"json_extract(jsonField.value, '$.type') = {:type}",
|
||||
dbx.Params{"type": schema.FieldTypeUser},
|
||||
),
|
||||
).
|
||||
All(&result)
|
||||
|
||||
return result, err
|
||||
return query.Row(&exists) == nil && !exists
|
||||
}
|
||||
|
||||
// FindCollectionReferences returns information for all
|
||||
@ -78,13 +81,15 @@ func (dao *Dao) FindCollectionsWithUserFields() ([]*models.Collection, error) {
|
||||
// If the provided collection has reference to itself then it will be
|
||||
// also included in the result. To exclude it, pass the collection id
|
||||
// as the excludeId argument.
|
||||
func (dao *Dao) FindCollectionReferences(collection *models.Collection, excludeId string) (map[*models.Collection][]*schema.SchemaField, error) {
|
||||
func (dao *Dao) FindCollectionReferences(collection *models.Collection, excludeIds ...string) (map[*models.Collection][]*schema.SchemaField, error) {
|
||||
collections := []*models.Collection{}
|
||||
|
||||
err := dao.CollectionQuery().
|
||||
AndWhere(dbx.Not(dbx.HashExp{"id": excludeId})).
|
||||
All(&collections)
|
||||
if err != nil {
|
||||
query := dao.CollectionQuery()
|
||||
if len(excludeIds) > 0 {
|
||||
uniqueExcludeIds := list.NonzeroUniques(excludeIds)
|
||||
query.AndWhere(dbx.NotIn("id", list.ToInterfaceSlice(uniqueExcludeIds)...))
|
||||
}
|
||||
if err := query.All(&collections); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@ -152,6 +157,11 @@ func (dao *Dao) SaveCollection(collection *models.Collection) error {
|
||||
}
|
||||
|
||||
return dao.RunInTransaction(func(txDao *Dao) error {
|
||||
// set default collection type
|
||||
if collection.Type == "" {
|
||||
collection.Type = models.CollectionTypeBase
|
||||
}
|
||||
|
||||
// persist the collection model
|
||||
if err := txDao.Save(collection); err != nil {
|
||||
return err
|
||||
@ -196,6 +206,11 @@ func (dao *Dao) ImportCollections(
|
||||
imported.RefreshId()
|
||||
}
|
||||
|
||||
// set default type if missing
|
||||
if imported.Type == "" {
|
||||
imported.Type = models.CollectionTypeBase
|
||||
}
|
||||
|
||||
if existing, ok := mappedExisting[imported.GetId()]; ok {
|
||||
// preserve original created date
|
||||
if !existing.Created.IsZero() {
|
||||
|
@ -24,6 +24,41 @@ func TestCollectionQuery(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindCollectionsByType(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
scenarios := []struct {
|
||||
collectionType string
|
||||
expectError bool
|
||||
expectTotal int
|
||||
}{
|
||||
{"", false, 0},
|
||||
{"unknown", false, 0},
|
||||
{models.CollectionTypeAuth, false, 3},
|
||||
{models.CollectionTypeBase, false, 4},
|
||||
}
|
||||
|
||||
for i, scenario := range scenarios {
|
||||
collections, err := app.Dao().FindCollectionsByType(scenario.collectionType)
|
||||
|
||||
hasErr := err != nil
|
||||
if hasErr != scenario.expectError {
|
||||
t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, scenario.expectError, hasErr, err)
|
||||
}
|
||||
|
||||
if len(collections) != scenario.expectTotal {
|
||||
t.Errorf("(%d) Expected %d collections, got %d", i, scenario.expectTotal, len(collections))
|
||||
}
|
||||
|
||||
for _, c := range collections {
|
||||
if c.Type != scenario.collectionType {
|
||||
t.Errorf("(%d) Expected collection with type %s, got %s: \n%v", i, scenario.collectionType, c.Type, c)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindCollectionByNameOrId(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
@ -34,9 +69,8 @@ func TestFindCollectionByNameOrId(t *testing.T) {
|
||||
}{
|
||||
{"", true},
|
||||
{"missing", true},
|
||||
{"00000000-075d-49fe-9d09-ea7e951000dc", true},
|
||||
{"3f2888f8-075d-49fe-9d09-ea7e951000dc", false},
|
||||
{"demo", false},
|
||||
{"wsmn24bux7wo113", false},
|
||||
{"demo1", false},
|
||||
}
|
||||
|
||||
for i, scenario := range scenarios {
|
||||
@ -63,9 +97,10 @@ func TestIsCollectionNameUnique(t *testing.T) {
|
||||
expected bool
|
||||
}{
|
||||
{"", "", false},
|
||||
{"demo", "", false},
|
||||
{"demo1", "", false},
|
||||
{"Demo1", "", false},
|
||||
{"new", "", true},
|
||||
{"demo", "3f2888f8-075d-49fe-9d09-ea7e951000dc", true},
|
||||
{"demo1", "wsmn24bux7wo113", true},
|
||||
}
|
||||
|
||||
for i, scenario := range scenarios {
|
||||
@ -76,33 +111,11 @@ func TestIsCollectionNameUnique(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindCollectionsWithUserFields(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
result, err := app.Dao().FindCollectionsWithUserFields()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
expectedNames := []string{"demo2", models.ProfileCollectionName}
|
||||
|
||||
if len(result) != len(expectedNames) {
|
||||
t.Fatalf("Expected collections %v, got %v", expectedNames, result)
|
||||
}
|
||||
|
||||
for i, col := range result {
|
||||
if !list.ExistInSlice(col.Name, expectedNames) {
|
||||
t.Errorf("(%d) Couldn't find %s in %v", i, col.Name, expectedNames)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindCollectionReferences(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
collection, err := app.Dao().FindCollectionByNameOrId("demo")
|
||||
collection, err := app.Dao().FindCollectionByNameOrId("demo3")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@ -116,11 +129,18 @@ func TestFindCollectionReferences(t *testing.T) {
|
||||
t.Fatalf("Expected 1 collection, got %d: %v", len(result), result)
|
||||
}
|
||||
|
||||
expectedFields := []string{"onerel", "manyrels", "cascaderel"}
|
||||
expectedFields := []string{
|
||||
"rel_one_no_cascade",
|
||||
"rel_one_no_cascade_required",
|
||||
"rel_one_cascade",
|
||||
"rel_many_no_cascade",
|
||||
"rel_many_no_cascade_required",
|
||||
"rel_many_cascade",
|
||||
}
|
||||
|
||||
for col, fields := range result {
|
||||
if col.Name != "demo2" {
|
||||
t.Fatalf("Expected collection demo2, got %s", col.Name)
|
||||
if col.Name != "demo4" {
|
||||
t.Fatalf("Expected collection demo4, got %s", col.Name)
|
||||
}
|
||||
if len(fields) != len(expectedFields) {
|
||||
t.Fatalf("Expected fields %v, got %v", expectedFields, fields)
|
||||
@ -138,7 +158,7 @@ func TestDeleteCollection(t *testing.T) {
|
||||
defer app.Cleanup()
|
||||
|
||||
c0 := &models.Collection{}
|
||||
c1, err := app.Dao().FindCollectionByNameOrId("demo")
|
||||
c1, err := app.Dao().FindCollectionByNameOrId("clients")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@ -146,18 +166,22 @@ func TestDeleteCollection(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c3, err := app.Dao().FindCollectionByNameOrId(models.ProfileCollectionName)
|
||||
c3, err := app.Dao().FindCollectionByNameOrId("demo1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c3.System = true
|
||||
if err := app.Dao().Save(c3); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
scenarios := []struct {
|
||||
model *models.Collection
|
||||
expectError bool
|
||||
}{
|
||||
{c0, true},
|
||||
{c1, true}, // is part of a reference
|
||||
{c2, false},
|
||||
{c1, false},
|
||||
{c2, true}, // is part of a reference
|
||||
{c3, true}, // system
|
||||
}
|
||||
|
||||
@ -177,6 +201,7 @@ func TestSaveCollectionCreate(t *testing.T) {
|
||||
|
||||
collection := &models.Collection{
|
||||
Name: "new_test",
|
||||
Type: models.CollectionTypeBase,
|
||||
Schema: schema.NewSchema(
|
||||
&schema.SchemaField{
|
||||
Type: schema.FieldTypeText,
|
||||
@ -239,7 +264,7 @@ func TestSaveCollectionUpdate(t *testing.T) {
|
||||
}
|
||||
|
||||
// check if the records table has the schema fields
|
||||
expectedColumns := []string{"id", "created", "updated", "title_update", "test"}
|
||||
expectedColumns := []string{"id", "created", "updated", "title_update", "test", "files"}
|
||||
columns, err := app.Dao().GetTableColumns(collection.Name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@ -262,13 +287,14 @@ func TestImportCollections(t *testing.T) {
|
||||
beforeRecordsSync func(txDao *daos.Dao, mappedImported, mappedExisting map[string]*models.Collection) error
|
||||
expectError bool
|
||||
expectCollectionsCount int
|
||||
beforeTestFunc func(testApp *tests.TestApp, resultCollections []*models.Collection)
|
||||
afterTestFunc func(testApp *tests.TestApp, resultCollections []*models.Collection)
|
||||
}{
|
||||
{
|
||||
name: "empty collections",
|
||||
jsonData: `[]`,
|
||||
expectError: true,
|
||||
expectCollectionsCount: 5,
|
||||
expectCollectionsCount: 7,
|
||||
},
|
||||
{
|
||||
name: "check db constraints",
|
||||
@ -277,7 +303,7 @@ func TestImportCollections(t *testing.T) {
|
||||
]`,
|
||||
deleteMissing: false,
|
||||
expectError: true,
|
||||
expectCollectionsCount: 5,
|
||||
expectCollectionsCount: 7,
|
||||
},
|
||||
{
|
||||
name: "minimal collection import",
|
||||
@ -286,7 +312,7 @@ func TestImportCollections(t *testing.T) {
|
||||
]`,
|
||||
deleteMissing: false,
|
||||
expectError: false,
|
||||
expectCollectionsCount: 6,
|
||||
expectCollectionsCount: 8,
|
||||
},
|
||||
{
|
||||
name: "minimal collection import + failed beforeRecordsSync",
|
||||
@ -298,7 +324,7 @@ func TestImportCollections(t *testing.T) {
|
||||
},
|
||||
deleteMissing: false,
|
||||
expectError: true,
|
||||
expectCollectionsCount: 5,
|
||||
expectCollectionsCount: 7,
|
||||
},
|
||||
{
|
||||
name: "minimal collection import + successful beforeRecordsSync",
|
||||
@ -310,13 +336,13 @@ func TestImportCollections(t *testing.T) {
|
||||
},
|
||||
deleteMissing: false,
|
||||
expectError: false,
|
||||
expectCollectionsCount: 6,
|
||||
expectCollectionsCount: 8,
|
||||
},
|
||||
{
|
||||
name: "new + update + delete system collection",
|
||||
jsonData: `[
|
||||
{
|
||||
"id":"3f2888f8-075d-49fe-9d09-ea7e951000dc",
|
||||
"id":"wsmn24bux7wo113",
|
||||
"name":"demo",
|
||||
"schema":[
|
||||
{
|
||||
@ -346,50 +372,49 @@ func TestImportCollections(t *testing.T) {
|
||||
]`,
|
||||
deleteMissing: true,
|
||||
expectError: true,
|
||||
expectCollectionsCount: 5,
|
||||
expectCollectionsCount: 7,
|
||||
},
|
||||
{
|
||||
name: "new + update + delete non-system collection",
|
||||
jsonData: `[
|
||||
{
|
||||
"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":[
|
||||
"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":"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": "x8zzktwe",
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"system": false,
|
||||
"required": false,
|
||||
"unique": false,
|
||||
"options": {
|
||||
"min": null,
|
||||
"max": null,
|
||||
"pattern": ""
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id":"3f2888f8-075d-49fe-9d09-ea7e951000dc",
|
||||
"id":"wsmn24bux7wo113",
|
||||
"name":"demo",
|
||||
"schema":[
|
||||
{
|
||||
@ -427,38 +452,8 @@ func TestImportCollections(t *testing.T) {
|
||||
name: "test with deleteMissing: false",
|
||||
jsonData: `[
|
||||
{
|
||||
"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":[
|
||||
{
|
||||
"id":"69ycbg3q",
|
||||
"name":"rel",
|
||||
"type":"relation",
|
||||
"system":false,
|
||||
"required":false,
|
||||
"unique":false,
|
||||
"options":{
|
||||
"maxSelect":2,
|
||||
"collectionId":"abe78266-fd4d-4aea-962d-8c0138ac522b",
|
||||
"cascadeDelete":true
|
||||
}
|
||||
},
|
||||
{
|
||||
"id":"abcd_import",
|
||||
"name":"new_field",
|
||||
"type":"bool"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id":"3f2888f8-075d-49fe-9d09-ea7e951000dc",
|
||||
"name":"demo",
|
||||
"id":"wsmn24bux7wo113",
|
||||
"name":"demo1",
|
||||
"schema":[
|
||||
{
|
||||
"id":"_2hlxbmp",
|
||||
@ -506,14 +501,14 @@ func TestImportCollections(t *testing.T) {
|
||||
]`,
|
||||
deleteMissing: false,
|
||||
expectError: false,
|
||||
expectCollectionsCount: 6,
|
||||
expectCollectionsCount: 8,
|
||||
afterTestFunc: func(testApp *tests.TestApp, resultCollections []*models.Collection) {
|
||||
expectedCollectionFields := map[string]int{
|
||||
"profiles": 6,
|
||||
"demo": 3,
|
||||
"demo2": 14,
|
||||
"demo3": 1,
|
||||
"demo4": 6,
|
||||
"nologin": 1,
|
||||
"demo1": 15,
|
||||
"demo2": 2,
|
||||
"demo3": 2,
|
||||
"demo4": 11,
|
||||
"new_import": 1,
|
||||
}
|
||||
for name, expectedCount := range expectedCollectionFields {
|
||||
|
@ -12,13 +12,16 @@ func (dao *Dao) ExternalAuthQuery() *dbx.SelectQuery {
|
||||
return dao.ModelQuery(&models.ExternalAuth{})
|
||||
}
|
||||
|
||||
/// FindAllExternalAuthsByUserId returns all ExternalAuth models
|
||||
/// linked to the provided userId.
|
||||
func (dao *Dao) FindAllExternalAuthsByUserId(userId string) ([]*models.ExternalAuth, error) {
|
||||
/// FindAllExternalAuthsByRecord returns all ExternalAuth models
|
||||
/// linked to the provided auth record.
|
||||
func (dao *Dao) FindAllExternalAuthsByRecord(authRecord *models.Record) ([]*models.ExternalAuth, error) {
|
||||
auths := []*models.ExternalAuth{}
|
||||
|
||||
err := dao.ExternalAuthQuery().
|
||||
AndWhere(dbx.HashExp{"userId": userId}).
|
||||
AndWhere(dbx.HashExp{
|
||||
"collectionId": authRecord.Collection().Id,
|
||||
"recordId": authRecord.Id,
|
||||
}).
|
||||
OrderBy("created ASC").
|
||||
All(&auths)
|
||||
|
||||
@ -50,15 +53,16 @@ func (dao *Dao) FindExternalAuthByProvider(provider, providerId string) (*models
|
||||
return model, nil
|
||||
}
|
||||
|
||||
// FindExternalAuthByUserIdAndProvider returns the first available
|
||||
// ExternalAuth model for the specified userId and provider.
|
||||
func (dao *Dao) FindExternalAuthByUserIdAndProvider(userId, provider string) (*models.ExternalAuth, error) {
|
||||
// FindExternalAuthByRecordAndProvider returns the first available
|
||||
// ExternalAuth model for the specified record data and provider.
|
||||
func (dao *Dao) FindExternalAuthByRecordAndProvider(authRecord *models.Record, provider string) (*models.ExternalAuth, error) {
|
||||
model := &models.ExternalAuth{}
|
||||
|
||||
err := dao.ExternalAuthQuery().
|
||||
AndWhere(dbx.HashExp{
|
||||
"userId": userId,
|
||||
"provider": provider,
|
||||
"collectionId": authRecord.Collection().Id,
|
||||
"recordId": authRecord.Id,
|
||||
"provider": provider,
|
||||
}).
|
||||
Limit(1).
|
||||
One(model)
|
||||
@ -74,7 +78,7 @@ func (dao *Dao) FindExternalAuthByUserIdAndProvider(userId, provider string) (*m
|
||||
func (dao *Dao) SaveExternalAuth(model *models.ExternalAuth) error {
|
||||
// extra check the model data in case the provider's API response
|
||||
// has changed and no longer returns the expected fields
|
||||
if model.UserId == "" || model.Provider == "" || model.ProviderId == "" {
|
||||
if model.CollectionId == "" || model.RecordId == "" || model.Provider == "" || model.ProviderId == "" {
|
||||
return errors.New("Missing required ExternalAuth fields.")
|
||||
}
|
||||
|
||||
@ -82,27 +86,6 @@ func (dao *Dao) SaveExternalAuth(model *models.ExternalAuth) error {
|
||||
}
|
||||
|
||||
// DeleteExternalAuth deletes the provided ExternalAuth model.
|
||||
//
|
||||
// The delete may fail if the linked user doesn't have an email and
|
||||
// there are no other linked ExternalAuth models available.
|
||||
func (dao *Dao) DeleteExternalAuth(model *models.ExternalAuth) error {
|
||||
user, err := dao.FindUserById(model.UserId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// if the user doesn't have an email, make sure that there
|
||||
// is at least one other external auth relation available
|
||||
if user.Email == "" {
|
||||
allExternalAuths, err := dao.FindAllExternalAuthsByUserId(user.Id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(allExternalAuths) <= 1 {
|
||||
return errors.New("You cannot delete the only available external auth relation because the user doesn't have an email address.")
|
||||
}
|
||||
}
|
||||
|
||||
return dao.Delete(model)
|
||||
}
|
||||
|
@ -19,7 +19,7 @@ func TestExternalAuthQuery(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindAllExternalAuthsByUserId(t *testing.T) {
|
||||
func TestFindAllExternalAuthsByRecord(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@ -27,16 +27,20 @@ func TestFindAllExternalAuthsByUserId(t *testing.T) {
|
||||
userId string
|
||||
expectedCount int
|
||||
}{
|
||||
{"", 0},
|
||||
{"missing", 0},
|
||||
{"97cc3d3d-6ba2-383f-b42a-7bc84d27410c", 0},
|
||||
{"cx9u0dh2udo8xol", 2},
|
||||
{"oap640cot4yru2s", 0},
|
||||
{"4q1xlclmfloku33", 2},
|
||||
}
|
||||
|
||||
for i, s := range scenarios {
|
||||
auths, err := app.Dao().FindAllExternalAuthsByUserId(s.userId)
|
||||
record, err := app.Dao().FindRecordById("users", s.userId)
|
||||
if err != nil {
|
||||
t.Errorf("(%d) Unexpected error %v", i, err)
|
||||
t.Errorf("(%d) Unexpected record fetch error %v", i, err)
|
||||
continue
|
||||
}
|
||||
|
||||
auths, err := app.Dao().FindAllExternalAuthsByRecord(record)
|
||||
if err != nil {
|
||||
t.Errorf("(%d) Unexpected auths fetch error %v", i, err)
|
||||
continue
|
||||
}
|
||||
|
||||
@ -45,8 +49,8 @@ func TestFindAllExternalAuthsByUserId(t *testing.T) {
|
||||
}
|
||||
|
||||
for _, auth := range auths {
|
||||
if auth.UserId != s.userId {
|
||||
t.Errorf("(%d) Expected all auths to be linked to userId %s, got %v", i, s.userId, auth)
|
||||
if auth.RecordId != record.Id {
|
||||
t.Errorf("(%d) Expected all auths to be linked to record id %s, got %v", i, record.Id, auth)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -65,8 +69,8 @@ func TestFindExternalAuthByProvider(t *testing.T) {
|
||||
{"github", "", ""},
|
||||
{"github", "id1", ""},
|
||||
{"github", "id2", ""},
|
||||
{"google", "id1", "abcdefghijklmn0"},
|
||||
{"gitlab", "id2", "abcdefghijklmn1"},
|
||||
{"google", "test123", "clmflokuq1xl341"},
|
||||
{"gitlab", "test123", "dlmflokuq1xl342"},
|
||||
}
|
||||
|
||||
for i, s := range scenarios {
|
||||
@ -85,7 +89,7 @@ func TestFindExternalAuthByProvider(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindExternalAuthByUserIdAndProvider(t *testing.T) {
|
||||
func TestFindExternalAuthByRecordAndProvider(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@ -94,17 +98,19 @@ func TestFindExternalAuthByUserIdAndProvider(t *testing.T) {
|
||||
provider string
|
||||
expectedId string
|
||||
}{
|
||||
{"", "", ""},
|
||||
{"", "github", ""},
|
||||
{"123456", "github", ""}, // missing user and provider record
|
||||
{"123456", "google", ""}, // missing user but existing provider record
|
||||
{"97cc3d3d-6ba2-383f-b42a-7bc84d27410c", "google", ""},
|
||||
{"cx9u0dh2udo8xol", "google", "abcdefghijklmn0"},
|
||||
{"cx9u0dh2udo8xol", "gitlab", "abcdefghijklmn1"},
|
||||
{"bgs820n361vj1qd", "google", ""},
|
||||
{"4q1xlclmfloku33", "google", "clmflokuq1xl341"},
|
||||
{"4q1xlclmfloku33", "gitlab", "dlmflokuq1xl342"},
|
||||
}
|
||||
|
||||
for i, s := range scenarios {
|
||||
auth, err := app.Dao().FindExternalAuthByUserIdAndProvider(s.userId, s.provider)
|
||||
record, err := app.Dao().FindRecordById("users", s.userId)
|
||||
if err != nil {
|
||||
t.Errorf("(%d) Unexpected record fetch error %v", i, err)
|
||||
continue
|
||||
}
|
||||
|
||||
auth, err := app.Dao().FindExternalAuthByRecordAndProvider(record, s.provider)
|
||||
|
||||
hasErr := err != nil
|
||||
expectErr := s.expectedId == ""
|
||||
@ -130,9 +136,10 @@ func TestSaveExternalAuth(t *testing.T) {
|
||||
}
|
||||
|
||||
auth := &models.ExternalAuth{
|
||||
UserId: "97cc3d3d-6ba2-383f-b42a-7bc84d27410c",
|
||||
Provider: "test",
|
||||
ProviderId: "test_id",
|
||||
RecordId: "o1y0dd0spd786md",
|
||||
CollectionId: "v851q4r790rhknl",
|
||||
Provider: "test",
|
||||
ProviderId: "test_id",
|
||||
}
|
||||
|
||||
if err := app.Dao().SaveExternalAuth(auth); err != nil {
|
||||
@ -154,42 +161,29 @@ func TestDeleteExternalAuth(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
user, err := app.Dao().FindUserById("cx9u0dh2udo8xol")
|
||||
record, err := app.Dao().FindRecordById("users", "4q1xlclmfloku33")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
auths, err := app.Dao().FindAllExternalAuthsByUserId(user.Id)
|
||||
auths, err := app.Dao().FindAllExternalAuthsByRecord(record)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := app.Dao().DeleteExternalAuth(auths[0]); err != nil {
|
||||
t.Fatalf("Failed to delete the first ExternalAuth relation, got \n%v", err)
|
||||
}
|
||||
|
||||
if err := app.Dao().DeleteExternalAuth(auths[1]); err == nil {
|
||||
t.Fatal("Expected delete to fail, got nil")
|
||||
}
|
||||
|
||||
// update the user model and try again
|
||||
user.Email = "test_new@example.com"
|
||||
if err := app.Dao().SaveUser(user); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// try to delete auths[1] again
|
||||
if err := app.Dao().DeleteExternalAuth(auths[1]); err != nil {
|
||||
t.Fatalf("Failed to delete the last ExternalAuth relation, got \n%v", err)
|
||||
for _, auth := range auths {
|
||||
if err := app.Dao().DeleteExternalAuth(auth); err != nil {
|
||||
t.Fatalf("Failed to delete the ExternalAuth relation, got \n%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// check if the relations were really deleted
|
||||
newAuths, err := app.Dao().FindAllExternalAuthsByUserId(user.Id)
|
||||
newAuths, err := app.Dao().FindAllExternalAuthsByRecord(record)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if len(newAuths) != 0 {
|
||||
t.Fatalf("Expected all user %s ExternalAuth relations to be deleted, got \n%v", user.Id, newAuths)
|
||||
t.Fatalf("Expected all record %s ExternalAuth relations to be deleted, got \n%v", record.Id, newAuths)
|
||||
}
|
||||
}
|
||||
|
376
daos/record.go
376
daos/record.go
@ -8,9 +8,11 @@ import (
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/models/schema"
|
||||
"github.com/pocketbase/pocketbase/tools/inflector"
|
||||
"github.com/pocketbase/pocketbase/tools/list"
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
"github.com/spf13/cast"
|
||||
)
|
||||
|
||||
// RecordQuery returns a new Record select query.
|
||||
@ -23,16 +25,24 @@ func (dao *Dao) RecordQuery(collection *models.Collection) *dbx.SelectQuery {
|
||||
|
||||
// FindRecordById finds the Record model by its id.
|
||||
func (dao *Dao) FindRecordById(
|
||||
collection *models.Collection,
|
||||
collectionNameOrId string,
|
||||
recordId string,
|
||||
filter func(q *dbx.SelectQuery) error,
|
||||
optFilters ...func(q *dbx.SelectQuery) error,
|
||||
) (*models.Record, error) {
|
||||
collection, err := dao.FindCollectionByNameOrId(collectionNameOrId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tableName := collection.Name
|
||||
|
||||
query := dao.RecordQuery(collection).
|
||||
AndWhere(dbx.HashExp{tableName + ".id": recordId})
|
||||
|
||||
if filter != nil {
|
||||
for _, filter := range optFilters {
|
||||
if filter == nil {
|
||||
continue
|
||||
}
|
||||
if err := filter(query); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -49,16 +59,25 @@ func (dao *Dao) FindRecordById(
|
||||
// FindRecordsByIds finds all Record models by the provided ids.
|
||||
// If no records are found, returns an empty slice.
|
||||
func (dao *Dao) FindRecordsByIds(
|
||||
collection *models.Collection,
|
||||
collectionNameOrId string,
|
||||
recordIds []string,
|
||||
filter func(q *dbx.SelectQuery) error,
|
||||
optFilters ...func(q *dbx.SelectQuery) error,
|
||||
) ([]*models.Record, error) {
|
||||
tableName := collection.Name
|
||||
collection, err := dao.FindCollectionByNameOrId(collectionNameOrId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
query := dao.RecordQuery(collection).
|
||||
AndWhere(dbx.In(tableName+".id", list.ToInterfaceSlice(recordIds)...))
|
||||
AndWhere(dbx.In(
|
||||
collection.Name+".id",
|
||||
list.ToInterfaceSlice(recordIds)...,
|
||||
))
|
||||
|
||||
if filter != nil {
|
||||
for _, filter := range optFilters {
|
||||
if filter == nil {
|
||||
continue
|
||||
}
|
||||
if err := filter(query); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -72,24 +91,34 @@ func (dao *Dao) FindRecordsByIds(
|
||||
return models.NewRecordsFromNullStringMaps(collection, rows), nil
|
||||
}
|
||||
|
||||
// FindRecordsByExpr finds all records by the provided db expression.
|
||||
// If no records are found, returns an empty slice.
|
||||
// FindRecordsByExpr finds all records by the specified db expression.
|
||||
//
|
||||
// Returns all collection records if no expressions are provided.
|
||||
//
|
||||
// Returns an empty slice if no records are found.
|
||||
//
|
||||
// Example:
|
||||
// expr := dbx.HashExp{"email": "test@example.com"}
|
||||
// dao.FindRecordsByExpr(collection, expr)
|
||||
func (dao *Dao) FindRecordsByExpr(collection *models.Collection, expr dbx.Expression) ([]*models.Record, error) {
|
||||
if expr == nil {
|
||||
return nil, errors.New("Missing filter expression")
|
||||
// expr1 := dbx.HashExp{"email": "test@example.com"}
|
||||
// expr2 := dbx.HashExp{"status": "active"}
|
||||
// dao.FindRecordsByExpr("example", expr1, expr2)
|
||||
func (dao *Dao) FindRecordsByExpr(collectionNameOrId string, exprs ...dbx.Expression) ([]*models.Record, error) {
|
||||
collection, err := dao.FindCollectionByNameOrId(collectionNameOrId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
query := dao.RecordQuery(collection)
|
||||
|
||||
// add only the non-nil expressions
|
||||
for _, expr := range exprs {
|
||||
if expr != nil {
|
||||
query.AndWhere(expr)
|
||||
}
|
||||
}
|
||||
|
||||
rows := []dbx.NullStringMap{}
|
||||
|
||||
err := dao.RecordQuery(collection).
|
||||
AndWhere(expr).
|
||||
All(&rows)
|
||||
|
||||
if err != nil {
|
||||
if err := query.All(&rows); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@ -98,11 +127,16 @@ func (dao *Dao) FindRecordsByExpr(collection *models.Collection, expr dbx.Expres
|
||||
|
||||
// FindFirstRecordByData returns the first found record matching
|
||||
// the provided key-value pair.
|
||||
func (dao *Dao) FindFirstRecordByData(collection *models.Collection, key string, value any) (*models.Record, error) {
|
||||
func (dao *Dao) FindFirstRecordByData(collectionNameOrId string, key string, value any) (*models.Record, error) {
|
||||
collection, err := dao.FindCollectionByNameOrId(collectionNameOrId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
row := dbx.NullStringMap{}
|
||||
|
||||
err := dao.RecordQuery(collection).
|
||||
AndWhere(dbx.HashExp{key: value}).
|
||||
err = dao.RecordQuery(collection).
|
||||
AndWhere(dbx.HashExp{inflector.Columnify(key): value}).
|
||||
Limit(1).
|
||||
One(row)
|
||||
|
||||
@ -115,85 +149,193 @@ func (dao *Dao) FindFirstRecordByData(collection *models.Collection, key string,
|
||||
|
||||
// IsRecordValueUnique checks if the provided key-value pair is a unique Record value.
|
||||
//
|
||||
// For correctness, if the collection is "auth" and the key is "username",
|
||||
// the unique check will be case insensitive.
|
||||
//
|
||||
// NB! Array values (eg. from multiple select fields) are matched
|
||||
// as a serialized json strings (eg. `["a","b"]`), so the value uniqueness
|
||||
// depends on the elements order. Or in other words the following values
|
||||
// are considered different: `[]string{"a","b"}` and `[]string{"b","a"}`
|
||||
func (dao *Dao) IsRecordValueUnique(
|
||||
collection *models.Collection,
|
||||
collectionNameOrId string,
|
||||
key string,
|
||||
value any,
|
||||
excludeId string,
|
||||
excludeIds ...string,
|
||||
) bool {
|
||||
collection, err := dao.FindCollectionByNameOrId(collectionNameOrId)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
var expr dbx.Expression
|
||||
if collection.IsAuth() && key == schema.FieldNameUsername {
|
||||
expr = dbx.NewExp("LOWER([["+schema.FieldNameUsername+"]])={:username}", dbx.Params{
|
||||
"username": strings.ToLower(cast.ToString(value)),
|
||||
})
|
||||
} else {
|
||||
var normalizedVal any
|
||||
switch val := value.(type) {
|
||||
case []string:
|
||||
normalizedVal = append(types.JsonArray{}, list.ToInterfaceSlice(val)...)
|
||||
case []any:
|
||||
normalizedVal = append(types.JsonArray{}, val...)
|
||||
default:
|
||||
normalizedVal = val
|
||||
}
|
||||
|
||||
expr = dbx.HashExp{inflector.Columnify(key): normalizedVal}
|
||||
}
|
||||
|
||||
query := dao.RecordQuery(collection).
|
||||
Select("count(*)").
|
||||
AndWhere(expr).
|
||||
Limit(1)
|
||||
|
||||
if len(excludeIds) > 0 {
|
||||
uniqueExcludeIds := list.NonzeroUniques(excludeIds)
|
||||
query.AndWhere(dbx.NotIn(collection.Name+".id", list.ToInterfaceSlice(uniqueExcludeIds)...))
|
||||
}
|
||||
|
||||
var exists bool
|
||||
|
||||
var normalizedVal any
|
||||
switch val := value.(type) {
|
||||
case []string:
|
||||
normalizedVal = append(types.JsonArray{}, list.ToInterfaceSlice(val)...)
|
||||
case []any:
|
||||
normalizedVal = append(types.JsonArray{}, val...)
|
||||
default:
|
||||
normalizedVal = val
|
||||
}
|
||||
|
||||
err := dao.RecordQuery(collection).
|
||||
Select("count(*)").
|
||||
AndWhere(dbx.Not(dbx.HashExp{"id": excludeId})).
|
||||
AndWhere(dbx.HashExp{key: normalizedVal}).
|
||||
Limit(1).
|
||||
Row(&exists)
|
||||
|
||||
return err == nil && !exists
|
||||
return query.Row(&exists) == nil && !exists
|
||||
}
|
||||
|
||||
// FindUserRelatedRecords returns all records that has a reference
|
||||
// to the provided User model (via the user shema field).
|
||||
func (dao *Dao) FindUserRelatedRecords(user *models.User) ([]*models.Record, error) {
|
||||
if user.Id == "" {
|
||||
return []*models.Record{}, nil
|
||||
}
|
||||
|
||||
collections, err := dao.FindCollectionsWithUserFields()
|
||||
// FindAuthRecordByToken finds the auth record associated with the provided JWT token.
|
||||
//
|
||||
// Returns an error if the JWT token is invalid, expired or not associated to an auth collection record.
|
||||
func (dao *Dao) FindAuthRecordByToken(token string, baseTokenKey string) (*models.Record, error) {
|
||||
unverifiedClaims, err := security.ParseUnverifiedJWT(token)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := []*models.Record{}
|
||||
for _, collection := range collections {
|
||||
userFields := []*schema.SchemaField{}
|
||||
|
||||
// prepare fields options
|
||||
if err := collection.Schema.InitFieldsOptions(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// extract user fields
|
||||
for _, field := range collection.Schema.Fields() {
|
||||
if field.Type == schema.FieldTypeUser {
|
||||
userFields = append(userFields, field)
|
||||
}
|
||||
}
|
||||
|
||||
// fetch records associated to the user
|
||||
exprs := []dbx.Expression{}
|
||||
for _, field := range userFields {
|
||||
exprs = append(exprs, dbx.HashExp{field.Name: user.Id})
|
||||
}
|
||||
rows := []dbx.NullStringMap{}
|
||||
if err := dao.RecordQuery(collection).AndWhere(dbx.Or(exprs...)).All(&rows); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
records := models.NewRecordsFromNullStringMaps(collection, rows)
|
||||
|
||||
result = append(result, records...)
|
||||
// check required claims
|
||||
id, _ := unverifiedClaims["id"].(string)
|
||||
collectionId, _ := unverifiedClaims["collectionId"].(string)
|
||||
if id == "" || collectionId == "" {
|
||||
return nil, errors.New("Missing or invalid token claims.")
|
||||
}
|
||||
|
||||
return result, nil
|
||||
record, err := dao.FindRecordById(collectionId, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !record.Collection().IsAuth() {
|
||||
return nil, errors.New("The token is not associated to an auth collection record.")
|
||||
}
|
||||
|
||||
verificationKey := record.TokenKey() + baseTokenKey
|
||||
|
||||
// verify token signature
|
||||
if _, err := security.ParseJWT(token, verificationKey); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return record, nil
|
||||
}
|
||||
|
||||
// FindAuthRecordByEmail finds the auth record associated with the provided email.
|
||||
//
|
||||
// Returns an error if it is not an auth collection or the record is not found.
|
||||
func (dao *Dao) FindAuthRecordByEmail(collectionNameOrId string, email string) (*models.Record, error) {
|
||||
collection, err := dao.FindCollectionByNameOrId(collectionNameOrId)
|
||||
if err != nil || !collection.IsAuth() {
|
||||
return nil, errors.New("Missing or not an auth collection.")
|
||||
}
|
||||
|
||||
row := dbx.NullStringMap{}
|
||||
|
||||
err = dao.RecordQuery(collection).
|
||||
AndWhere(dbx.HashExp{schema.FieldNameEmail: email}).
|
||||
Limit(1).
|
||||
One(row)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return models.NewRecordFromNullStringMap(collection, row), nil
|
||||
}
|
||||
|
||||
// FindAuthRecordByUsername finds the auth record associated with the provided username (case insensitive).
|
||||
//
|
||||
// Returns an error if it is not an auth collection or the record is not found.
|
||||
func (dao *Dao) FindAuthRecordByUsername(collectionNameOrId string, username string) (*models.Record, error) {
|
||||
collection, err := dao.FindCollectionByNameOrId(collectionNameOrId)
|
||||
if err != nil || !collection.IsAuth() {
|
||||
return nil, errors.New("Missing or not an auth collection.")
|
||||
}
|
||||
|
||||
row := dbx.NullStringMap{}
|
||||
|
||||
err = dao.RecordQuery(collection).
|
||||
AndWhere(dbx.NewExp("LOWER([["+schema.FieldNameUsername+"]])={:username}", dbx.Params{
|
||||
"username": strings.ToLower(username),
|
||||
})).
|
||||
Limit(1).
|
||||
One(row)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return models.NewRecordFromNullStringMap(collection, row), nil
|
||||
}
|
||||
|
||||
// SuggestUniqueAuthRecordUsername checks if the provided username is unique
|
||||
// and return a new "unique" username with appended random numeric part
|
||||
// (eg. "existingName" -> "existingName583").
|
||||
//
|
||||
// The same username will be returned if the provided string is already unique.
|
||||
func (dao *Dao) SuggestUniqueAuthRecordUsername(
|
||||
collectionNameOrId string,
|
||||
baseUsername string,
|
||||
excludeIds ...string,
|
||||
) string {
|
||||
username := baseUsername
|
||||
|
||||
for i := 0; i < 10; i++ { // max 10 attempts
|
||||
isUnique := dao.IsRecordValueUnique(
|
||||
collectionNameOrId,
|
||||
schema.FieldNameUsername,
|
||||
username,
|
||||
excludeIds...,
|
||||
)
|
||||
if isUnique {
|
||||
break // already unique
|
||||
}
|
||||
username = baseUsername + security.RandomStringWithAlphabet(3+i, "123456789")
|
||||
}
|
||||
|
||||
return username
|
||||
}
|
||||
|
||||
// SaveRecord upserts the provided Record model.
|
||||
func (dao *Dao) SaveRecord(record *models.Record) error {
|
||||
if record.Collection().IsAuth() {
|
||||
if record.Username() == "" {
|
||||
return errors.New("Unable to save auth record without username.")
|
||||
}
|
||||
|
||||
// Cross-check that the auth record id is unique for all auth collections.
|
||||
// This is to make sure that the filter `@request.auth.id` always returns a unique id.
|
||||
authCollections, err := dao.FindCollectionsByType(models.CollectionTypeAuth)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Unable to fetch the auth collections for cross-id unique check: %v", err)
|
||||
}
|
||||
for _, collection := range authCollections {
|
||||
if record.Collection().Id == collection.Id {
|
||||
continue // skip current collection (sqlite will do the check for us)
|
||||
}
|
||||
isUnique := dao.IsRecordValueUnique(collection.Id, schema.FieldNameId, record.Id)
|
||||
if !isUnique {
|
||||
return errors.New("The auth record ID must be unique across all auth collections.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return dao.Save(record)
|
||||
}
|
||||
|
||||
@ -206,8 +348,8 @@ func (dao *Dao) SaveRecord(record *models.Record) error {
|
||||
// reference in another record (aka. cannot be deleted or set to NULL).
|
||||
func (dao *Dao) DeleteRecord(record *models.Record) error {
|
||||
// check for references
|
||||
// note: the select is outside of the transaction to prevent SQLITE_LOCKED error when mixing read&write in a single transaction
|
||||
refs, err := dao.FindCollectionReferences(record.Collection(), "")
|
||||
// note: the select is outside of the transaction to prevent SQLITE_LOCKED error when mixing read&write in a single transaction.
|
||||
refs, err := dao.FindCollectionReferences(record.Collection())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@ -217,6 +359,7 @@ func (dao *Dao) DeleteRecord(record *models.Record) error {
|
||||
// just unset the record id from any relation field values (if they are not required)
|
||||
// -----------------------------------------------------------
|
||||
return dao.RunInTransaction(func(txDao *Dao) error {
|
||||
// delete/update references
|
||||
for refCollection, fields := range refs {
|
||||
for _, field := range fields {
|
||||
options, _ := field.Options.(*schema.RelationOptions)
|
||||
@ -234,7 +377,7 @@ func (dao *Dao) DeleteRecord(record *models.Record) error {
|
||||
|
||||
refRecords := models.NewRecordsFromNullStringMaps(refCollection, rows)
|
||||
for _, refRecord := range refRecords {
|
||||
ids := refRecord.GetStringSliceDataValue(field.Name)
|
||||
ids := refRecord.GetStringSlice(field.Name)
|
||||
|
||||
// unset the record id
|
||||
for i := len(ids) - 1; i >= 0; i-- {
|
||||
@ -259,7 +402,7 @@ func (dao *Dao) DeleteRecord(record *models.Record) error {
|
||||
}
|
||||
|
||||
// save the reference changes
|
||||
refRecord.SetDataValue(field.Name, field.PrepareValue(ids))
|
||||
refRecord.Set(field.Name, field.PrepareValue(ids))
|
||||
if err := txDao.SaveRecord(refRecord); err != nil {
|
||||
return err
|
||||
}
|
||||
@ -267,6 +410,17 @@ func (dao *Dao) DeleteRecord(record *models.Record) error {
|
||||
}
|
||||
}
|
||||
|
||||
// delete linked external auths
|
||||
if record.Collection().IsAuth() {
|
||||
_, err = txDao.DB().Delete((&models.ExternalAuth{}).TableName(), dbx.HashExp{
|
||||
"collectionId": record.Collection().Id,
|
||||
"recordId": record.Id,
|
||||
}).Execute()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return txDao.Delete(record)
|
||||
})
|
||||
}
|
||||
@ -279,9 +433,26 @@ func (dao *Dao) SyncRecordTableSchema(newCollection *models.Collection, oldColle
|
||||
// create
|
||||
if oldCollection == nil {
|
||||
cols := map[string]string{
|
||||
schema.ReservedFieldNameId: "TEXT PRIMARY KEY",
|
||||
schema.ReservedFieldNameCreated: `TEXT DEFAULT "" NOT NULL`,
|
||||
schema.ReservedFieldNameUpdated: `TEXT DEFAULT "" NOT NULL`,
|
||||
schema.FieldNameId: "TEXT PRIMARY KEY",
|
||||
schema.FieldNameCreated: "TEXT DEFAULT '' NOT NULL",
|
||||
schema.FieldNameUpdated: "TEXT DEFAULT '' NOT NULL",
|
||||
}
|
||||
|
||||
if newCollection.IsAuth() {
|
||||
cols[schema.FieldNameUsername] = "TEXT NOT NULL"
|
||||
cols[schema.FieldNameEmail] = "TEXT DEFAULT '' NOT NULL"
|
||||
cols[schema.FieldNameEmailVisibility] = "BOOLEAN DEFAULT FALSE NOT NULL"
|
||||
cols[schema.FieldNameVerified] = "BOOLEAN DEFAULT FALSE NOT NULL"
|
||||
cols[schema.FieldNameTokenKey] = "TEXT NOT NULL"
|
||||
cols[schema.FieldNamePasswordHash] = "TEXT NOT NULL"
|
||||
cols[schema.FieldNameLastResetSentAt] = "TEXT DEFAULT '' NOT NULL"
|
||||
cols[schema.FieldNameLastVerificationSentAt] = "TEXT DEFAULT '' NOT NULL"
|
||||
}
|
||||
|
||||
// ensure that the new collection has an id
|
||||
if !newCollection.HasId() {
|
||||
newCollection.RefreshId()
|
||||
newCollection.MarkAsNew()
|
||||
}
|
||||
|
||||
tableName := newCollection.Name
|
||||
@ -292,15 +463,30 @@ func (dao *Dao) SyncRecordTableSchema(newCollection *models.Collection, oldColle
|
||||
}
|
||||
|
||||
// create table
|
||||
_, tableErr := dao.DB().CreateTable(tableName, cols).Execute()
|
||||
if tableErr != nil {
|
||||
return tableErr
|
||||
if _, err := dao.DB().CreateTable(tableName, cols).Execute(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// add index on the base `created` column
|
||||
_, indexErr := dao.DB().CreateIndex(tableName, tableName+"_created_idx", "created").Execute()
|
||||
if indexErr != nil {
|
||||
return indexErr
|
||||
// add named index on the base `created` column
|
||||
if _, err := dao.DB().CreateIndex(tableName, "_"+newCollection.Id+"_created_idx", "created").Execute(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// add named unique index on the email and tokenKey columns
|
||||
if newCollection.IsAuth() {
|
||||
_, err := dao.DB().NewQuery(fmt.Sprintf(
|
||||
`
|
||||
CREATE UNIQUE INDEX _%s_username_idx ON {{%s}} ([[username]]);
|
||||
CREATE UNIQUE INDEX _%s_email_idx ON {{%s}} ([[email]]) WHERE [[email]] != '';
|
||||
CREATE UNIQUE INDEX _%s_tokenKey_idx ON {{%s}} ([[tokenKey]]);
|
||||
`,
|
||||
newCollection.Id, tableName,
|
||||
newCollection.Id, tableName,
|
||||
newCollection.Id, tableName,
|
||||
)).Execute()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
@ -315,7 +501,7 @@ func (dao *Dao) SyncRecordTableSchema(newCollection *models.Collection, oldColle
|
||||
|
||||
// check for renamed table
|
||||
if !strings.EqualFold(oldTableName, newTableName) {
|
||||
_, err := dao.DB().RenameTable(oldTableName, newTableName).Execute()
|
||||
_, err := txDao.DB().RenameTable(oldTableName, newTableName).Execute()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
@ -3,11 +3,16 @@ package daos
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/models/schema"
|
||||
"github.com/pocketbase/pocketbase/tools/inflector"
|
||||
"github.com/pocketbase/pocketbase/tools/list"
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
)
|
||||
|
||||
// MaxExpandDepth specifies the max allowed nested expand depth path.
|
||||
@ -40,10 +45,13 @@ func (dao *Dao) ExpandRecords(records []*models.Record, expands []string, fetchF
|
||||
return failed
|
||||
}
|
||||
|
||||
var indirectExpandRegex = regexp.MustCompile(`^(\w+)\((\w+)\)$`)
|
||||
|
||||
// notes:
|
||||
// - fetchFunc must be non-nil func
|
||||
// - all records are expected to be from the same collection
|
||||
// - if MaxExpandDepth is reached, the function returns nil ignoring the remaining expand path
|
||||
// - indirect expands are supported only with single relation fields
|
||||
func (dao *Dao) expandRecords(records []*models.Record, expandPath string, fetchFunc ExpandFetchFunc, recursionLevel int) error {
|
||||
if fetchFunc == nil {
|
||||
return errors.New("Relation records fetchFunc is not set.")
|
||||
@ -53,29 +61,104 @@ func (dao *Dao) expandRecords(records []*models.Record, expandPath string, fetch
|
||||
return nil
|
||||
}
|
||||
|
||||
parts := strings.SplitN(expandPath, ".", 2)
|
||||
|
||||
// extract the relation field (if exist)
|
||||
mainCollection := records[0].Collection()
|
||||
relField := mainCollection.Schema.GetFieldByName(parts[0])
|
||||
if relField == nil || relField.Type != schema.FieldTypeRelation {
|
||||
return fmt.Errorf("Couldn't find relation field %q in collection %q.", parts[0], mainCollection.Name)
|
||||
}
|
||||
relField.InitOptions()
|
||||
relFieldOptions, ok := relField.Options.(*schema.RelationOptions)
|
||||
if !ok {
|
||||
return fmt.Errorf("Cannot initialize the options of relation field %q.", parts[0])
|
||||
|
||||
var relField *schema.SchemaField
|
||||
var relFieldOptions *schema.RelationOptions
|
||||
var relCollection *models.Collection
|
||||
|
||||
parts := strings.SplitN(expandPath, ".", 2)
|
||||
matches := indirectExpandRegex.FindStringSubmatch(parts[0])
|
||||
|
||||
if len(matches) == 3 {
|
||||
indirectRel, _ := dao.FindCollectionByNameOrId(matches[1])
|
||||
if indirectRel == nil {
|
||||
return fmt.Errorf("Couldn't find indirect related collection %q.", matches[1])
|
||||
}
|
||||
|
||||
indirectRelField := indirectRel.Schema.GetFieldByName(matches[2])
|
||||
if indirectRelField == nil || indirectRelField.Type != schema.FieldTypeRelation {
|
||||
return fmt.Errorf("Couldn't find indirect relation field %q in collection %q.", matches[2], mainCollection.Name)
|
||||
}
|
||||
|
||||
indirectRelField.InitOptions()
|
||||
indirectRelFieldOptions, _ := indirectRelField.Options.(*schema.RelationOptions)
|
||||
if indirectRelFieldOptions == nil || indirectRelFieldOptions.CollectionId != mainCollection.Id {
|
||||
return fmt.Errorf("Invalid indirect relation field path %q.", parts[0])
|
||||
}
|
||||
if indirectRelFieldOptions.MaxSelect != nil && *indirectRelFieldOptions.MaxSelect != 1 {
|
||||
// for now don't allow multi-relation indirect fields expand
|
||||
// due to eventual poor query performance with large data sets.
|
||||
return fmt.Errorf("Multi-relation fields cannot be indirectly expanded in %q.", parts[0])
|
||||
}
|
||||
|
||||
recordIds := make([]any, len(records))
|
||||
for _, record := range records {
|
||||
recordIds = append(recordIds, record.Id)
|
||||
}
|
||||
|
||||
indirectRecords, err := dao.FindRecordsByExpr(
|
||||
indirectRel.Id,
|
||||
dbx.In(inflector.Columnify(matches[2]), recordIds...),
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
mappedIndirectRecordIds := make(map[string][]string, len(indirectRecords))
|
||||
for _, indirectRecord := range indirectRecords {
|
||||
recId := indirectRecord.GetString(matches[2])
|
||||
if recId != "" {
|
||||
mappedIndirectRecordIds[recId] = append(mappedIndirectRecordIds[recId], indirectRecord.Id)
|
||||
}
|
||||
}
|
||||
|
||||
// add the indirect relation ids as a new relation field value
|
||||
for _, record := range records {
|
||||
relIds, ok := mappedIndirectRecordIds[record.Id]
|
||||
if ok && len(relIds) > 0 {
|
||||
record.Set(parts[0], relIds)
|
||||
}
|
||||
}
|
||||
|
||||
relFieldOptions = &schema.RelationOptions{
|
||||
MaxSelect: nil,
|
||||
CollectionId: indirectRel.Id,
|
||||
}
|
||||
if indirectRelField.Unique {
|
||||
relFieldOptions.MaxSelect = types.Pointer(1)
|
||||
}
|
||||
// indirect relation
|
||||
relField = &schema.SchemaField{
|
||||
Id: "indirect_" + security.RandomString(3),
|
||||
Type: schema.FieldTypeRelation,
|
||||
Name: parts[0],
|
||||
Options: relFieldOptions,
|
||||
}
|
||||
relCollection = indirectRel
|
||||
} else {
|
||||
// direct relation
|
||||
relField = mainCollection.Schema.GetFieldByName(parts[0])
|
||||
if relField == nil || relField.Type != schema.FieldTypeRelation {
|
||||
return fmt.Errorf("Couldn't find relation field %q in collection %q.", parts[0], mainCollection.Name)
|
||||
}
|
||||
relField.InitOptions()
|
||||
relFieldOptions, _ = relField.Options.(*schema.RelationOptions)
|
||||
if relFieldOptions == nil {
|
||||
return fmt.Errorf("Couldn't initialize the options of relation field %q.", parts[0])
|
||||
}
|
||||
|
||||
relCollection, _ = dao.FindCollectionByNameOrId(relFieldOptions.CollectionId)
|
||||
if relCollection == nil {
|
||||
return fmt.Errorf("Couldn't find related collection %q.", relFieldOptions.CollectionId)
|
||||
}
|
||||
}
|
||||
|
||||
relCollection, err := dao.FindCollectionByNameOrId(relFieldOptions.CollectionId)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Couldn't find collection %q.", relFieldOptions.CollectionId)
|
||||
}
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
// extract the id of the relations to expand
|
||||
relIds := make([]string, 0, len(records))
|
||||
for _, record := range records {
|
||||
relIds = append(relIds, record.GetStringSliceDataValue(relField.Name)...)
|
||||
relIds = append(relIds, record.GetStringSlice(relField.Name)...)
|
||||
}
|
||||
|
||||
// fetch rels
|
||||
@ -99,7 +182,7 @@ func (dao *Dao) expandRecords(records []*models.Record, expandPath string, fetch
|
||||
}
|
||||
|
||||
for _, model := range records {
|
||||
relIds := model.GetStringSliceDataValue(relField.Name)
|
||||
relIds := model.GetStringSlice(relField.Name)
|
||||
|
||||
validRels := make([]*models.Record, 0, len(relIds))
|
||||
for _, id := range relIds {
|
||||
@ -112,7 +195,7 @@ func (dao *Dao) expandRecords(records []*models.Record, expandPath string, fetch
|
||||
continue // no valid relations
|
||||
}
|
||||
|
||||
expandData := model.GetExpand()
|
||||
expandData := model.Expand()
|
||||
|
||||
// normalize access to the previously expanded rel records (if any)
|
||||
var oldExpandedRels []*models.Record
|
||||
@ -133,8 +216,8 @@ func (dao *Dao) expandRecords(records []*models.Record, expandPath string, fetch
|
||||
continue
|
||||
}
|
||||
|
||||
oldRelExpand := oldExpandedRel.GetExpand()
|
||||
newRelExpand := rel.GetExpand()
|
||||
oldRelExpand := oldExpandedRel.Expand()
|
||||
newRelExpand := rel.Expand()
|
||||
for k, v := range oldRelExpand {
|
||||
newRelExpand[k] = v
|
||||
}
|
||||
@ -143,7 +226,7 @@ func (dao *Dao) expandRecords(records []*models.Record, expandPath string, fetch
|
||||
}
|
||||
|
||||
// update the expanded data
|
||||
if relFieldOptions.MaxSelect == 1 {
|
||||
if relFieldOptions.MaxSelect != nil && *relFieldOptions.MaxSelect <= 1 {
|
||||
expandData[relField.Name] = validRels[0]
|
||||
} else {
|
||||
expandData[relField.Name] = validRels
|
||||
|
@ -8,6 +8,7 @@ import (
|
||||
|
||||
"github.com/pocketbase/pocketbase/daos"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/models/schema"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
"github.com/pocketbase/pocketbase/tools/list"
|
||||
)
|
||||
@ -16,152 +17,173 @@ func TestExpandRecords(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
col, _ := app.Dao().FindCollectionByNameOrId("demo4")
|
||||
|
||||
scenarios := []struct {
|
||||
testName string
|
||||
collectionIdOrName string
|
||||
recordIds []string
|
||||
expands []string
|
||||
fetchFunc daos.ExpandFetchFunc
|
||||
expectExpandProps int
|
||||
expectExpandFailures int
|
||||
}{
|
||||
// empty records
|
||||
{
|
||||
"empty records",
|
||||
"",
|
||||
[]string{},
|
||||
[]string{"onerel", "manyrels.onerel.manyrels"},
|
||||
[]string{"self_rel_one", "self_rel_many.self_rel_one"},
|
||||
func(c *models.Collection, ids []string) ([]*models.Record, error) {
|
||||
return app.Dao().FindRecordsByIds(c, ids, nil)
|
||||
return app.Dao().FindRecordsByIds(c.Id, ids, nil)
|
||||
},
|
||||
0,
|
||||
0,
|
||||
},
|
||||
// empty expand
|
||||
{
|
||||
[]string{"b8ba58f9-e2d7-42a0-b0e7-a11efd98236b", "df55c8ff-45ef-4c82-8aed-6e2183fe1125"},
|
||||
"empty expand",
|
||||
"demo4",
|
||||
[]string{"i9naidtvr6qsgb4", "qzaqccwrmva4o1n"},
|
||||
[]string{},
|
||||
func(c *models.Collection, ids []string) ([]*models.Record, error) {
|
||||
return app.Dao().FindRecordsByIds(c, ids, nil)
|
||||
return app.Dao().FindRecordsByIds(c.Id, ids, nil)
|
||||
},
|
||||
0,
|
||||
0,
|
||||
},
|
||||
// empty fetchFunc
|
||||
{
|
||||
[]string{"b8ba58f9-e2d7-42a0-b0e7-a11efd98236b", "df55c8ff-45ef-4c82-8aed-6e2183fe1125"},
|
||||
[]string{"onerel", "manyrels.onerel.manyrels"},
|
||||
"empty fetchFunc",
|
||||
"demo4",
|
||||
[]string{"i9naidtvr6qsgb4", "qzaqccwrmva4o1n"},
|
||||
[]string{"self_rel_one", "self_rel_many.self_rel_one"},
|
||||
nil,
|
||||
0,
|
||||
2,
|
||||
},
|
||||
// fetchFunc with error
|
||||
{
|
||||
[]string{"b8ba58f9-e2d7-42a0-b0e7-a11efd98236b", "df55c8ff-45ef-4c82-8aed-6e2183fe1125"},
|
||||
[]string{"onerel", "manyrels.onerel.manyrels"},
|
||||
"fetchFunc with error",
|
||||
"demo4",
|
||||
[]string{"i9naidtvr6qsgb4", "qzaqccwrmva4o1n"},
|
||||
[]string{"self_rel_one", "self_rel_many.self_rel_one"},
|
||||
func(c *models.Collection, ids []string) ([]*models.Record, error) {
|
||||
return nil, errors.New("test error")
|
||||
},
|
||||
0,
|
||||
2,
|
||||
},
|
||||
// missing relation field
|
||||
{
|
||||
[]string{"b8ba58f9-e2d7-42a0-b0e7-a11efd98236b", "df55c8ff-45ef-4c82-8aed-6e2183fe1125"},
|
||||
[]string{"invalid"},
|
||||
"missing relation field",
|
||||
"demo4",
|
||||
[]string{"i9naidtvr6qsgb4", "qzaqccwrmva4o1n"},
|
||||
[]string{"missing"},
|
||||
func(c *models.Collection, ids []string) ([]*models.Record, error) {
|
||||
return app.Dao().FindRecordsByIds(c, ids, nil)
|
||||
return app.Dao().FindRecordsByIds(c.Id, ids, nil)
|
||||
},
|
||||
0,
|
||||
1,
|
||||
},
|
||||
// existing, but non-relation type field
|
||||
{
|
||||
[]string{"b8ba58f9-e2d7-42a0-b0e7-a11efd98236b", "df55c8ff-45ef-4c82-8aed-6e2183fe1125"},
|
||||
"existing, but non-relation type field",
|
||||
"demo4",
|
||||
[]string{"i9naidtvr6qsgb4", "qzaqccwrmva4o1n"},
|
||||
[]string{"title"},
|
||||
func(c *models.Collection, ids []string) ([]*models.Record, error) {
|
||||
return app.Dao().FindRecordsByIds(c, ids, nil)
|
||||
return app.Dao().FindRecordsByIds(c.Id, ids, nil)
|
||||
},
|
||||
0,
|
||||
1,
|
||||
},
|
||||
// invalid/missing second level expand
|
||||
{
|
||||
[]string{"b8ba58f9-e2d7-42a0-b0e7-a11efd98236b", "df55c8ff-45ef-4c82-8aed-6e2183fe1125"},
|
||||
[]string{"manyrels.invalid"},
|
||||
"invalid/missing second level expand",
|
||||
"demo4",
|
||||
[]string{"i9naidtvr6qsgb4", "qzaqccwrmva4o1n"},
|
||||
[]string{"rel_one_no_cascade.title"},
|
||||
func(c *models.Collection, ids []string) ([]*models.Record, error) {
|
||||
return app.Dao().FindRecordsByIds(c, ids, nil)
|
||||
return app.Dao().FindRecordsByIds(c.Id, ids, nil)
|
||||
},
|
||||
0,
|
||||
1,
|
||||
},
|
||||
// expand normalizations
|
||||
{
|
||||
"expand normalizations",
|
||||
"demo4",
|
||||
[]string{"i9naidtvr6qsgb4", "qzaqccwrmva4o1n"},
|
||||
[]string{
|
||||
"b8ba58f9-e2d7-42a0-b0e7-a11efd98236b",
|
||||
"df55c8ff-45ef-4c82-8aed-6e2183fe1125",
|
||||
"b84cd893-7119-43c9-8505-3c4e22da28a9",
|
||||
"054f9f24-0a0a-4e09-87b1-bc7ff2b336a2",
|
||||
"self_rel_one", "self_rel_many.self_rel_many.rel_one_no_cascade",
|
||||
"self_rel_many.self_rel_one.self_rel_many.self_rel_one.rel_one_no_cascade",
|
||||
"self_rel_many", "self_rel_many.",
|
||||
" self_rel_many ", "",
|
||||
},
|
||||
[]string{"manyrels.onerel.manyrels.onerel", "manyrels.onerel", "onerel", "onerel.", " onerel ", ""},
|
||||
func(c *models.Collection, ids []string) ([]*models.Record, error) {
|
||||
return app.Dao().FindRecordsByIds(c, ids, nil)
|
||||
return app.Dao().FindRecordsByIds(c.Id, ids, nil)
|
||||
},
|
||||
9,
|
||||
0,
|
||||
},
|
||||
// expand multiple relations sharing a common root path
|
||||
{
|
||||
"single expand",
|
||||
"users",
|
||||
[]string{
|
||||
"i15r5aa28ad06c8",
|
||||
"bgs820n361vj1qd",
|
||||
"4q1xlclmfloku33",
|
||||
"oap640cot4yru2s", // no rels
|
||||
},
|
||||
[]string{"manyrels.onerel.manyrels.onerel", "manyrels.onerel.onerel"},
|
||||
[]string{"rel"},
|
||||
func(c *models.Collection, ids []string) ([]*models.Record, error) {
|
||||
return app.Dao().FindRecordsByIds(c, ids, nil)
|
||||
},
|
||||
4,
|
||||
0,
|
||||
},
|
||||
// single expand
|
||||
{
|
||||
[]string{
|
||||
"b8ba58f9-e2d7-42a0-b0e7-a11efd98236b",
|
||||
"df55c8ff-45ef-4c82-8aed-6e2183fe1125",
|
||||
"b84cd893-7119-43c9-8505-3c4e22da28a9", // no manyrels
|
||||
"054f9f24-0a0a-4e09-87b1-bc7ff2b336a2", // no manyrels
|
||||
},
|
||||
[]string{"manyrels"},
|
||||
func(c *models.Collection, ids []string) ([]*models.Record, error) {
|
||||
return app.Dao().FindRecordsByIds(c, ids, nil)
|
||||
return app.Dao().FindRecordsByIds(c.Id, ids, nil)
|
||||
},
|
||||
2,
|
||||
0,
|
||||
},
|
||||
// maxExpandDepth reached
|
||||
{
|
||||
[]string{"b8ba58f9-e2d7-42a0-b0e7-a11efd98236b"},
|
||||
[]string{"manyrels.onerel.manyrels.onerel.manyrels.onerel.manyrels.onerel.manyrels"},
|
||||
"maxExpandDepth reached",
|
||||
"demo4",
|
||||
[]string{"qzaqccwrmva4o1n"},
|
||||
[]string{"self_rel_many.self_rel_many.self_rel_many.self_rel_many.self_rel_many.self_rel_many.self_rel_many.self_rel_many.self_rel_many.self_rel_many.self_rel_many.self_rel_many"},
|
||||
func(c *models.Collection, ids []string) ([]*models.Record, error) {
|
||||
return app.Dao().FindRecordsByIds(c, ids, nil)
|
||||
return app.Dao().FindRecordsByIds(c.Id, ids, nil)
|
||||
},
|
||||
6,
|
||||
0,
|
||||
},
|
||||
{
|
||||
"simple indirect expand",
|
||||
"demo3",
|
||||
[]string{"lcl9d87w22ml6jy"},
|
||||
[]string{"demo4(rel_one_no_cascade_required)"},
|
||||
func(c *models.Collection, ids []string) ([]*models.Record, error) {
|
||||
return app.Dao().FindRecordsByIds(c.Id, ids, nil)
|
||||
},
|
||||
1,
|
||||
0,
|
||||
},
|
||||
{
|
||||
"nested indirect expand",
|
||||
"demo3",
|
||||
[]string{"lcl9d87w22ml6jy"},
|
||||
[]string{
|
||||
"demo4(rel_one_no_cascade_required).self_rel_many.self_rel_many.self_rel_one",
|
||||
},
|
||||
func(c *models.Collection, ids []string) ([]*models.Record, error) {
|
||||
return app.Dao().FindRecordsByIds(c.Id, ids, nil)
|
||||
},
|
||||
5,
|
||||
0,
|
||||
},
|
||||
}
|
||||
|
||||
for i, s := range scenarios {
|
||||
for _, s := range scenarios {
|
||||
ids := list.ToUniqueStringSlice(s.recordIds)
|
||||
records, _ := app.Dao().FindRecordsByIds(col, ids, nil)
|
||||
records, _ := app.Dao().FindRecordsByIds(s.collectionIdOrName, ids)
|
||||
failed := app.Dao().ExpandRecords(records, s.expands, s.fetchFunc)
|
||||
|
||||
if len(failed) != s.expectExpandFailures {
|
||||
t.Errorf("(%d) Expected %d failures, got %d: \n%v", i, s.expectExpandFailures, len(failed), failed)
|
||||
t.Errorf("[%s] Expected %d failures, got %d: \n%v", s.testName, s.expectExpandFailures, len(failed), failed)
|
||||
}
|
||||
|
||||
encoded, _ := json.Marshal(records)
|
||||
encodedStr := string(encoded)
|
||||
totalExpandProps := strings.Count(encodedStr, "@expand")
|
||||
totalExpandProps := strings.Count(encodedStr, schema.FieldNameExpand)
|
||||
|
||||
if s.expectExpandProps != totalExpandProps {
|
||||
t.Errorf("(%d) Expected %d @expand props, got %d: \n%v", i, s.expectExpandProps, totalExpandProps, encodedStr)
|
||||
t.Errorf("[%s] Expected %d expand props, got %d: \n%v", s.testName, s.expectExpandProps, totalExpandProps, encodedStr)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -170,109 +192,157 @@ func TestExpandRecord(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
col, _ := app.Dao().FindCollectionByNameOrId("demo4")
|
||||
|
||||
scenarios := []struct {
|
||||
testName string
|
||||
collectionIdOrName string
|
||||
recordId string
|
||||
expands []string
|
||||
fetchFunc daos.ExpandFetchFunc
|
||||
expectExpandProps int
|
||||
expectExpandFailures int
|
||||
}{
|
||||
// empty expand
|
||||
{
|
||||
"b8ba58f9-e2d7-42a0-b0e7-a11efd98236b",
|
||||
"empty expand",
|
||||
"demo4",
|
||||
"i9naidtvr6qsgb4",
|
||||
[]string{},
|
||||
func(c *models.Collection, ids []string) ([]*models.Record, error) {
|
||||
return app.Dao().FindRecordsByIds(c, ids, nil)
|
||||
return app.Dao().FindRecordsByIds(c.Id, ids, nil)
|
||||
},
|
||||
0,
|
||||
0,
|
||||
},
|
||||
// empty fetchFunc
|
||||
{
|
||||
"b8ba58f9-e2d7-42a0-b0e7-a11efd98236b",
|
||||
[]string{"onerel", "manyrels.onerel.manyrels"},
|
||||
"empty fetchFunc",
|
||||
"demo4",
|
||||
"i9naidtvr6qsgb4",
|
||||
[]string{"self_rel_one", "self_rel_many.self_rel_one"},
|
||||
nil,
|
||||
0,
|
||||
2,
|
||||
},
|
||||
// fetchFunc with error
|
||||
{
|
||||
"b8ba58f9-e2d7-42a0-b0e7-a11efd98236b",
|
||||
[]string{"onerel", "manyrels.onerel.manyrels"},
|
||||
"fetchFunc with error",
|
||||
"demo4",
|
||||
"i9naidtvr6qsgb4",
|
||||
[]string{"self_rel_one", "self_rel_many.self_rel_one"},
|
||||
func(c *models.Collection, ids []string) ([]*models.Record, error) {
|
||||
return nil, errors.New("test error")
|
||||
},
|
||||
0,
|
||||
2,
|
||||
},
|
||||
// invalid missing first level expand
|
||||
{
|
||||
"b8ba58f9-e2d7-42a0-b0e7-a11efd98236b",
|
||||
[]string{"invalid"},
|
||||
"missing relation field",
|
||||
"demo4",
|
||||
"i9naidtvr6qsgb4",
|
||||
[]string{"missing"},
|
||||
func(c *models.Collection, ids []string) ([]*models.Record, error) {
|
||||
return app.Dao().FindRecordsByIds(c, ids, nil)
|
||||
return app.Dao().FindRecordsByIds(c.Id, ids, nil)
|
||||
},
|
||||
0,
|
||||
1,
|
||||
},
|
||||
// invalid missing second level expand
|
||||
{
|
||||
"b8ba58f9-e2d7-42a0-b0e7-a11efd98236b",
|
||||
[]string{"manyrels.invalid"},
|
||||
"existing, but non-relation type field",
|
||||
"demo4",
|
||||
"i9naidtvr6qsgb4",
|
||||
[]string{"title"},
|
||||
func(c *models.Collection, ids []string) ([]*models.Record, error) {
|
||||
return app.Dao().FindRecordsByIds(c, ids, nil)
|
||||
return app.Dao().FindRecordsByIds(c.Id, ids, nil)
|
||||
},
|
||||
0,
|
||||
1,
|
||||
},
|
||||
// expand normalizations
|
||||
{
|
||||
"b8ba58f9-e2d7-42a0-b0e7-a11efd98236b",
|
||||
[]string{"manyrels.onerel.manyrels", "manyrels.onerel", "onerel", " onerel "},
|
||||
"invalid/missing second level expand",
|
||||
"demo4",
|
||||
"qzaqccwrmva4o1n",
|
||||
[]string{"rel_one_no_cascade.title"},
|
||||
func(c *models.Collection, ids []string) ([]*models.Record, error) {
|
||||
return app.Dao().FindRecordsByIds(c, ids, nil)
|
||||
return app.Dao().FindRecordsByIds(c.Id, ids, nil)
|
||||
},
|
||||
3,
|
||||
0,
|
||||
},
|
||||
// single expand
|
||||
{
|
||||
"b8ba58f9-e2d7-42a0-b0e7-a11efd98236b",
|
||||
[]string{"manyrels"},
|
||||
func(c *models.Collection, ids []string) ([]*models.Record, error) {
|
||||
return app.Dao().FindRecordsByIds(c, ids, nil)
|
||||
},
|
||||
1,
|
||||
},
|
||||
{
|
||||
"expand normalizations",
|
||||
"demo4",
|
||||
"qzaqccwrmva4o1n",
|
||||
[]string{
|
||||
"self_rel_one", "self_rel_many.self_rel_many.rel_one_no_cascade",
|
||||
"self_rel_many.self_rel_one.self_rel_many.self_rel_one.rel_one_no_cascade",
|
||||
"self_rel_many", "self_rel_many.",
|
||||
" self_rel_many ", "",
|
||||
},
|
||||
func(c *models.Collection, ids []string) ([]*models.Record, error) {
|
||||
return app.Dao().FindRecordsByIds(c.Id, ids, nil)
|
||||
},
|
||||
8,
|
||||
0,
|
||||
},
|
||||
// maxExpandDepth reached
|
||||
{
|
||||
"b8ba58f9-e2d7-42a0-b0e7-a11efd98236b",
|
||||
[]string{"manyrels.onerel.manyrels.onerel.manyrels.onerel.manyrels.onerel.manyrels"},
|
||||
"no rels to expand",
|
||||
"users",
|
||||
"oap640cot4yru2s",
|
||||
[]string{"rel"},
|
||||
func(c *models.Collection, ids []string) ([]*models.Record, error) {
|
||||
return app.Dao().FindRecordsByIds(c, ids, nil)
|
||||
return app.Dao().FindRecordsByIds(c.Id, ids, nil)
|
||||
},
|
||||
0,
|
||||
0,
|
||||
},
|
||||
{
|
||||
"maxExpandDepth reached",
|
||||
"demo4",
|
||||
"qzaqccwrmva4o1n",
|
||||
[]string{"self_rel_many.self_rel_many.self_rel_many.self_rel_many.self_rel_many.self_rel_many.self_rel_many.self_rel_many.self_rel_many.self_rel_many.self_rel_many.self_rel_many"},
|
||||
func(c *models.Collection, ids []string) ([]*models.Record, error) {
|
||||
return app.Dao().FindRecordsByIds(c.Id, ids, nil)
|
||||
},
|
||||
6,
|
||||
0,
|
||||
},
|
||||
{
|
||||
"simple indirect expand",
|
||||
"demo3",
|
||||
"lcl9d87w22ml6jy",
|
||||
[]string{"demo4(rel_one_no_cascade_required)"},
|
||||
func(c *models.Collection, ids []string) ([]*models.Record, error) {
|
||||
return app.Dao().FindRecordsByIds(c.Id, ids, nil)
|
||||
},
|
||||
1,
|
||||
0,
|
||||
},
|
||||
{
|
||||
"nested indirect expand",
|
||||
"demo3",
|
||||
"lcl9d87w22ml6jy",
|
||||
[]string{
|
||||
"demo4(rel_one_no_cascade_required).self_rel_many.self_rel_many.self_rel_one",
|
||||
},
|
||||
func(c *models.Collection, ids []string) ([]*models.Record, error) {
|
||||
return app.Dao().FindRecordsByIds(c.Id, ids, nil)
|
||||
},
|
||||
5,
|
||||
0,
|
||||
},
|
||||
}
|
||||
|
||||
for i, s := range scenarios {
|
||||
record, _ := app.Dao().FindFirstRecordByData(col, "id", s.recordId)
|
||||
for _, s := range scenarios {
|
||||
record, _ := app.Dao().FindRecordById(s.collectionIdOrName, s.recordId)
|
||||
failed := app.Dao().ExpandRecord(record, s.expands, s.fetchFunc)
|
||||
|
||||
if len(failed) != s.expectExpandFailures {
|
||||
t.Errorf("(%d) Expected %d failures, got %d: \n%v", i, s.expectExpandFailures, len(failed), failed)
|
||||
t.Errorf("[%s] Expected %d failures, got %d: \n%v", s.testName, s.expectExpandFailures, len(failed), failed)
|
||||
}
|
||||
|
||||
encoded, _ := json.Marshal(record)
|
||||
encodedStr := string(encoded)
|
||||
totalExpandProps := strings.Count(encodedStr, "@expand")
|
||||
totalExpandProps := strings.Count(encodedStr, schema.FieldNameExpand)
|
||||
|
||||
if s.expectExpandProps != totalExpandProps {
|
||||
t.Errorf("(%d) Expected %d @expand props, got %d: \n%v", i, s.expectExpandProps, totalExpandProps, encodedStr)
|
||||
t.Errorf("[%s] Expected %d expand props, got %d: \n%v", s.testName, s.expectExpandProps, totalExpandProps, encodedStr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -3,6 +3,8 @@ package daos_test
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/pocketbase/dbx"
|
||||
@ -16,7 +18,10 @@ func TestRecordQuery(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
collection, _ := app.Dao().FindCollectionByNameOrId("demo")
|
||||
collection, err := app.Dao().FindCollectionByNameOrId("demo1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
expected := fmt.Sprintf("SELECT `%s`.* FROM `%s`", collection.Name, collection.Name)
|
||||
|
||||
@ -30,30 +35,50 @@ func TestFindRecordById(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
collection, _ := app.Dao().FindCollectionByNameOrId("demo")
|
||||
|
||||
scenarios := []struct {
|
||||
id string
|
||||
filter func(q *dbx.SelectQuery) error
|
||||
expectError bool
|
||||
collectionIdOrName string
|
||||
id string
|
||||
filter1 func(q *dbx.SelectQuery) error
|
||||
filter2 func(q *dbx.SelectQuery) error
|
||||
expectError bool
|
||||
}{
|
||||
{"00000000-bafd-48f7-b8b7-090638afe209", nil, true},
|
||||
{"b5c2ffc2-bafd-48f7-b8b7-090638afe209", nil, false},
|
||||
{"b5c2ffc2-bafd-48f7-b8b7-090638afe209", func(q *dbx.SelectQuery) error {
|
||||
{"demo2", "missing", nil, nil, true},
|
||||
{"missing", "0yxhwia2amd8gec", nil, nil, true},
|
||||
{"demo2", "0yxhwia2amd8gec", nil, nil, false},
|
||||
{"demo2", "0yxhwia2amd8gec", func(q *dbx.SelectQuery) error {
|
||||
q.AndWhere(dbx.HashExp{"title": "missing"})
|
||||
return nil
|
||||
}, true},
|
||||
{"b5c2ffc2-bafd-48f7-b8b7-090638afe209", func(q *dbx.SelectQuery) error {
|
||||
}, nil, true},
|
||||
{"demo2", "0yxhwia2amd8gec", func(q *dbx.SelectQuery) error {
|
||||
return errors.New("test error")
|
||||
}, nil, true},
|
||||
{"demo2", "0yxhwia2amd8gec", func(q *dbx.SelectQuery) error {
|
||||
q.AndWhere(dbx.HashExp{"title": "test3"})
|
||||
return nil
|
||||
}, nil, false},
|
||||
{"demo2", "0yxhwia2amd8gec", func(q *dbx.SelectQuery) error {
|
||||
q.AndWhere(dbx.HashExp{"title": "test3"})
|
||||
return nil
|
||||
}, func(q *dbx.SelectQuery) error {
|
||||
q.AndWhere(dbx.HashExp{"active": false})
|
||||
return nil
|
||||
}, true},
|
||||
{"b5c2ffc2-bafd-48f7-b8b7-090638afe209", func(q *dbx.SelectQuery) error {
|
||||
q.AndWhere(dbx.HashExp{"title": "lorem"})
|
||||
{"sz5l5z67tg7gku0", "0yxhwia2amd8gec", func(q *dbx.SelectQuery) error {
|
||||
q.AndWhere(dbx.HashExp{"title": "test3"})
|
||||
return nil
|
||||
}, func(q *dbx.SelectQuery) error {
|
||||
q.AndWhere(dbx.HashExp{"active": true})
|
||||
return nil
|
||||
}, false},
|
||||
}
|
||||
|
||||
for i, scenario := range scenarios {
|
||||
record, err := app.Dao().FindRecordById(collection, scenario.id, scenario.filter)
|
||||
record, err := app.Dao().FindRecordById(
|
||||
scenario.collectionIdOrName,
|
||||
scenario.id,
|
||||
scenario.filter1,
|
||||
scenario.filter2,
|
||||
)
|
||||
|
||||
hasErr := err != nil
|
||||
if hasErr != scenario.expectError {
|
||||
@ -70,25 +95,34 @@ func TestFindRecordsByIds(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
collection, _ := app.Dao().FindCollectionByNameOrId("demo")
|
||||
|
||||
scenarios := []struct {
|
||||
ids []string
|
||||
filter func(q *dbx.SelectQuery) error
|
||||
expectTotal int
|
||||
expectError bool
|
||||
collectionIdOrName string
|
||||
ids []string
|
||||
filter1 func(q *dbx.SelectQuery) error
|
||||
filter2 func(q *dbx.SelectQuery) error
|
||||
expectTotal int
|
||||
expectError bool
|
||||
}{
|
||||
{[]string{}, nil, 0, false},
|
||||
{[]string{"00000000-bafd-48f7-b8b7-090638afe209"}, nil, 0, false},
|
||||
{[]string{"b5c2ffc2-bafd-48f7-b8b7-090638afe209"}, nil, 1, false},
|
||||
{"demo2", []string{}, nil, nil, 0, false},
|
||||
{"demo2", []string{""}, nil, nil, 0, false},
|
||||
{"demo2", []string{"missing"}, nil, nil, 0, false},
|
||||
{"missing", []string{"0yxhwia2amd8gec"}, nil, nil, 0, true},
|
||||
{"demo2", []string{"0yxhwia2amd8gec"}, nil, nil, 1, false},
|
||||
{"sz5l5z67tg7gku0", []string{"0yxhwia2amd8gec"}, nil, nil, 1, false},
|
||||
{
|
||||
[]string{"b5c2ffc2-bafd-48f7-b8b7-090638afe209", "848a1dea-5ddd-42d6-a00d-030547bffcfe"},
|
||||
"demo2",
|
||||
[]string{"0yxhwia2amd8gec", "llvuca81nly1qls"},
|
||||
nil,
|
||||
nil,
|
||||
2,
|
||||
false,
|
||||
},
|
||||
{
|
||||
[]string{"b5c2ffc2-bafd-48f7-b8b7-090638afe209", "848a1dea-5ddd-42d6-a00d-030547bffcfe"},
|
||||
"demo2",
|
||||
[]string{"0yxhwia2amd8gec", "llvuca81nly1qls"},
|
||||
func(q *dbx.SelectQuery) error {
|
||||
return nil // empty filter
|
||||
},
|
||||
func(q *dbx.SelectQuery) error {
|
||||
return errors.New("test error")
|
||||
},
|
||||
@ -96,9 +130,25 @@ func TestFindRecordsByIds(t *testing.T) {
|
||||
true,
|
||||
},
|
||||
{
|
||||
[]string{"b5c2ffc2-bafd-48f7-b8b7-090638afe209", "848a1dea-5ddd-42d6-a00d-030547bffcfe"},
|
||||
"demo2",
|
||||
[]string{"0yxhwia2amd8gec", "llvuca81nly1qls"},
|
||||
func(q *dbx.SelectQuery) error {
|
||||
q.AndWhere(dbx.Like("title", "test").Match(true, true))
|
||||
q.AndWhere(dbx.HashExp{"active": true})
|
||||
return nil
|
||||
},
|
||||
nil,
|
||||
1,
|
||||
false,
|
||||
},
|
||||
{
|
||||
"sz5l5z67tg7gku0",
|
||||
[]string{"0yxhwia2amd8gec", "llvuca81nly1qls"},
|
||||
func(q *dbx.SelectQuery) error {
|
||||
q.AndWhere(dbx.HashExp{"active": true})
|
||||
return nil
|
||||
},
|
||||
func(q *dbx.SelectQuery) error {
|
||||
q.AndWhere(dbx.Not(dbx.HashExp{"title": ""}))
|
||||
return nil
|
||||
},
|
||||
1,
|
||||
@ -107,7 +157,12 @@ func TestFindRecordsByIds(t *testing.T) {
|
||||
}
|
||||
|
||||
for i, scenario := range scenarios {
|
||||
records, err := app.Dao().FindRecordsByIds(collection, scenario.ids, scenario.filter)
|
||||
records, err := app.Dao().FindRecordsByIds(
|
||||
scenario.collectionIdOrName,
|
||||
scenario.ids,
|
||||
scenario.filter1,
|
||||
scenario.filter2,
|
||||
)
|
||||
|
||||
hasErr := err != nil
|
||||
if hasErr != scenario.expectError {
|
||||
@ -131,35 +186,53 @@ func TestFindRecordsByExpr(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
collection, _ := app.Dao().FindCollectionByNameOrId("demo")
|
||||
|
||||
scenarios := []struct {
|
||||
expression dbx.Expression
|
||||
expectIds []string
|
||||
expectError bool
|
||||
collectionIdOrName string
|
||||
expressions []dbx.Expression
|
||||
expectIds []string
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
"missing",
|
||||
nil,
|
||||
[]string{},
|
||||
true,
|
||||
},
|
||||
{
|
||||
dbx.HashExp{"id": 123},
|
||||
"demo2",
|
||||
nil,
|
||||
[]string{
|
||||
"achvryl401bhse3",
|
||||
"llvuca81nly1qls",
|
||||
"0yxhwia2amd8gec",
|
||||
},
|
||||
false,
|
||||
},
|
||||
{
|
||||
"demo2",
|
||||
[]dbx.Expression{
|
||||
nil,
|
||||
dbx.HashExp{"id": "123"},
|
||||
},
|
||||
[]string{},
|
||||
false,
|
||||
},
|
||||
{
|
||||
dbx.Like("title", "test").Match(true, true),
|
||||
"sz5l5z67tg7gku0",
|
||||
[]dbx.Expression{
|
||||
dbx.Like("title", "test").Match(true, true),
|
||||
dbx.HashExp{"active": true},
|
||||
},
|
||||
[]string{
|
||||
"848a1dea-5ddd-42d6-a00d-030547bffcfe",
|
||||
"577bd676-aacb-4072-b7da-99d00ee210a4",
|
||||
"achvryl401bhse3",
|
||||
"0yxhwia2amd8gec",
|
||||
},
|
||||
false,
|
||||
},
|
||||
}
|
||||
|
||||
for i, scenario := range scenarios {
|
||||
records, err := app.Dao().FindRecordsByExpr(collection, scenario.expression)
|
||||
records, err := app.Dao().FindRecordsByExpr(scenario.collectionIdOrName, scenario.expressions...)
|
||||
|
||||
hasErr := err != nil
|
||||
if hasErr != scenario.expectError {
|
||||
@ -183,42 +256,52 @@ func TestFindFirstRecordByData(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
collection, _ := app.Dao().FindCollectionByNameOrId("demo")
|
||||
|
||||
scenarios := []struct {
|
||||
key string
|
||||
value any
|
||||
expectId string
|
||||
expectError bool
|
||||
collectionIdOrName string
|
||||
key string
|
||||
value any
|
||||
expectId string
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
"missing",
|
||||
"id",
|
||||
"llvuca81nly1qls",
|
||||
"llvuca81nly1qls",
|
||||
true,
|
||||
},
|
||||
{
|
||||
"demo2",
|
||||
"",
|
||||
"848a1dea-5ddd-42d6-a00d-030547bffcfe",
|
||||
"llvuca81nly1qls",
|
||||
"",
|
||||
true,
|
||||
},
|
||||
{
|
||||
"demo2",
|
||||
"id",
|
||||
"invalid",
|
||||
"",
|
||||
true,
|
||||
},
|
||||
{
|
||||
"demo2",
|
||||
"id",
|
||||
"848a1dea-5ddd-42d6-a00d-030547bffcfe",
|
||||
"848a1dea-5ddd-42d6-a00d-030547bffcfe",
|
||||
"llvuca81nly1qls",
|
||||
"llvuca81nly1qls",
|
||||
false,
|
||||
},
|
||||
{
|
||||
"sz5l5z67tg7gku0",
|
||||
"title",
|
||||
"lorem",
|
||||
"b5c2ffc2-bafd-48f7-b8b7-090638afe209",
|
||||
"test3",
|
||||
"0yxhwia2amd8gec",
|
||||
false,
|
||||
},
|
||||
}
|
||||
|
||||
for i, scenario := range scenarios {
|
||||
record, err := app.Dao().FindFirstRecordByData(collection, scenario.key, scenario.value)
|
||||
record, err := app.Dao().FindFirstRecordByData(scenario.collectionIdOrName, scenario.key, scenario.value)
|
||||
|
||||
hasErr := err != nil
|
||||
if hasErr != scenario.expectError {
|
||||
@ -236,32 +319,44 @@ func TestIsRecordValueUnique(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
collection, _ := app.Dao().FindCollectionByNameOrId("demo4")
|
||||
|
||||
testManyRelsId1 := "df55c8ff-45ef-4c82-8aed-6e2183fe1125"
|
||||
testManyRelsId2 := "b84cd893-7119-43c9-8505-3c4e22da28a9"
|
||||
testManyRelsId1 := "bgs820n361vj1qd"
|
||||
testManyRelsId2 := "4q1xlclmfloku33"
|
||||
testManyRelsId3 := "oap640cot4yru2s"
|
||||
|
||||
scenarios := []struct {
|
||||
key string
|
||||
value any
|
||||
excludeId string
|
||||
expected bool
|
||||
collectionIdOrName string
|
||||
key string
|
||||
value any
|
||||
excludeIds []string
|
||||
expected bool
|
||||
}{
|
||||
{"", "", "", false},
|
||||
{"missing", "unique", "", false},
|
||||
{"title", "unique", "", true},
|
||||
{"title", "demo1", "", false},
|
||||
{"title", "demo1", "054f9f24-0a0a-4e09-87b1-bc7ff2b336a2", true},
|
||||
{"manyrels", []string{testManyRelsId2}, "", false},
|
||||
{"manyrels", []any{testManyRelsId2}, "", false},
|
||||
// with exclude
|
||||
{"manyrels", []string{testManyRelsId1, testManyRelsId2}, "b8ba58f9-e2d7-42a0-b0e7-a11efd98236b", true},
|
||||
// reverse order
|
||||
{"manyrels", []string{testManyRelsId2, testManyRelsId1}, "", true},
|
||||
{"demo2", "", "", nil, false},
|
||||
{"demo2", "", "", []string{""}, false},
|
||||
{"demo2", "missing", "unique", nil, false},
|
||||
{"demo2", "title", "unique", nil, true},
|
||||
{"demo2", "title", "unique", []string{}, true},
|
||||
{"demo2", "title", "unique", []string{""}, true},
|
||||
{"demo2", "title", "test1", []string{""}, false},
|
||||
{"demo2", "title", "test1", []string{"llvuca81nly1qls"}, true},
|
||||
{"demo1", "rel_many", []string{testManyRelsId3}, nil, false},
|
||||
{"wsmn24bux7wo113", "rel_many", []any{testManyRelsId3}, []string{""}, false},
|
||||
{"wsmn24bux7wo113", "rel_many", []any{testManyRelsId3}, []string{"84nmscqy84lsi1t"}, true},
|
||||
// mixed json array order
|
||||
{"demo1", "rel_many", []string{testManyRelsId1, testManyRelsId3, testManyRelsId2}, nil, true},
|
||||
// username special case-insensitive match
|
||||
{"users", "username", "test2_username", nil, false},
|
||||
{"users", "username", "TEST2_USERNAME", nil, false},
|
||||
{"users", "username", "new_username", nil, true},
|
||||
{"users", "username", "TEST2_USERNAME", []string{"oap640cot4yru2s"}, true},
|
||||
}
|
||||
|
||||
for i, scenario := range scenarios {
|
||||
result := app.Dao().IsRecordValueUnique(collection, scenario.key, scenario.value, scenario.excludeId)
|
||||
result := app.Dao().IsRecordValueUnique(
|
||||
scenario.collectionIdOrName,
|
||||
scenario.key,
|
||||
scenario.value,
|
||||
scenario.excludeIds...,
|
||||
)
|
||||
|
||||
if result != scenario.expected {
|
||||
t.Errorf("(%d) Expected %v, got %v", i, scenario.expected, result)
|
||||
@ -269,43 +364,164 @@ func TestIsRecordValueUnique(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindUserRelatedRecords(t *testing.T) {
|
||||
func TestFindAuthRecordByToken(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
u0 := &models.User{}
|
||||
u1, _ := app.Dao().FindUserByEmail("test3@example.com")
|
||||
u2, _ := app.Dao().FindUserByEmail("test2@example.com")
|
||||
|
||||
scenarios := []struct {
|
||||
user *models.User
|
||||
expectedIds []string
|
||||
token string
|
||||
baseKey string
|
||||
expectedEmail string
|
||||
expectError bool
|
||||
}{
|
||||
{u0, []string{}},
|
||||
{u1, []string{
|
||||
"94568ca2-0bee-49d7-b749-06cb97956fd9", // demo2
|
||||
"fc69274d-ca5c-416a-b9ef-561b101cfbb1", // profile
|
||||
}},
|
||||
{u2, []string{
|
||||
"b2d5e39d-f569-4cc1-b593-3f074ad026bf", // profile
|
||||
}},
|
||||
// invalid auth token
|
||||
{
|
||||
"eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.H2KKcIXiAfxvuXMFzizo1SgsinDP4hcWhD3pYoP4Nqw",
|
||||
app.Settings().RecordAuthToken.Secret,
|
||||
"",
|
||||
true,
|
||||
},
|
||||
// expired token
|
||||
{
|
||||
"eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoxNjQwOTkxNjYxfQ.HqvpCpM0RAk3Qu9PfCMuZsk_DKh9UYuzFLwXBMTZd1w",
|
||||
app.Settings().RecordAuthToken.Secret,
|
||||
"",
|
||||
true,
|
||||
},
|
||||
// wrong base key (password reset token secret instead of auth secret)
|
||||
{
|
||||
"eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
|
||||
app.Settings().RecordPasswordResetToken.Secret,
|
||||
"",
|
||||
true,
|
||||
},
|
||||
// valid token and base key but with deleted/missing collection
|
||||
{
|
||||
"eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoibWlzc2luZyIsImV4cCI6MjIwODk4NTI2MX0.0oEHQpdpHp0Nb3VN8La0ssg-SjwWKiRl_k1mUGxdKlU",
|
||||
app.Settings().RecordAuthToken.Secret,
|
||||
"test@example.com",
|
||||
true,
|
||||
},
|
||||
// valid token
|
||||
{
|
||||
"eyJhbGciOiJIUzI1NiJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.UwD8JvkbQtXpymT09d7J6fdA0aP9g4FJ1GPh_ggEkzc",
|
||||
app.Settings().RecordAuthToken.Secret,
|
||||
"test@example.com",
|
||||
false,
|
||||
},
|
||||
}
|
||||
|
||||
for i, scenario := range scenarios {
|
||||
records, err := app.Dao().FindUserRelatedRecords(scenario.user)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
record, err := app.Dao().FindAuthRecordByToken(scenario.token, scenario.baseKey)
|
||||
|
||||
if len(records) != len(scenario.expectedIds) {
|
||||
t.Errorf("(%d) Expected %d records, got %d (%v)", i, len(scenario.expectedIds), len(records), records)
|
||||
hasErr := err != nil
|
||||
if hasErr != scenario.expectError {
|
||||
t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, scenario.expectError, hasErr, err)
|
||||
continue
|
||||
}
|
||||
|
||||
for _, r := range records {
|
||||
if !list.ExistInSlice(r.Id, scenario.expectedIds) {
|
||||
t.Errorf("(%d) Couldn't find %s in %v", i, r.Id, scenario.expectedIds)
|
||||
}
|
||||
if !scenario.expectError && record.Email() != scenario.expectedEmail {
|
||||
t.Errorf("(%d) Expected record model %s, got %s", i, scenario.expectedEmail, record.Email())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindAuthRecordByEmail(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
scenarios := []struct {
|
||||
collectionIdOrName string
|
||||
email string
|
||||
expectError bool
|
||||
}{
|
||||
{"missing", "test@example.com", true},
|
||||
{"demo2", "test@example.com", true},
|
||||
{"users", "missing@example.com", true},
|
||||
{"users", "test@example.com", false},
|
||||
{"clients", "test2@example.com", false},
|
||||
}
|
||||
|
||||
for i, scenario := range scenarios {
|
||||
record, err := app.Dao().FindAuthRecordByEmail(scenario.collectionIdOrName, scenario.email)
|
||||
|
||||
hasErr := err != nil
|
||||
if hasErr != scenario.expectError {
|
||||
t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, scenario.expectError, hasErr, err)
|
||||
continue
|
||||
}
|
||||
|
||||
if !scenario.expectError && record.Email() != scenario.email {
|
||||
t.Errorf("(%d) Expected record with email %s, got %s", i, scenario.email, record.Email())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindAuthRecordByUsername(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
scenarios := []struct {
|
||||
collectionIdOrName string
|
||||
username string
|
||||
expectError bool
|
||||
}{
|
||||
{"missing", "test_username", true},
|
||||
{"demo2", "test_username", true},
|
||||
{"users", "missing", true},
|
||||
{"users", "test2_username", false},
|
||||
{"users", "TEST2_USERNAME", false}, // case insensitive check
|
||||
{"clients", "clients43362", false},
|
||||
}
|
||||
|
||||
for i, scenario := range scenarios {
|
||||
record, err := app.Dao().FindAuthRecordByUsername(scenario.collectionIdOrName, scenario.username)
|
||||
|
||||
hasErr := err != nil
|
||||
if hasErr != scenario.expectError {
|
||||
t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, scenario.expectError, hasErr, err)
|
||||
continue
|
||||
}
|
||||
|
||||
if !scenario.expectError && !strings.EqualFold(record.Username(), scenario.username) {
|
||||
t.Errorf("(%d) Expected record with username %s, got %s", i, scenario.username, record.Username())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuggestUniqueAuthRecordUsername(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
scenarios := []struct {
|
||||
collectionIdOrName string
|
||||
baseUsername string
|
||||
expectedPattern string
|
||||
}{
|
||||
// missing collection
|
||||
{"missing", "test2_username", `^test2_username\d{12}$`},
|
||||
// not an auth collection
|
||||
{"demo2", "test2_username", `^test2_username\d{12}$`},
|
||||
// auth collection with unique base username
|
||||
{"users", "new_username", `^new_username$`},
|
||||
{"users", "NEW_USERNAME", `^NEW_USERNAME$`},
|
||||
// auth collection with existing username
|
||||
{"users", "test2_username", `^test2_username\d{3}$`},
|
||||
{"users", "TEST2_USERNAME", `^TEST2_USERNAME\d{3}$`},
|
||||
}
|
||||
|
||||
for i, scenario := range scenarios {
|
||||
username := app.Dao().SuggestUniqueAuthRecordUsername(
|
||||
scenario.collectionIdOrName,
|
||||
scenario.baseUsername,
|
||||
)
|
||||
|
||||
pattern, err := regexp.Compile(scenario.expectedPattern)
|
||||
if err != nil {
|
||||
t.Errorf("[%d] Invalid username pattern %q: %v", i, scenario.expectedPattern, err)
|
||||
}
|
||||
if !pattern.MatchString(username) {
|
||||
t.Fatalf("Expected username to match %s, got username %s", scenario.expectedPattern, username)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -314,32 +530,64 @@ func TestSaveRecord(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
collection, _ := app.Dao().FindCollectionByNameOrId("demo")
|
||||
collection, _ := app.Dao().FindCollectionByNameOrId("demo2")
|
||||
|
||||
// create
|
||||
// ---
|
||||
r1 := models.NewRecord(collection)
|
||||
r1.SetDataValue("title", "test_new")
|
||||
r1.Set("title", "test_new")
|
||||
err1 := app.Dao().SaveRecord(r1)
|
||||
if err1 != nil {
|
||||
t.Fatal(err1)
|
||||
}
|
||||
newR1, _ := app.Dao().FindFirstRecordByData(collection, "title", "test_new")
|
||||
if newR1 == nil || newR1.Id != r1.Id || newR1.GetStringDataValue("title") != r1.GetStringDataValue("title") {
|
||||
t.Errorf("Expected to find record %v, got %v", r1, newR1)
|
||||
newR1, _ := app.Dao().FindFirstRecordByData(collection.Id, "title", "test_new")
|
||||
if newR1 == nil || newR1.Id != r1.Id || newR1.GetString("title") != r1.GetString("title") {
|
||||
t.Fatalf("Expected to find record %v, got %v", r1, newR1)
|
||||
}
|
||||
|
||||
// update
|
||||
// ---
|
||||
r2, _ := app.Dao().FindFirstRecordByData(collection, "id", "b5c2ffc2-bafd-48f7-b8b7-090638afe209")
|
||||
r2.SetDataValue("title", "test_update")
|
||||
r2, _ := app.Dao().FindFirstRecordByData(collection.Id, "id", "0yxhwia2amd8gec")
|
||||
r2.Set("title", "test_update")
|
||||
err2 := app.Dao().SaveRecord(r2)
|
||||
if err2 != nil {
|
||||
t.Fatal(err2)
|
||||
}
|
||||
newR2, _ := app.Dao().FindFirstRecordByData(collection, "title", "test_update")
|
||||
if newR2 == nil || newR2.Id != r2.Id || newR2.GetStringDataValue("title") != r2.GetStringDataValue("title") {
|
||||
t.Errorf("Expected to find record %v, got %v", r2, newR2)
|
||||
newR2, _ := app.Dao().FindFirstRecordByData(collection.Id, "title", "test_update")
|
||||
if newR2 == nil || newR2.Id != r2.Id || newR2.GetString("title") != r2.GetString("title") {
|
||||
t.Fatalf("Expected to find record %v, got %v", r2, newR2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveRecordWithIdFromOtherCollection(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
baseCollection, _ := app.Dao().FindCollectionByNameOrId("demo2")
|
||||
authCollection, _ := app.Dao().FindCollectionByNameOrId("nologin")
|
||||
|
||||
// base collection test
|
||||
r1 := models.NewRecord(baseCollection)
|
||||
r1.Set("title", "test_new")
|
||||
r1.Set("id", "mk5fmymtx4wsprk") // existing id of demo3 record
|
||||
r1.MarkAsNew()
|
||||
if err := app.Dao().SaveRecord(r1); err != nil {
|
||||
t.Fatalf("Expected nil, got error %v", err)
|
||||
}
|
||||
|
||||
// auth collection test
|
||||
r2 := models.NewRecord(authCollection)
|
||||
r2.Set("username", "test_new")
|
||||
r2.Set("id", "gk390qegs4y47wn") // existing id of "clients" record
|
||||
r2.MarkAsNew()
|
||||
if err := app.Dao().SaveRecord(r2); err == nil {
|
||||
t.Fatal("Expected error, got nil")
|
||||
}
|
||||
|
||||
// try again with unique id
|
||||
r2.Set("id", "unique_id")
|
||||
if err := app.Dao().SaveRecord(r2); err != nil {
|
||||
t.Fatalf("Expected nil, got error %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@ -347,41 +595,50 @@ func TestDeleteRecord(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
demo, _ := app.Dao().FindCollectionByNameOrId("demo")
|
||||
demo2, _ := app.Dao().FindCollectionByNameOrId("demo2")
|
||||
demoCollection, _ := app.Dao().FindCollectionByNameOrId("demo2")
|
||||
|
||||
// delete unsaved record
|
||||
// ---
|
||||
rec1 := models.NewRecord(demo)
|
||||
err1 := app.Dao().DeleteRecord(rec1)
|
||||
if err1 == nil {
|
||||
t.Fatal("(rec1) Didn't expect to succeed deleting new record")
|
||||
rec0 := models.NewRecord(demoCollection)
|
||||
if err := app.Dao().DeleteRecord(rec0); err == nil {
|
||||
t.Fatal("(rec0) Didn't expect to succeed deleting unsaved record")
|
||||
}
|
||||
|
||||
// delete existing record + external auths
|
||||
// ---
|
||||
rec1, _ := app.Dao().FindRecordById("users", "4q1xlclmfloku33")
|
||||
if err := app.Dao().DeleteRecord(rec1); err != nil {
|
||||
t.Fatalf("(rec1) Expected nil, got error %v", err)
|
||||
}
|
||||
// check if it was really deleted
|
||||
if refreshed, _ := app.Dao().FindRecordById(rec1.Collection().Id, rec1.Id); refreshed != nil {
|
||||
t.Fatalf("(rec1) Expected record to be deleted, got %v", refreshed)
|
||||
}
|
||||
// check if the external auths were deleted
|
||||
if auths, _ := app.Dao().FindAllExternalAuthsByRecord(rec1); len(auths) > 0 {
|
||||
t.Fatalf("(rec1) Expected external auths to be deleted, got %v", auths)
|
||||
}
|
||||
|
||||
// delete existing record while being part of a non-cascade required relation
|
||||
// ---
|
||||
rec2, _ := app.Dao().FindFirstRecordByData(demo, "id", "848a1dea-5ddd-42d6-a00d-030547bffcfe")
|
||||
err2 := app.Dao().DeleteRecord(rec2)
|
||||
if err2 == nil {
|
||||
rec2, _ := app.Dao().FindRecordById("demo3", "7nwo8tuiatetxdm")
|
||||
if err := app.Dao().DeleteRecord(rec2); err == nil {
|
||||
t.Fatalf("(rec2) Expected error, got nil")
|
||||
}
|
||||
|
||||
// delete existing record
|
||||
// delete existing record + cascade
|
||||
// ---
|
||||
rec3, _ := app.Dao().FindFirstRecordByData(demo, "id", "577bd676-aacb-4072-b7da-99d00ee210a4")
|
||||
err3 := app.Dao().DeleteRecord(rec3)
|
||||
if err3 != nil {
|
||||
t.Fatalf("(rec3) Expected nil, got error %v", err3)
|
||||
rec3, _ := app.Dao().FindRecordById("users", "oap640cot4yru2s")
|
||||
if err := app.Dao().DeleteRecord(rec3); err != nil {
|
||||
t.Fatalf("(rec3) Expected nil, got error %v", err)
|
||||
}
|
||||
|
||||
// check if it was really deleted
|
||||
rec3, _ = app.Dao().FindRecordById(demo, rec3.Id, nil)
|
||||
rec3, _ = app.Dao().FindRecordById(rec3.Collection().Id, rec3.Id)
|
||||
if rec3 != nil {
|
||||
t.Fatalf("(rec3) Expected record to be deleted, got %v", rec3)
|
||||
}
|
||||
|
||||
// check if the operation cascaded
|
||||
rel, _ := app.Dao().FindFirstRecordByData(demo2, "id", "63c2ab80-84ab-4057-a592-4604a731f78f")
|
||||
rel, _ := app.Dao().FindRecordById("demo1", "84nmscqy84lsi1t")
|
||||
if rel != nil {
|
||||
t.Fatalf("(rec3) Expected the delete to cascade, found relation %v", rel)
|
||||
}
|
||||
@ -391,16 +648,16 @@ func TestSyncRecordTableSchema(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
oldCollection, err := app.Dao().FindCollectionByNameOrId("demo")
|
||||
oldCollection, err := app.Dao().FindCollectionByNameOrId("demo2")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
updatedCollection, err := app.Dao().FindCollectionByNameOrId("demo")
|
||||
updatedCollection, err := app.Dao().FindCollectionByNameOrId("demo2")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
updatedCollection.Name = "demo_renamed"
|
||||
updatedCollection.Schema.RemoveField(updatedCollection.Schema.GetFieldByName("file").Id)
|
||||
updatedCollection.Schema.RemoveField(updatedCollection.Schema.GetFieldByName("active").Id)
|
||||
updatedCollection.Schema.AddField(
|
||||
&schema.SchemaField{
|
||||
Name: "new_field",
|
||||
@ -421,6 +678,7 @@ func TestSyncRecordTableSchema(t *testing.T) {
|
||||
expectedTableName string
|
||||
expectedColumns []string
|
||||
}{
|
||||
// new base collection
|
||||
{
|
||||
&models.Collection{
|
||||
Name: "new_table",
|
||||
@ -435,12 +693,32 @@ func TestSyncRecordTableSchema(t *testing.T) {
|
||||
"new_table",
|
||||
[]string{"id", "created", "updated", "test"},
|
||||
},
|
||||
// new auth collection
|
||||
{
|
||||
&models.Collection{
|
||||
Name: "new_table_auth",
|
||||
Type: models.CollectionTypeAuth,
|
||||
Schema: schema.NewSchema(
|
||||
&schema.SchemaField{
|
||||
Name: "test",
|
||||
Type: schema.FieldTypeText,
|
||||
},
|
||||
),
|
||||
},
|
||||
nil,
|
||||
"new_table_auth",
|
||||
[]string{
|
||||
"id", "created", "updated", "test",
|
||||
"username", "email", "verified", "emailVisibility",
|
||||
"tokenKey", "passwordHash", "lastResetSentAt", "lastVerificationSentAt",
|
||||
},
|
||||
},
|
||||
// no changes
|
||||
{
|
||||
oldCollection,
|
||||
oldCollection,
|
||||
"demo",
|
||||
[]string{"id", "created", "updated", "title", "file"},
|
||||
"demo3",
|
||||
[]string{"id", "created", "updated", "title", "active"},
|
||||
},
|
||||
// renamed table, deleted column, renamed columnd and new column
|
||||
{
|
||||
|
@ -59,7 +59,7 @@ func TestRequestsStats(t *testing.T) {
|
||||
|
||||
tests.MockRequestLogsData(app)
|
||||
|
||||
expected := `[{"total":1,"date":"2022-05-01 10:00:00.000"},{"total":1,"date":"2022-05-02 10:00:00.000"}]`
|
||||
expected := `[{"total":1,"date":"2022-05-01 10:00:00.000Z"},{"total":1,"date":"2022-05-02 10:00:00.000Z"}]`
|
||||
|
||||
now := time.Now().UTC().Format(types.DefaultDateLayout)
|
||||
exp := dbx.NewExp("[[created]] <= {:date}", dbx.Params{"date": now})
|
||||
@ -84,10 +84,10 @@ func TestDeleteOldRequests(t *testing.T) {
|
||||
date string
|
||||
expectedTotal int
|
||||
}{
|
||||
{"2022-01-01 10:00:00.000", 2}, // no requests to delete before that time
|
||||
{"2022-05-01 11:00:00.000", 1}, // only 1 request should have left
|
||||
{"2022-05-03 11:00:00.000", 0}, // no more requests should have left
|
||||
{"2022-05-04 11:00:00.000", 0}, // no more requests should have left
|
||||
{"2022-01-01 10:00:00.000Z", 2}, // no requests to delete before that time
|
||||
{"2022-05-01 11:00:00.000Z", 1}, // only 1 request should have left
|
||||
{"2022-05-03 11:00:00.000Z", 0}, // no more requests should have left
|
||||
{"2022-05-04 11:00:00.000Z", 0}, // no more requests should have left
|
||||
}
|
||||
|
||||
for i, scenario := range scenarios {
|
||||
|
282
daos/user.go
282
daos/user.go
@ -1,282 +0,0 @@
|
||||
package daos
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/models/schema"
|
||||
"github.com/pocketbase/pocketbase/tools/list"
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
)
|
||||
|
||||
// UserQuery returns a new User model select query.
|
||||
func (dao *Dao) UserQuery() *dbx.SelectQuery {
|
||||
return dao.ModelQuery(&models.User{})
|
||||
}
|
||||
|
||||
// LoadProfile loads the profile record associated to the provided user.
|
||||
func (dao *Dao) LoadProfile(user *models.User) error {
|
||||
collection, err := dao.FindCollectionByNameOrId(models.ProfileCollectionName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
profile, err := dao.FindFirstRecordByData(collection, models.ProfileCollectionUserFieldName, user.Id)
|
||||
if err != nil && err != sql.ErrNoRows {
|
||||
return err
|
||||
}
|
||||
|
||||
user.Profile = profile
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoadProfiles loads the profile records associated to the provided users list.
|
||||
func (dao *Dao) LoadProfiles(users []*models.User) error {
|
||||
collection, err := dao.FindCollectionByNameOrId(models.ProfileCollectionName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// extract user ids
|
||||
ids := make([]string, len(users))
|
||||
usersMap := map[string]*models.User{}
|
||||
for i, user := range users {
|
||||
ids[i] = user.Id
|
||||
usersMap[user.Id] = user
|
||||
}
|
||||
|
||||
profiles, err := dao.FindRecordsByExpr(collection, dbx.HashExp{
|
||||
models.ProfileCollectionUserFieldName: list.ToInterfaceSlice(ids),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// populate each user.Profile member
|
||||
for _, profile := range profiles {
|
||||
userId := profile.GetStringDataValue(models.ProfileCollectionUserFieldName)
|
||||
user, ok := usersMap[userId]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
user.Profile = profile
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// FindUserById finds a single User model by its id.
|
||||
//
|
||||
// This method also auto loads the related user profile record
|
||||
// into the found model.
|
||||
func (dao *Dao) FindUserById(id string) (*models.User, error) {
|
||||
model := &models.User{}
|
||||
|
||||
err := dao.UserQuery().
|
||||
AndWhere(dbx.HashExp{"id": id}).
|
||||
Limit(1).
|
||||
One(model)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// try to load the user profile (if exist)
|
||||
if err := dao.LoadProfile(model); err != nil {
|
||||
log.Println(err)
|
||||
}
|
||||
|
||||
return model, nil
|
||||
}
|
||||
|
||||
// FindUserByEmail finds a single User model by its non-empty email address.
|
||||
//
|
||||
// This method also auto loads the related user profile record
|
||||
// into the found model.
|
||||
func (dao *Dao) FindUserByEmail(email string) (*models.User, error) {
|
||||
model := &models.User{}
|
||||
|
||||
err := dao.UserQuery().
|
||||
AndWhere(dbx.Not(dbx.HashExp{"email": ""})).
|
||||
AndWhere(dbx.HashExp{"email": email}).
|
||||
Limit(1).
|
||||
One(model)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// try to load the user profile (if exist)
|
||||
if err := dao.LoadProfile(model); err != nil {
|
||||
log.Println(err)
|
||||
}
|
||||
|
||||
return model, nil
|
||||
}
|
||||
|
||||
// FindUserByToken finds the user associated with the provided JWT token.
|
||||
// Returns an error if the JWT token is invalid or expired.
|
||||
//
|
||||
// This method also auto loads the related user profile record
|
||||
// into the found model.
|
||||
func (dao *Dao) FindUserByToken(token string, baseTokenKey string) (*models.User, error) {
|
||||
unverifiedClaims, err := security.ParseUnverifiedJWT(token)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// check required claims
|
||||
id, _ := unverifiedClaims["id"].(string)
|
||||
if id == "" {
|
||||
return nil, errors.New("Missing or invalid token claims.")
|
||||
}
|
||||
|
||||
user, err := dao.FindUserById(id)
|
||||
if err != nil || user == nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
verificationKey := user.TokenKey + baseTokenKey
|
||||
|
||||
// verify token signature
|
||||
if _, err := security.ParseJWT(token, verificationKey); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// IsUserEmailUnique checks if the provided email address is not
|
||||
// already in use by other users.
|
||||
func (dao *Dao) IsUserEmailUnique(email string, excludeId string) bool {
|
||||
if email == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
var exists bool
|
||||
err := dao.UserQuery().
|
||||
Select("count(*)").
|
||||
AndWhere(dbx.Not(dbx.HashExp{"id": excludeId})).
|
||||
AndWhere(dbx.HashExp{"email": email}).
|
||||
Limit(1).
|
||||
Row(&exists)
|
||||
|
||||
return err == nil && !exists
|
||||
}
|
||||
|
||||
// DeleteUser deletes the provided User model.
|
||||
//
|
||||
// This method will also cascade the delete operation to all
|
||||
// Record models that references the provided User model
|
||||
// (delete or set to NULL, depending on the related user shema field settings).
|
||||
//
|
||||
// The delete operation may fail if the user is part of a required
|
||||
// reference in another Record model (aka. cannot be deleted or set to NULL).
|
||||
func (dao *Dao) DeleteUser(user *models.User) error {
|
||||
// fetch related records
|
||||
// note: the select is outside of the transaction to prevent SQLITE_LOCKED error when mixing read&write in a single transaction
|
||||
relatedRecords, err := dao.FindUserRelatedRecords(user)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return dao.RunInTransaction(func(txDao *Dao) error {
|
||||
// check if related records has to be deleted (if `CascadeDelete` is set)
|
||||
// OR
|
||||
// just unset the user related fields (if they are not required)
|
||||
// -----------------------------------------------------------
|
||||
recordsLoop:
|
||||
for _, record := range relatedRecords {
|
||||
var needSave bool
|
||||
|
||||
for _, field := range record.Collection().Schema.Fields() {
|
||||
if field.Type != schema.FieldTypeUser {
|
||||
continue // not a user field
|
||||
}
|
||||
|
||||
ids := record.GetStringSliceDataValue(field.Name)
|
||||
|
||||
// unset the user id
|
||||
for i := len(ids) - 1; i >= 0; i-- {
|
||||
if ids[i] == user.Id {
|
||||
ids = append(ids[:i], ids[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
options, _ := field.Options.(*schema.UserOptions)
|
||||
|
||||
// cascade delete
|
||||
// (only if there are no other user references in case of multiple select)
|
||||
if options.CascadeDelete && len(ids) == 0 {
|
||||
if err := txDao.DeleteRecord(record); err != nil {
|
||||
return err
|
||||
}
|
||||
// no need to further iterate the user fields (the record is deleted)
|
||||
continue recordsLoop
|
||||
}
|
||||
|
||||
if field.Required && len(ids) == 0 {
|
||||
return fmt.Errorf("Failed delete the user because a record exist with required user reference to the current model (%q, %q).", record.Id, record.Collection().Name)
|
||||
}
|
||||
|
||||
// apply the reference changes
|
||||
record.SetDataValue(field.Name, field.PrepareValue(ids))
|
||||
needSave = true
|
||||
}
|
||||
|
||||
if needSave {
|
||||
if err := txDao.SaveRecord(record); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
// -----------------------------------------------------------
|
||||
|
||||
return txDao.Delete(user)
|
||||
})
|
||||
}
|
||||
|
||||
// SaveUser upserts the provided User model.
|
||||
//
|
||||
// An empty profile record will be created if the user
|
||||
// doesn't have a profile record set yet.
|
||||
func (dao *Dao) SaveUser(user *models.User) error {
|
||||
profileCollection, err := dao.FindCollectionByNameOrId(models.ProfileCollectionName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// fetch the related user profile record (if exist)
|
||||
var userProfile *models.Record
|
||||
if user.HasId() {
|
||||
userProfile, _ = dao.FindFirstRecordByData(
|
||||
profileCollection,
|
||||
models.ProfileCollectionUserFieldName,
|
||||
user.Id,
|
||||
)
|
||||
}
|
||||
|
||||
return dao.RunInTransaction(func(txDao *Dao) error {
|
||||
if err := txDao.Save(user); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// create default/empty profile record if doesn't exist
|
||||
if userProfile == nil {
|
||||
userProfile = models.NewRecord(profileCollection)
|
||||
userProfile.SetDataValue(models.ProfileCollectionUserFieldName, user.Id)
|
||||
if err := txDao.Save(userProfile); err != nil {
|
||||
return err
|
||||
}
|
||||
user.Profile = userProfile
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
@ -1,275 +0,0 @@
|
||||
package daos_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
)
|
||||
|
||||
func TestUserQuery(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
expected := "SELECT {{_users}}.* FROM `_users`"
|
||||
|
||||
sql := app.Dao().UserQuery().Build().SQL()
|
||||
if sql != expected {
|
||||
t.Errorf("Expected sql %s, got %s", expected, sql)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadProfile(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
// try to load missing profile (shouldn't return an error)
|
||||
// ---
|
||||
newUser := &models.User{}
|
||||
err1 := app.Dao().LoadProfile(newUser)
|
||||
if err1 != nil {
|
||||
t.Fatalf("Expected nil, got error %v", err1)
|
||||
}
|
||||
|
||||
// try to load existing profile
|
||||
// ---
|
||||
existingUser, _ := app.Dao().FindUserByEmail("test@example.com")
|
||||
existingUser.Profile = nil // reset
|
||||
|
||||
err2 := app.Dao().LoadProfile(existingUser)
|
||||
if err2 != nil {
|
||||
t.Fatal(err2)
|
||||
}
|
||||
|
||||
if existingUser.Profile == nil {
|
||||
t.Fatal("Expected user profile to be loaded, got nil")
|
||||
}
|
||||
|
||||
if existingUser.Profile.GetStringDataValue("name") != "test" {
|
||||
t.Fatalf("Expected profile.name to be 'test', got %s", existingUser.Profile.GetStringDataValue("name"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadProfiles(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
u0 := &models.User{}
|
||||
u1, _ := app.Dao().FindUserByEmail("test@example.com")
|
||||
u2, _ := app.Dao().FindUserByEmail("test2@example.com")
|
||||
|
||||
users := []*models.User{u0, u1, u2}
|
||||
|
||||
err := app.Dao().LoadProfiles(users)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if u0.Profile != nil {
|
||||
t.Errorf("Expected profile to be nil for u0, got %v", u0.Profile)
|
||||
}
|
||||
if u1.Profile == nil {
|
||||
t.Errorf("Expected profile to be set for u1, got nil")
|
||||
}
|
||||
if u2.Profile == nil {
|
||||
t.Errorf("Expected profile to be set for u2, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindUserById(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
scenarios := []struct {
|
||||
id string
|
||||
expectError bool
|
||||
}{
|
||||
{"00000000-2b4a-a26b-4d01-42d3c3d77bc8", true},
|
||||
{"97cc3d3d-6ba2-383f-b42a-7bc84d27410c", false},
|
||||
}
|
||||
|
||||
for i, scenario := range scenarios {
|
||||
user, err := app.Dao().FindUserById(scenario.id)
|
||||
|
||||
hasErr := err != nil
|
||||
if hasErr != scenario.expectError {
|
||||
t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, scenario.expectError, hasErr, err)
|
||||
}
|
||||
|
||||
if user != nil && user.Id != scenario.id {
|
||||
t.Errorf("(%d) Expected user with id %s, got %s", i, scenario.id, user.Id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindUserByEmail(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
scenarios := []struct {
|
||||
email string
|
||||
expectError bool
|
||||
}{
|
||||
{"", true},
|
||||
{"invalid", true},
|
||||
{"missing@example.com", true},
|
||||
{"test@example.com", false},
|
||||
}
|
||||
|
||||
for i, scenario := range scenarios {
|
||||
user, err := app.Dao().FindUserByEmail(scenario.email)
|
||||
|
||||
hasErr := err != nil
|
||||
if hasErr != scenario.expectError {
|
||||
t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, scenario.expectError, hasErr, err)
|
||||
continue
|
||||
}
|
||||
|
||||
if !scenario.expectError && user.Email != scenario.email {
|
||||
t.Errorf("(%d) Expected user with email %s, got %s", i, scenario.email, user.Email)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindUserByToken(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
scenarios := []struct {
|
||||
token string
|
||||
baseKey string
|
||||
expectedEmail string
|
||||
expectError bool
|
||||
}{
|
||||
// invalid base key (password reset key for auth token)
|
||||
{
|
||||
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
|
||||
app.Settings().UserPasswordResetToken.Secret,
|
||||
"",
|
||||
true,
|
||||
},
|
||||
// expired token
|
||||
{
|
||||
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxNjQwOTkxNjYxfQ.RrSG5NwysI38DEZrIQiz3lUgI6sEuYGTll_jLRbBSiw",
|
||||
app.Settings().UserAuthToken.Secret,
|
||||
"",
|
||||
true,
|
||||
},
|
||||
// valid token
|
||||
{
|
||||
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
|
||||
app.Settings().UserAuthToken.Secret,
|
||||
"test@example.com",
|
||||
false,
|
||||
},
|
||||
}
|
||||
|
||||
for i, scenario := range scenarios {
|
||||
user, err := app.Dao().FindUserByToken(scenario.token, scenario.baseKey)
|
||||
|
||||
hasErr := err != nil
|
||||
if hasErr != scenario.expectError {
|
||||
t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, scenario.expectError, hasErr, err)
|
||||
continue
|
||||
}
|
||||
|
||||
if !scenario.expectError && user.Email != scenario.expectedEmail {
|
||||
t.Errorf("(%d) Expected user model %s, got %s", i, scenario.expectedEmail, user.Email)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsUserEmailUnique(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
scenarios := []struct {
|
||||
email string
|
||||
excludeId string
|
||||
expected bool
|
||||
}{
|
||||
{"", "", false},
|
||||
{"test@example.com", "", false},
|
||||
{"new@example.com", "", true},
|
||||
{"test@example.com", "4d0197cc-2b4a-3f83-a26b-d77bc8423d3c", true},
|
||||
}
|
||||
|
||||
for i, scenario := range scenarios {
|
||||
result := app.Dao().IsUserEmailUnique(scenario.email, scenario.excludeId)
|
||||
if result != scenario.expected {
|
||||
t.Errorf("(%d) Expected %v, got %v", i, scenario.expected, result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteUser(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
// try to delete unsaved user
|
||||
// ---
|
||||
err1 := app.Dao().DeleteUser(&models.User{})
|
||||
if err1 == nil {
|
||||
t.Fatal("Expected error, got nil")
|
||||
}
|
||||
|
||||
// try to delete existing user
|
||||
// ---
|
||||
user, _ := app.Dao().FindUserByEmail("test3@example.com")
|
||||
err2 := app.Dao().DeleteUser(user)
|
||||
if err2 != nil {
|
||||
t.Fatalf("Expected nil, got error %v", err2)
|
||||
}
|
||||
|
||||
// check if the delete operation was cascaded to the profiles collection (record delete)
|
||||
profilesCol, _ := app.Dao().FindCollectionByNameOrId(models.ProfileCollectionName)
|
||||
profile, _ := app.Dao().FindRecordById(profilesCol, user.Profile.Id, nil)
|
||||
if profile != nil {
|
||||
t.Fatalf("Expected user profile to be deleted, got %v", profile)
|
||||
}
|
||||
|
||||
// check if delete operation was cascaded to the related demo2 collection (null set)
|
||||
demo2Col, _ := app.Dao().FindCollectionByNameOrId("demo2")
|
||||
record, _ := app.Dao().FindRecordById(demo2Col, "94568ca2-0bee-49d7-b749-06cb97956fd9", nil)
|
||||
if record == nil {
|
||||
t.Fatal("Expected to found related record, got nil")
|
||||
}
|
||||
if record.GetStringDataValue("user") != "" {
|
||||
t.Fatalf("Expected user field to be set to empty string, got %v", record.GetStringDataValue("user"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveUser(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
// create
|
||||
// ---
|
||||
u1 := &models.User{}
|
||||
u1.Email = "new@example.com"
|
||||
u1.SetPassword("123456")
|
||||
err1 := app.Dao().SaveUser(u1)
|
||||
if err1 != nil {
|
||||
t.Fatal(err1)
|
||||
}
|
||||
u1, refreshErr1 := app.Dao().FindUserByEmail("new@example.com")
|
||||
if refreshErr1 != nil {
|
||||
t.Fatalf("Expected user with email new@example.com to have been created, got error %v", refreshErr1)
|
||||
}
|
||||
if u1.Profile == nil {
|
||||
t.Fatalf("Expected creating a user to create also an empty profile record")
|
||||
}
|
||||
|
||||
// update
|
||||
// ---
|
||||
u2, _ := app.Dao().FindUserByEmail("test@example.com")
|
||||
u2.Email = "test_update@example.com"
|
||||
err2 := app.Dao().SaveUser(u2)
|
||||
if err2 != nil {
|
||||
t.Fatal(err2)
|
||||
}
|
||||
u2, refreshErr2 := app.Dao().FindUserByEmail("test_update@example.com")
|
||||
if u2 == nil {
|
||||
t.Fatalf("Couldn't find user with email test_update@example.com (%v)", refreshErr2)
|
||||
}
|
||||
}
|
@ -35,7 +35,7 @@ func main() {
|
||||
|
||||
app.OnBeforeServe().Add(func(e *core.ServeEvent) error {
|
||||
// serves static files from the provided public dir (if exists)
|
||||
e.Router.GET("/*", apis.StaticDirectoryHandler(os.DirFS(publicDirFlag), false))
|
||||
e.Router.GET("/*", apis.StaticDirectoryHandler(os.DirFS(publicDirFlag), true))
|
||||
|
||||
return nil
|
||||
})
|
||||
|
@ -10,53 +10,36 @@ import (
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
)
|
||||
|
||||
// AdminLogin specifies an admin email/pass login form.
|
||||
// AdminLogin is an admin email/pass login form.
|
||||
type AdminLogin struct {
|
||||
config AdminLoginConfig
|
||||
app core.App
|
||||
dao *daos.Dao
|
||||
|
||||
Email string `form:"email" json:"email"`
|
||||
Identity string `form:"identity" json:"identity"`
|
||||
Password string `form:"password" json:"password"`
|
||||
}
|
||||
|
||||
// AdminLoginConfig is the [AdminLogin] factory initializer config.
|
||||
// NewAdminLogin creates a new [AdminLogin] form initialized with
|
||||
// the provided [core.App] instance.
|
||||
//
|
||||
// NB! App is a required struct member.
|
||||
type AdminLoginConfig struct {
|
||||
App core.App
|
||||
Dao *daos.Dao
|
||||
}
|
||||
|
||||
// NewAdminLogin creates a new [AdminLogin] form with initializer
|
||||
// config created from the provided [core.App] instance.
|
||||
//
|
||||
// If you want to submit the form as part of another transaction, use
|
||||
// [NewAdminLoginWithConfig] with explicitly set Dao.
|
||||
// If you want to submit the form as part of a transaction,
|
||||
// you can change the default Dao via [SetDao()].
|
||||
func NewAdminLogin(app core.App) *AdminLogin {
|
||||
return NewAdminLoginWithConfig(AdminLoginConfig{
|
||||
App: app,
|
||||
})
|
||||
return &AdminLogin{
|
||||
app: app,
|
||||
dao: app.Dao(),
|
||||
}
|
||||
}
|
||||
|
||||
// NewAdminLoginWithConfig creates a new [AdminLogin] form
|
||||
// with the provided config or panics on invalid configuration.
|
||||
func NewAdminLoginWithConfig(config AdminLoginConfig) *AdminLogin {
|
||||
form := &AdminLogin{config: config}
|
||||
|
||||
if form.config.App == nil {
|
||||
panic("Missing required config.App instance.")
|
||||
}
|
||||
|
||||
if form.config.Dao == nil {
|
||||
form.config.Dao = form.config.App.Dao()
|
||||
}
|
||||
|
||||
return form
|
||||
// SetDao replaces the default form Dao instance with the provided one.
|
||||
func (form *AdminLogin) SetDao(dao *daos.Dao) {
|
||||
form.dao = dao
|
||||
}
|
||||
|
||||
// Validate makes the form validatable by implementing [validation.Validatable] interface.
|
||||
func (form *AdminLogin) Validate() error {
|
||||
return validation.ValidateStruct(form,
|
||||
validation.Field(&form.Email, validation.Required, validation.Length(1, 255), is.EmailFormat),
|
||||
validation.Field(&form.Identity, validation.Required, validation.Length(1, 255), is.EmailFormat),
|
||||
validation.Field(&form.Password, validation.Required, validation.Length(1, 255)),
|
||||
)
|
||||
}
|
||||
@ -68,7 +51,7 @@ func (form *AdminLogin) Submit() (*models.Admin, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
admin, err := form.config.Dao.FindAdminByEmail(form.Email)
|
||||
admin, err := form.dao.FindAdminByEmail(form.Identity)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
@ -7,48 +7,7 @@ import (
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
)
|
||||
|
||||
func TestAdminLoginPanic(t *testing.T) {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("The form did not panic")
|
||||
}
|
||||
}()
|
||||
|
||||
forms.NewAdminLogin(nil)
|
||||
}
|
||||
|
||||
func TestAdminLoginValidate(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
form := forms.NewAdminLogin(app)
|
||||
|
||||
scenarios := []struct {
|
||||
email string
|
||||
password string
|
||||
expectError bool
|
||||
}{
|
||||
{"", "", true},
|
||||
{"", "123", true},
|
||||
{"test@example.com", "", true},
|
||||
{"test", "123", true},
|
||||
{"test@example.com", "123", false},
|
||||
}
|
||||
|
||||
for i, s := range scenarios {
|
||||
form.Email = s.email
|
||||
form.Password = s.password
|
||||
|
||||
err := form.Validate()
|
||||
|
||||
hasErr := err != nil
|
||||
if hasErr != s.expectError {
|
||||
t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, s.expectError, hasErr, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminLoginSubmit(t *testing.T) {
|
||||
func TestAdminLoginValidateAndSubmit(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@ -62,14 +21,14 @@ func TestAdminLoginSubmit(t *testing.T) {
|
||||
{"", "", true},
|
||||
{"", "1234567890", true},
|
||||
{"test@example.com", "", true},
|
||||
{"test", "1234567890", true},
|
||||
{"test", "test", true},
|
||||
{"missing@example.com", "1234567890", true},
|
||||
{"test@example.com", "123456789", true},
|
||||
{"test@example.com", "1234567890", false},
|
||||
}
|
||||
|
||||
for i, s := range scenarios {
|
||||
form.Email = s.email
|
||||
form.Identity = s.email
|
||||
form.Password = s.password
|
||||
|
||||
admin, err := form.Submit()
|
||||
|
@ -8,55 +8,41 @@ import (
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
)
|
||||
|
||||
// AdminPasswordResetConfirm specifies an admin password reset confirmation form.
|
||||
// AdminPasswordResetConfirm is an admin password reset confirmation form.
|
||||
type AdminPasswordResetConfirm struct {
|
||||
config AdminPasswordResetConfirmConfig
|
||||
app core.App
|
||||
dao *daos.Dao
|
||||
|
||||
Token string `form:"token" json:"token"`
|
||||
Password string `form:"password" json:"password"`
|
||||
PasswordConfirm string `form:"passwordConfirm" json:"passwordConfirm"`
|
||||
}
|
||||
|
||||
// AdminPasswordResetConfirmConfig is the [AdminPasswordResetConfirm] factory initializer config.
|
||||
//
|
||||
// NB! App is required struct member.
|
||||
type AdminPasswordResetConfirmConfig struct {
|
||||
App core.App
|
||||
Dao *daos.Dao
|
||||
}
|
||||
|
||||
// NewAdminPasswordResetConfirm creates a new [AdminPasswordResetConfirm]
|
||||
// form with initializer config created from the provided [core.App] instance.
|
||||
// form initialized with from the provided [core.App] instance.
|
||||
//
|
||||
// If you want to submit the form as part of another transaction, use
|
||||
// [NewAdminPasswordResetConfirmWithConfig] with explicitly set Dao.
|
||||
// If you want to submit the form as part of a transaction,
|
||||
// you can change the default Dao via [SetDao()].
|
||||
func NewAdminPasswordResetConfirm(app core.App) *AdminPasswordResetConfirm {
|
||||
return NewAdminPasswordResetConfirmWithConfig(AdminPasswordResetConfirmConfig{
|
||||
App: app,
|
||||
})
|
||||
return &AdminPasswordResetConfirm{
|
||||
app: app,
|
||||
dao: app.Dao(),
|
||||
}
|
||||
}
|
||||
|
||||
// NewAdminPasswordResetConfirmWithConfig creates a new [AdminPasswordResetConfirm]
|
||||
// form with the provided config or panics on invalid configuration.
|
||||
func NewAdminPasswordResetConfirmWithConfig(config AdminPasswordResetConfirmConfig) *AdminPasswordResetConfirm {
|
||||
form := &AdminPasswordResetConfirm{config: config}
|
||||
|
||||
if form.config.App == nil {
|
||||
panic("Missing required config.App instance.")
|
||||
}
|
||||
|
||||
if form.config.Dao == nil {
|
||||
form.config.Dao = form.config.App.Dao()
|
||||
}
|
||||
|
||||
return form
|
||||
// SetDao replaces the form Dao instance with the provided one.
|
||||
//
|
||||
// This is useful if you want to use a specific transaction Dao instance
|
||||
// instead of the default app.Dao().
|
||||
func (form *AdminPasswordResetConfirm) SetDao(dao *daos.Dao) {
|
||||
form.dao = dao
|
||||
}
|
||||
|
||||
// Validate makes the form validatable by implementing [validation.Validatable] interface.
|
||||
func (form *AdminPasswordResetConfirm) Validate() error {
|
||||
return validation.ValidateStruct(form,
|
||||
validation.Field(&form.Token, validation.Required, validation.By(form.checkToken)),
|
||||
validation.Field(&form.Password, validation.Required, validation.Length(10, 100)),
|
||||
validation.Field(&form.Password, validation.Required, validation.Length(10, 72)),
|
||||
validation.Field(&form.PasswordConfirm, validation.Required, validation.By(validators.Compare(form.Password))),
|
||||
)
|
||||
}
|
||||
@ -67,10 +53,7 @@ func (form *AdminPasswordResetConfirm) checkToken(value any) error {
|
||||
return nil // nothing to check
|
||||
}
|
||||
|
||||
admin, err := form.config.Dao.FindAdminByToken(
|
||||
v,
|
||||
form.config.App.Settings().AdminPasswordResetToken.Secret,
|
||||
)
|
||||
admin, err := form.dao.FindAdminByToken(v, form.app.Settings().AdminPasswordResetToken.Secret)
|
||||
if err != nil || admin == nil {
|
||||
return validation.NewError("validation_invalid_token", "Invalid or expired token.")
|
||||
}
|
||||
@ -85,9 +68,9 @@ func (form *AdminPasswordResetConfirm) Submit() (*models.Admin, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
admin, err := form.config.Dao.FindAdminByToken(
|
||||
admin, err := form.dao.FindAdminByToken(
|
||||
form.Token,
|
||||
form.config.App.Settings().AdminPasswordResetToken.Secret,
|
||||
form.app.Settings().AdminPasswordResetToken.Secret,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@ -97,7 +80,7 @@ func (form *AdminPasswordResetConfirm) Submit() (*models.Admin, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := form.config.Dao.SaveAdmin(admin); err != nil {
|
||||
if err := form.dao.SaveAdmin(admin); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
|
@ -8,17 +8,7 @@ import (
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
)
|
||||
|
||||
func TestAdminPasswordResetPanic(t *testing.T) {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("The form did not panic")
|
||||
}
|
||||
}()
|
||||
|
||||
forms.NewAdminPasswordResetConfirm(nil)
|
||||
}
|
||||
|
||||
func TestAdminPasswordResetConfirmValidate(t *testing.T) {
|
||||
func TestAdminPasswordResetConfirmValidateAndSubmit(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@ -38,64 +28,23 @@ func TestAdminPasswordResetConfirmValidate(t *testing.T) {
|
||||
{"test", "123", "123", true},
|
||||
{
|
||||
// expired
|
||||
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTY0MTAxMzIwMH0.Gp_1b5WVhqjj2o3nJhNUlJmpdiwFLXN72LbMP-26gjA",
|
||||
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImV4cCI6MTY0MDk5MTY2MX0.GLwCOsgWTTEKXTK-AyGW838de1OeZGIjfHH0FoRLqZg",
|
||||
"1234567890",
|
||||
"1234567890",
|
||||
true,
|
||||
},
|
||||
{
|
||||
// valid
|
||||
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg5MzQ3NDAwMH0.72IhlL_5CpNGE0ZKM7sV9aAKa3wxQaMZdDiHBo0orpw",
|
||||
"1234567890",
|
||||
"1234567890",
|
||||
false,
|
||||
},
|
||||
}
|
||||
|
||||
for i, s := range scenarios {
|
||||
form.Token = s.token
|
||||
form.Password = s.password
|
||||
form.PasswordConfirm = s.passwordConfirm
|
||||
|
||||
err := form.Validate()
|
||||
|
||||
hasErr := err != nil
|
||||
if hasErr != s.expectError {
|
||||
t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, s.expectError, hasErr, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminPasswordResetConfirmSubmit(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
form := forms.NewAdminPasswordResetConfirm(app)
|
||||
|
||||
scenarios := []struct {
|
||||
token string
|
||||
password string
|
||||
passwordConfirm string
|
||||
expectError bool
|
||||
}{
|
||||
{"", "", "", true},
|
||||
{"", "123", "", true},
|
||||
{"", "", "123", true},
|
||||
{"test", "", "", true},
|
||||
{"test", "123", "", true},
|
||||
{"test", "123", "123", true},
|
||||
{
|
||||
// expired
|
||||
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTY0MTAxMzIwMH0.Gp_1b5WVhqjj2o3nJhNUlJmpdiwFLXN72LbMP-26gjA",
|
||||
"1234567890",
|
||||
// valid with mismatched passwords
|
||||
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImV4cCI6MjIwODk4MTYwMH0.kwFEler6KSMKJNstuaSDvE1QnNdCta5qSnjaIQ0hhhc",
|
||||
"1234567890",
|
||||
"1234567891",
|
||||
true,
|
||||
},
|
||||
{
|
||||
// valid
|
||||
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg5MzQ3NDAwMH0.72IhlL_5CpNGE0ZKM7sV9aAKa3wxQaMZdDiHBo0orpw",
|
||||
"1234567890",
|
||||
"1234567890",
|
||||
// valid with matching passwords
|
||||
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhZG1pbiIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImV4cCI6MjIwODk4MTYwMH0.kwFEler6KSMKJNstuaSDvE1QnNdCta5qSnjaIQ0hhhc",
|
||||
"1234567891",
|
||||
"1234567891",
|
||||
false,
|
||||
},
|
||||
}
|
||||
@ -110,6 +59,7 @@ func TestAdminPasswordResetConfirmSubmit(t *testing.T) {
|
||||
hasErr := err != nil
|
||||
if hasErr != s.expectError {
|
||||
t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, s.expectError, hasErr, err)
|
||||
continue
|
||||
}
|
||||
|
||||
if s.expectError {
|
||||
|
@ -12,48 +12,31 @@ import (
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
)
|
||||
|
||||
// AdminPasswordResetRequest specifies an admin password reset request form.
|
||||
// AdminPasswordResetRequest is an admin password reset request form.
|
||||
type AdminPasswordResetRequest struct {
|
||||
config AdminPasswordResetRequestConfig
|
||||
app core.App
|
||||
dao *daos.Dao
|
||||
resendThreshold float64 // in seconds
|
||||
|
||||
Email string `form:"email" json:"email"`
|
||||
}
|
||||
|
||||
// AdminPasswordResetRequestConfig is the [AdminPasswordResetRequest] factory initializer config.
|
||||
//
|
||||
// NB! App is required struct member.
|
||||
type AdminPasswordResetRequestConfig struct {
|
||||
App core.App
|
||||
Dao *daos.Dao
|
||||
ResendThreshold float64 // in seconds
|
||||
}
|
||||
|
||||
// NewAdminPasswordResetRequest creates a new [AdminPasswordResetRequest]
|
||||
// form with initializer config created from the provided [core.App] instance.
|
||||
// form initialized with from the provided [core.App] instance.
|
||||
//
|
||||
// If you want to submit the form as part of another transaction, use
|
||||
// [NewAdminPasswordResetRequestWithConfig] with explicitly set Dao.
|
||||
// If you want to submit the form as part of a transaction,
|
||||
// you can change the default Dao via [SetDao()].
|
||||
func NewAdminPasswordResetRequest(app core.App) *AdminPasswordResetRequest {
|
||||
return NewAdminPasswordResetRequestWithConfig(AdminPasswordResetRequestConfig{
|
||||
App: app,
|
||||
ResendThreshold: 120, // 2min
|
||||
})
|
||||
return &AdminPasswordResetRequest{
|
||||
app: app,
|
||||
dao: app.Dao(),
|
||||
resendThreshold: 120, // 2min
|
||||
}
|
||||
}
|
||||
|
||||
// NewAdminPasswordResetRequestWithConfig creates a new [AdminPasswordResetRequest]
|
||||
// form with the provided config or panics on invalid configuration.
|
||||
func NewAdminPasswordResetRequestWithConfig(config AdminPasswordResetRequestConfig) *AdminPasswordResetRequest {
|
||||
form := &AdminPasswordResetRequest{config: config}
|
||||
|
||||
if form.config.App == nil {
|
||||
panic("Missing required config.App instance.")
|
||||
}
|
||||
|
||||
if form.config.Dao == nil {
|
||||
form.config.Dao = form.config.App.Dao()
|
||||
}
|
||||
|
||||
return form
|
||||
// SetDao replaces the default form Dao instance with the provided one.
|
||||
func (form *AdminPasswordResetRequest) SetDao(dao *daos.Dao) {
|
||||
form.dao = dao
|
||||
}
|
||||
|
||||
// Validate makes the form validatable by implementing [validation.Validatable] interface.
|
||||
@ -77,23 +60,23 @@ func (form *AdminPasswordResetRequest) Submit() error {
|
||||
return err
|
||||
}
|
||||
|
||||
admin, err := form.config.Dao.FindAdminByEmail(form.Email)
|
||||
admin, err := form.dao.FindAdminByEmail(form.Email)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
lastResetSentAt := admin.LastResetSentAt.Time()
|
||||
if now.Sub(lastResetSentAt).Seconds() < form.config.ResendThreshold {
|
||||
if now.Sub(lastResetSentAt).Seconds() < form.resendThreshold {
|
||||
return errors.New("You have already requested a password reset.")
|
||||
}
|
||||
|
||||
if err := mails.SendAdminPasswordReset(form.config.App, admin); err != nil {
|
||||
if err := mails.SendAdminPasswordReset(form.app, admin); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// update last sent timestamp
|
||||
admin.LastResetSentAt = types.NowDateTime()
|
||||
|
||||
return form.config.Dao.SaveAdmin(admin)
|
||||
return form.dao.SaveAdmin(admin)
|
||||
}
|
||||
|
@ -7,46 +7,7 @@ import (
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
)
|
||||
|
||||
func TestAdminPasswordResetRequestPanic(t *testing.T) {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("The form did not panic")
|
||||
}
|
||||
}()
|
||||
|
||||
forms.NewAdminPasswordResetRequest(nil)
|
||||
}
|
||||
|
||||
func TestAdminPasswordResetRequestValidate(t *testing.T) {
|
||||
testApp, _ := tests.NewTestApp()
|
||||
defer testApp.Cleanup()
|
||||
|
||||
form := forms.NewAdminPasswordResetRequest(testApp)
|
||||
|
||||
scenarios := []struct {
|
||||
email string
|
||||
expectError bool
|
||||
}{
|
||||
{"", true},
|
||||
{"", true},
|
||||
{"invalid", true},
|
||||
{"missing@example.com", false}, // doesn't check for existing admin
|
||||
{"test@example.com", false},
|
||||
}
|
||||
|
||||
for i, s := range scenarios {
|
||||
form.Email = s.email
|
||||
|
||||
err := form.Validate()
|
||||
|
||||
hasErr := err != nil
|
||||
if hasErr != s.expectError {
|
||||
t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, s.expectError, hasErr, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminPasswordResetRequestSubmit(t *testing.T) {
|
||||
func TestAdminPasswordResetRequestValidateAndSubmit(t *testing.T) {
|
||||
testApp, _ := tests.NewTestApp()
|
||||
defer testApp.Cleanup()
|
||||
|
||||
|
@ -9,10 +9,11 @@ import (
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
)
|
||||
|
||||
// AdminUpsert specifies a [models.Admin] upsert (create/update) form.
|
||||
// AdminUpsert is a [models.Admin] upsert (create/update) form.
|
||||
type AdminUpsert struct {
|
||||
config AdminUpsertConfig
|
||||
admin *models.Admin
|
||||
app core.App
|
||||
dao *daos.Dao
|
||||
admin *models.Admin
|
||||
|
||||
Id string `form:"id" json:"id"`
|
||||
Avatar int `form:"avatar" json:"avatar"`
|
||||
@ -21,41 +22,17 @@ type AdminUpsert struct {
|
||||
PasswordConfirm string `form:"passwordConfirm" json:"passwordConfirm"`
|
||||
}
|
||||
|
||||
// AdminUpsertConfig is the [AdminUpsert] factory initializer config.
|
||||
//
|
||||
// NB! App is a required struct member.
|
||||
type AdminUpsertConfig struct {
|
||||
App core.App
|
||||
Dao *daos.Dao
|
||||
}
|
||||
|
||||
// NewAdminUpsert creates a new [AdminUpsert] form with initializer
|
||||
// config created from the provided [core.App] and [models.Admin] instances
|
||||
// (for create you could pass a pointer to an empty Admin - `&models.Admin{}`).
|
||||
//
|
||||
// If you want to submit the form as part of another transaction, use
|
||||
// [NewAdminUpsertWithConfig] with explicitly set Dao.
|
||||
// If you want to submit the form as part of a transaction,
|
||||
// you can change the default Dao via [SetDao()].
|
||||
func NewAdminUpsert(app core.App, admin *models.Admin) *AdminUpsert {
|
||||
return NewAdminUpsertWithConfig(AdminUpsertConfig{
|
||||
App: app,
|
||||
}, admin)
|
||||
}
|
||||
|
||||
// NewAdminUpsertWithConfig creates a new [AdminUpsert] form
|
||||
// with the provided config and [models.Admin] instance or panics on invalid configuration
|
||||
// (for create you could pass a pointer to an empty Admin - `&models.Admin{}`).
|
||||
func NewAdminUpsertWithConfig(config AdminUpsertConfig, admin *models.Admin) *AdminUpsert {
|
||||
form := &AdminUpsert{
|
||||
config: config,
|
||||
admin: admin,
|
||||
}
|
||||
|
||||
if form.config.App == nil || form.admin == nil {
|
||||
panic("Invalid initializer config or nil upsert model.")
|
||||
}
|
||||
|
||||
if form.config.Dao == nil {
|
||||
form.config.Dao = form.config.App.Dao()
|
||||
app: app,
|
||||
dao: app.Dao(),
|
||||
admin: admin,
|
||||
}
|
||||
|
||||
// load defaults
|
||||
@ -66,6 +43,11 @@ func NewAdminUpsertWithConfig(config AdminUpsertConfig, admin *models.Admin) *Ad
|
||||
return form
|
||||
}
|
||||
|
||||
// SetDao replaces the default form Dao instance with the provided one.
|
||||
func (form *AdminUpsert) SetDao(dao *daos.Dao) {
|
||||
form.dao = dao
|
||||
}
|
||||
|
||||
// Validate makes the form validatable by implementing [validation.Validatable] interface.
|
||||
func (form *AdminUpsert) Validate() error {
|
||||
return validation.ValidateStruct(form,
|
||||
@ -92,7 +74,7 @@ func (form *AdminUpsert) Validate() error {
|
||||
validation.Field(
|
||||
&form.Password,
|
||||
validation.When(form.admin.IsNew(), validation.Required),
|
||||
validation.Length(10, 100),
|
||||
validation.Length(10, 72),
|
||||
),
|
||||
validation.Field(
|
||||
&form.PasswordConfirm,
|
||||
@ -105,7 +87,7 @@ func (form *AdminUpsert) Validate() error {
|
||||
func (form *AdminUpsert) checkUniqueEmail(value any) error {
|
||||
v, _ := value.(string)
|
||||
|
||||
if form.config.Dao.IsAdminEmailUnique(v, form.admin.Id) {
|
||||
if form.dao.IsAdminEmailUnique(v, form.admin.Id) {
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -135,6 +117,6 @@ func (form *AdminUpsert) Submit(interceptors ...InterceptorFunc) error {
|
||||
}
|
||||
|
||||
return runInterceptors(func() error {
|
||||
return form.config.Dao.SaveAdmin(form.admin)
|
||||
return form.dao.SaveAdmin(form.admin)
|
||||
}, interceptors...)
|
||||
}
|
||||
|
@ -6,35 +6,11 @@ import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
"github.com/pocketbase/pocketbase/forms"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
)
|
||||
|
||||
func TestAdminUpsertPanic1(t *testing.T) {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("The form did not panic")
|
||||
}
|
||||
}()
|
||||
|
||||
forms.NewAdminUpsert(nil, nil)
|
||||
}
|
||||
|
||||
func TestAdminUpsertPanic2(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("The form did not panic")
|
||||
}
|
||||
}()
|
||||
|
||||
forms.NewAdminUpsert(app, nil)
|
||||
}
|
||||
|
||||
func TestNewAdminUpsert(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
@ -54,125 +30,7 @@ func TestNewAdminUpsert(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminUpsertValidate(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
scenarios := []struct {
|
||||
id string
|
||||
avatar int
|
||||
email string
|
||||
password string
|
||||
passwordConfirm string
|
||||
expectedErrors int
|
||||
}{
|
||||
{
|
||||
"",
|
||||
-1,
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
3,
|
||||
},
|
||||
{
|
||||
"",
|
||||
10,
|
||||
"invalid",
|
||||
"12345678",
|
||||
"87654321",
|
||||
4,
|
||||
},
|
||||
{
|
||||
// existing email
|
||||
"2b4a97cc-3f83-4d01-a26b-3d77bc842d3c",
|
||||
3,
|
||||
"test2@example.com",
|
||||
"1234567890",
|
||||
"1234567890",
|
||||
1,
|
||||
},
|
||||
{
|
||||
// mismatching passwords
|
||||
"2b4a97cc-3f83-4d01-a26b-3d77bc842d3c",
|
||||
3,
|
||||
"test@example.com",
|
||||
"1234567890",
|
||||
"1234567891",
|
||||
1,
|
||||
},
|
||||
{
|
||||
// create without setting password
|
||||
"",
|
||||
9,
|
||||
"test_create@example.com",
|
||||
"",
|
||||
"",
|
||||
1,
|
||||
},
|
||||
{
|
||||
// create with existing email
|
||||
"",
|
||||
9,
|
||||
"test@example.com",
|
||||
"1234567890!",
|
||||
"1234567890!",
|
||||
1,
|
||||
},
|
||||
{
|
||||
// update without setting password
|
||||
"2b4a97cc-3f83-4d01-a26b-3d77bc842d3c",
|
||||
3,
|
||||
"test_update@example.com",
|
||||
"",
|
||||
"",
|
||||
0,
|
||||
},
|
||||
{
|
||||
// create with password
|
||||
"",
|
||||
9,
|
||||
"test_create@example.com",
|
||||
"1234567890!",
|
||||
"1234567890!",
|
||||
0,
|
||||
},
|
||||
{
|
||||
// update with password
|
||||
"2b4a97cc-3f83-4d01-a26b-3d77bc842d3c",
|
||||
4,
|
||||
"test_update@example.com",
|
||||
"1234567890",
|
||||
"1234567890",
|
||||
0,
|
||||
},
|
||||
}
|
||||
|
||||
for i, s := range scenarios {
|
||||
admin := &models.Admin{}
|
||||
if s.id != "" {
|
||||
admin, _ = app.Dao().FindAdminById(s.id)
|
||||
}
|
||||
|
||||
form := forms.NewAdminUpsert(app, admin)
|
||||
form.Avatar = s.avatar
|
||||
form.Email = s.email
|
||||
form.Password = s.password
|
||||
form.PasswordConfirm = s.passwordConfirm
|
||||
|
||||
result := form.Validate()
|
||||
errs, ok := result.(validation.Errors)
|
||||
if !ok && result != nil {
|
||||
t.Errorf("(%d) Failed to parse errors %v", i, result)
|
||||
continue
|
||||
}
|
||||
|
||||
if len(errs) != s.expectedErrors {
|
||||
t.Errorf("(%d) Expected %d errors, got %d (%v)", i, s.expectedErrors, len(errs), errs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminUpsertSubmit(t *testing.T) {
|
||||
func TestAdminUpsertValidateAndSubmit(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@ -189,7 +47,7 @@ func TestAdminUpsertSubmit(t *testing.T) {
|
||||
},
|
||||
{
|
||||
// update empty
|
||||
"2b4a97cc-3f83-4d01-a26b-3d77bc842d3c",
|
||||
"sywbhecnh46rhm0",
|
||||
`{}`,
|
||||
false,
|
||||
},
|
||||
@ -225,7 +83,7 @@ func TestAdminUpsertSubmit(t *testing.T) {
|
||||
},
|
||||
{
|
||||
// update failure - existing email
|
||||
"2b4a97cc-3f83-4d01-a26b-3d77bc842d3c",
|
||||
"sywbhecnh46rhm0",
|
||||
`{
|
||||
"email": "test2@example.com"
|
||||
}`,
|
||||
@ -233,7 +91,7 @@ func TestAdminUpsertSubmit(t *testing.T) {
|
||||
},
|
||||
{
|
||||
// update failure - mismatching passwords
|
||||
"2b4a97cc-3f83-4d01-a26b-3d77bc842d3c",
|
||||
"sywbhecnh46rhm0",
|
||||
`{
|
||||
"password": "1234567890",
|
||||
"passwordConfirm": "1234567891"
|
||||
@ -242,7 +100,7 @@ func TestAdminUpsertSubmit(t *testing.T) {
|
||||
},
|
||||
{
|
||||
// update success - new email
|
||||
"2b4a97cc-3f83-4d01-a26b-3d77bc842d3c",
|
||||
"sywbhecnh46rhm0",
|
||||
`{
|
||||
"email": "test_update@example.com"
|
||||
}`,
|
||||
@ -250,7 +108,7 @@ func TestAdminUpsertSubmit(t *testing.T) {
|
||||
},
|
||||
{
|
||||
// update success - new password
|
||||
"2b4a97cc-3f83-4d01-a26b-3d77bc842d3c",
|
||||
"sywbhecnh46rhm0",
|
||||
`{
|
||||
"password": "1234567890",
|
||||
"passwordConfirm": "1234567890"
|
||||
|
@ -2,7 +2,9 @@
|
||||
// validation and applying changes to existing DB models through the app Dao.
|
||||
package forms
|
||||
|
||||
import "regexp"
|
||||
import (
|
||||
"regexp"
|
||||
)
|
||||
|
||||
// base ID value regex pattern
|
||||
var idRegex = regexp.MustCompile(`^[^\@\#\$\&\|\.\,\'\"\\\/\s]+$`)
|
||||
|
@ -1,6 +1,7 @@
|
||||
package forms
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
@ -11,17 +12,21 @@ import (
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/models/schema"
|
||||
"github.com/pocketbase/pocketbase/resolvers"
|
||||
"github.com/pocketbase/pocketbase/tools/list"
|
||||
"github.com/pocketbase/pocketbase/tools/search"
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
)
|
||||
|
||||
var collectionNameRegex = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9_]*$`)
|
||||
|
||||
// CollectionUpsert specifies a [models.Collection] upsert (create/update) form.
|
||||
// CollectionUpsert is a [models.Collection] upsert (create/update) form.
|
||||
type CollectionUpsert struct {
|
||||
config CollectionUpsertConfig
|
||||
app core.App
|
||||
dao *daos.Dao
|
||||
collection *models.Collection
|
||||
|
||||
Id string `form:"id" json:"id"`
|
||||
Type string `form:"type" json:"type"`
|
||||
Name string `form:"name" json:"name"`
|
||||
System bool `form:"system" json:"system"`
|
||||
Schema schema.Schema `form:"schema" json:"schema"`
|
||||
@ -30,47 +35,25 @@ type CollectionUpsert struct {
|
||||
CreateRule *string `form:"createRule" json:"createRule"`
|
||||
UpdateRule *string `form:"updateRule" json:"updateRule"`
|
||||
DeleteRule *string `form:"deleteRule" json:"deleteRule"`
|
||||
}
|
||||
|
||||
// CollectionUpsertConfig is the [CollectionUpsert] factory initializer config.
|
||||
//
|
||||
// NB! App is a required struct member.
|
||||
type CollectionUpsertConfig struct {
|
||||
App core.App
|
||||
Dao *daos.Dao
|
||||
Options types.JsonMap `form:"options" json:"options"`
|
||||
}
|
||||
|
||||
// NewCollectionUpsert creates a new [CollectionUpsert] form with initializer
|
||||
// config created from the provided [core.App] and [models.Collection] instances
|
||||
// (for create you could pass a pointer to an empty Collection - `&models.Collection{}`).
|
||||
//
|
||||
// If you want to submit the form as part of another transaction, use
|
||||
// [NewCollectionUpsertWithConfig] with explicitly set Dao.
|
||||
// If you want to submit the form as part of a transaction,
|
||||
// you can change the default Dao via [SetDao()].
|
||||
func NewCollectionUpsert(app core.App, collection *models.Collection) *CollectionUpsert {
|
||||
return NewCollectionUpsertWithConfig(CollectionUpsertConfig{
|
||||
App: app,
|
||||
}, collection)
|
||||
}
|
||||
|
||||
// NewCollectionUpsertWithConfig creates a new [CollectionUpsert] form
|
||||
// with the provided config and [models.Collection] instance or panics on invalid configuration
|
||||
// (for create you could pass a pointer to an empty Collection - `&models.Collection{}`).
|
||||
func NewCollectionUpsertWithConfig(config CollectionUpsertConfig, collection *models.Collection) *CollectionUpsert {
|
||||
form := &CollectionUpsert{
|
||||
config: config,
|
||||
app: app,
|
||||
dao: app.Dao(),
|
||||
collection: collection,
|
||||
}
|
||||
|
||||
if form.config.App == nil || form.collection == nil {
|
||||
panic("Invalid initializer config or nil upsert model.")
|
||||
}
|
||||
|
||||
if form.config.Dao == nil {
|
||||
form.config.Dao = form.config.App.Dao()
|
||||
}
|
||||
|
||||
// load defaults
|
||||
form.Id = form.collection.Id
|
||||
form.Type = form.collection.Type
|
||||
form.Name = form.collection.Name
|
||||
form.System = form.collection.System
|
||||
form.ListRule = form.collection.ListRule
|
||||
@ -78,6 +61,11 @@ func NewCollectionUpsertWithConfig(config CollectionUpsertConfig, collection *mo
|
||||
form.CreateRule = form.collection.CreateRule
|
||||
form.UpdateRule = form.collection.UpdateRule
|
||||
form.DeleteRule = form.collection.DeleteRule
|
||||
form.Options = form.collection.Options
|
||||
|
||||
if form.Type == "" {
|
||||
form.Type = models.CollectionTypeBase
|
||||
}
|
||||
|
||||
clone, _ := form.collection.Schema.Clone()
|
||||
if clone != nil {
|
||||
@ -89,8 +77,15 @@ func NewCollectionUpsertWithConfig(config CollectionUpsertConfig, collection *mo
|
||||
return form
|
||||
}
|
||||
|
||||
// SetDao replaces the default form Dao instance with the provided one.
|
||||
func (form *CollectionUpsert) SetDao(dao *daos.Dao) {
|
||||
form.dao = dao
|
||||
}
|
||||
|
||||
// Validate makes the form validatable by implementing [validation.Validatable] interface.
|
||||
func (form *CollectionUpsert) Validate() error {
|
||||
isAuth := form.Type == models.CollectionTypeAuth
|
||||
|
||||
return validation.ValidateStruct(form,
|
||||
validation.Field(
|
||||
&form.Id,
|
||||
@ -104,6 +99,12 @@ func (form *CollectionUpsert) Validate() error {
|
||||
&form.System,
|
||||
validation.By(form.ensureNoSystemFlagChange),
|
||||
),
|
||||
validation.Field(
|
||||
&form.Type,
|
||||
validation.Required,
|
||||
validation.In(models.CollectionTypeAuth, models.CollectionTypeBase),
|
||||
validation.By(form.ensureNoTypeChange),
|
||||
),
|
||||
validation.Field(
|
||||
&form.Name,
|
||||
validation.Required,
|
||||
@ -118,23 +119,35 @@ func (form *CollectionUpsert) Validate() error {
|
||||
validation.By(form.ensureNoSystemFieldsChange),
|
||||
validation.By(form.ensureNoFieldsTypeChange),
|
||||
validation.By(form.ensureExistingRelationCollectionId),
|
||||
validation.When(
|
||||
isAuth,
|
||||
validation.By(form.ensureNoAuthFieldName),
|
||||
),
|
||||
),
|
||||
validation.Field(&form.ListRule, validation.By(form.checkRule)),
|
||||
validation.Field(&form.ViewRule, validation.By(form.checkRule)),
|
||||
validation.Field(&form.CreateRule, validation.By(form.checkRule)),
|
||||
validation.Field(&form.UpdateRule, validation.By(form.checkRule)),
|
||||
validation.Field(&form.DeleteRule, validation.By(form.checkRule)),
|
||||
validation.Field(&form.Options, validation.By(form.checkOptions)),
|
||||
)
|
||||
}
|
||||
|
||||
func (form *CollectionUpsert) checkUniqueName(value any) error {
|
||||
v, _ := value.(string)
|
||||
|
||||
if !form.config.Dao.IsCollectionNameUnique(v, form.collection.Id) {
|
||||
// ensure unique collection name
|
||||
if !form.dao.IsCollectionNameUnique(v, form.collection.Id) {
|
||||
return validation.NewError("validation_collection_name_exists", "Collection name must be unique (case insensitive).")
|
||||
}
|
||||
|
||||
if (form.collection.IsNew() || !strings.EqualFold(v, form.collection.Name)) && form.config.Dao.HasTable(v) {
|
||||
// ensure that the collection name doesn't collide with the id of any collection
|
||||
if form.dao.FindById(&models.Collection{}, v) == nil {
|
||||
return validation.NewError("validation_collection_name_id_duplicate", "The name must not match an existing collection id.")
|
||||
}
|
||||
|
||||
// ensure that there is no existing table name with the same name
|
||||
if (form.collection.IsNew() || !strings.EqualFold(v, form.collection.Name)) && form.dao.HasTable(v) {
|
||||
return validation.NewError("validation_collection_name_table_exists", "The collection name must be also unique table name.")
|
||||
}
|
||||
|
||||
@ -144,21 +157,31 @@ func (form *CollectionUpsert) checkUniqueName(value any) error {
|
||||
func (form *CollectionUpsert) ensureNoSystemNameChange(value any) error {
|
||||
v, _ := value.(string)
|
||||
|
||||
if form.collection.IsNew() || !form.collection.System || v == form.collection.Name {
|
||||
return nil
|
||||
if !form.collection.IsNew() && form.collection.System && v != form.collection.Name {
|
||||
return validation.NewError("validation_collection_system_name_change", "System collections cannot be renamed.")
|
||||
}
|
||||
|
||||
return validation.NewError("validation_system_collection_name_change", "System collections cannot be renamed.")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (form *CollectionUpsert) ensureNoSystemFlagChange(value any) error {
|
||||
v, _ := value.(bool)
|
||||
|
||||
if form.collection.IsNew() || v == form.collection.System {
|
||||
return nil
|
||||
if !form.collection.IsNew() && v != form.collection.System {
|
||||
return validation.NewError("validation_collection_system_flag_change", "System collection state cannot be changed.")
|
||||
}
|
||||
|
||||
return validation.NewError("validation_system_collection_flag_change", "System collection state cannot be changed.")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (form *CollectionUpsert) ensureNoTypeChange(value any) error {
|
||||
v, _ := value.(string)
|
||||
|
||||
if !form.collection.IsNew() && v != form.collection.Type {
|
||||
return validation.NewError("validation_collection_type_change", "Collection type cannot be changed.")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (form *CollectionUpsert) ensureNoFieldsTypeChange(value any) error {
|
||||
@ -191,7 +214,7 @@ func (form *CollectionUpsert) ensureExistingRelationCollectionId(value any) erro
|
||||
continue
|
||||
}
|
||||
|
||||
if _, err := form.config.Dao.FindCollectionByNameOrId(options.CollectionId); err != nil {
|
||||
if _, err := form.dao.FindCollectionByNameOrId(options.CollectionId); err != nil {
|
||||
return validation.Errors{fmt.Sprint(i): validation.NewError(
|
||||
"validation_field_invalid_relation",
|
||||
"The relation collection doesn't exist.",
|
||||
@ -202,6 +225,36 @@ func (form *CollectionUpsert) ensureExistingRelationCollectionId(value any) erro
|
||||
return nil
|
||||
}
|
||||
|
||||
func (form *CollectionUpsert) ensureNoAuthFieldName(value any) error {
|
||||
v, _ := value.(schema.Schema)
|
||||
|
||||
if form.Type != models.CollectionTypeAuth {
|
||||
return nil // not an auth collection
|
||||
}
|
||||
|
||||
authFieldNames := schema.AuthFieldNames()
|
||||
// exclude the meta RecordUpsert form fields
|
||||
authFieldNames = append(authFieldNames, "password", "passwordConfirm", "oldPassword")
|
||||
|
||||
errs := validation.Errors{}
|
||||
for i, field := range v.Fields() {
|
||||
if list.ExistInSlice(field.Name, authFieldNames) {
|
||||
errs[fmt.Sprint(i)] = validation.Errors{
|
||||
"name": validation.NewError(
|
||||
"validation_reserved_auth_field_name",
|
||||
"The field name is reserved and cannot be used.",
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(errs) > 0 {
|
||||
return errs
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (form *CollectionUpsert) ensureNoSystemFieldsChange(value any) error {
|
||||
v, _ := value.(schema.Schema)
|
||||
|
||||
@ -222,17 +275,44 @@ func (form *CollectionUpsert) ensureNoSystemFieldsChange(value any) error {
|
||||
|
||||
func (form *CollectionUpsert) checkRule(value any) error {
|
||||
v, _ := value.(*string)
|
||||
|
||||
if v == nil || *v == "" {
|
||||
return nil // nothing to check
|
||||
}
|
||||
|
||||
dummy := &models.Collection{Schema: form.Schema}
|
||||
r := resolvers.NewRecordFieldResolver(form.config.Dao, dummy, nil)
|
||||
r := resolvers.NewRecordFieldResolver(form.dao, dummy, nil, true)
|
||||
|
||||
_, err := search.FilterData(*v).BuildExpr(r)
|
||||
if err != nil {
|
||||
return validation.NewError("validation_collection_rule", "Invalid filter rule.")
|
||||
return validation.NewError("validation_invalid_rule", "Invalid filter rule.")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (form *CollectionUpsert) checkOptions(value any) error {
|
||||
v, _ := value.(types.JsonMap)
|
||||
|
||||
if form.Type == models.CollectionTypeAuth {
|
||||
raw, err := v.MarshalJSON()
|
||||
if err != nil {
|
||||
return validation.NewError("validation_invalid_options", "Invalid options.")
|
||||
}
|
||||
|
||||
options := models.CollectionAuthOptions{}
|
||||
if err := json.Unmarshal(raw, &options); err != nil {
|
||||
return validation.NewError("validation_invalid_options", "Invalid options.")
|
||||
}
|
||||
|
||||
// check the generic validations
|
||||
if err := options.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// additional form specific validations
|
||||
if err := form.checkRule(options.ManageRule); err != nil {
|
||||
return validation.Errors{"manageRule": err}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
@ -250,6 +330,9 @@ func (form *CollectionUpsert) Submit(interceptors ...InterceptorFunc) error {
|
||||
}
|
||||
|
||||
if form.collection.IsNew() {
|
||||
// type can be set only on create
|
||||
form.collection.Type = form.Type
|
||||
|
||||
// system flag can be set only on create
|
||||
form.collection.System = form.System
|
||||
|
||||
@ -271,8 +354,9 @@ func (form *CollectionUpsert) Submit(interceptors ...InterceptorFunc) error {
|
||||
form.collection.CreateRule = form.CreateRule
|
||||
form.collection.UpdateRule = form.UpdateRule
|
||||
form.collection.DeleteRule = form.DeleteRule
|
||||
form.collection.SetOptions(form.Options)
|
||||
|
||||
return runInterceptors(func() error {
|
||||
return form.config.Dao.SaveCollection(form.collection)
|
||||
return form.dao.SaveCollection(form.collection)
|
||||
}, interceptors...)
|
||||
}
|
||||
|
@ -14,35 +14,13 @@ import (
|
||||
"github.com/spf13/cast"
|
||||
)
|
||||
|
||||
func TestCollectionUpsertPanic1(t *testing.T) {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("The form did not panic")
|
||||
}
|
||||
}()
|
||||
|
||||
forms.NewCollectionUpsert(nil, nil)
|
||||
}
|
||||
|
||||
func TestCollectionUpsertPanic2(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("The form did not panic")
|
||||
}
|
||||
}()
|
||||
|
||||
forms.NewCollectionUpsert(app, nil)
|
||||
}
|
||||
|
||||
func TestNewCollectionUpsert(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
collection := &models.Collection{}
|
||||
collection.Name = "test"
|
||||
collection.Name = "test_name"
|
||||
collection.Type = "test_type"
|
||||
collection.System = true
|
||||
listRule := "testview"
|
||||
collection.ListRule = &listRule
|
||||
@ -65,6 +43,10 @@ func TestNewCollectionUpsert(t *testing.T) {
|
||||
t.Errorf("Expected Name %q, got %q", collection.Name, form.Name)
|
||||
}
|
||||
|
||||
if form.Type != collection.Type {
|
||||
t.Errorf("Expected Type %q, got %q", collection.Type, form.Type)
|
||||
}
|
||||
|
||||
if form.System != collection.System {
|
||||
t.Errorf("Expected System %v, got %v", collection.System, form.System)
|
||||
}
|
||||
@ -104,95 +86,24 @@ func TestNewCollectionUpsert(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectionUpsertValidate(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
scenarios := []struct {
|
||||
jsonData string
|
||||
expectedErrors []string
|
||||
}{
|
||||
{"{}", []string{"name", "schema"}},
|
||||
{
|
||||
`{
|
||||
"name": "test ?!@#$",
|
||||
"system": true,
|
||||
"schema": [
|
||||
{"name":"","type":"text"}
|
||||
],
|
||||
"listRule": "missing = '123'",
|
||||
"viewRule": "missing = '123'",
|
||||
"createRule": "missing = '123'",
|
||||
"updateRule": "missing = '123'",
|
||||
"deleteRule": "missing = '123'"
|
||||
}`,
|
||||
[]string{"name", "schema", "listRule", "viewRule", "createRule", "updateRule", "deleteRule"},
|
||||
},
|
||||
{
|
||||
`{
|
||||
"name": "test",
|
||||
"system": true,
|
||||
"schema": [
|
||||
{"name":"test","type":"text"}
|
||||
],
|
||||
"listRule": "test='123'",
|
||||
"viewRule": "test='123'",
|
||||
"createRule": "test='123'",
|
||||
"updateRule": "test='123'",
|
||||
"deleteRule": "test='123'"
|
||||
}`,
|
||||
[]string{},
|
||||
},
|
||||
}
|
||||
|
||||
for i, s := range scenarios {
|
||||
form := forms.NewCollectionUpsert(app, &models.Collection{})
|
||||
|
||||
// load data
|
||||
loadErr := json.Unmarshal([]byte(s.jsonData), form)
|
||||
if loadErr != nil {
|
||||
t.Errorf("(%d) Failed to load form data: %v", i, loadErr)
|
||||
continue
|
||||
}
|
||||
|
||||
// parse errors
|
||||
result := form.Validate()
|
||||
errs, ok := result.(validation.Errors)
|
||||
if !ok && result != nil {
|
||||
t.Errorf("(%d) Failed to parse errors %v", i, result)
|
||||
continue
|
||||
}
|
||||
|
||||
// check errors
|
||||
if len(errs) > len(s.expectedErrors) {
|
||||
t.Errorf("(%d) Expected error keys %v, got %v", i, s.expectedErrors, errs)
|
||||
}
|
||||
for _, k := range s.expectedErrors {
|
||||
if _, ok := errs[k]; !ok {
|
||||
t.Errorf("(%d) Missing expected error key %q in %v", i, k, errs)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectionUpsertSubmit(t *testing.T) {
|
||||
func TestCollectionUpsertValidateAndSubmit(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
scenarios := []struct {
|
||||
testName string
|
||||
existingName string
|
||||
jsonData string
|
||||
expectedErrors []string
|
||||
}{
|
||||
// empty create
|
||||
{"", "{}", []string{"name", "schema"}},
|
||||
// empty update
|
||||
{"demo", "{}", []string{}},
|
||||
// create failure
|
||||
{"empty create", "", "{}", []string{"name", "schema"}},
|
||||
{"empty update", "demo2", "{}", []string{}},
|
||||
{
|
||||
"create failure",
|
||||
"",
|
||||
`{
|
||||
"name": "test ?!@#$",
|
||||
"type": "invalid",
|
||||
"system": true,
|
||||
"schema": [
|
||||
{"name":"","type":"text"}
|
||||
@ -203,13 +114,13 @@ func TestCollectionUpsertSubmit(t *testing.T) {
|
||||
"updateRule": "missing = '123'",
|
||||
"deleteRule": "missing = '123'"
|
||||
}`,
|
||||
[]string{"name", "schema", "listRule", "viewRule", "createRule", "updateRule", "deleteRule"},
|
||||
[]string{"name", "type", "schema", "listRule", "viewRule", "createRule", "updateRule", "deleteRule"},
|
||||
},
|
||||
// create failure - existing name
|
||||
{
|
||||
"create failure - existing name",
|
||||
"",
|
||||
`{
|
||||
"name": "demo",
|
||||
"name": "demo1",
|
||||
"system": true,
|
||||
"schema": [
|
||||
{"name":"test","type":"text"}
|
||||
@ -222,19 +133,19 @@ func TestCollectionUpsertSubmit(t *testing.T) {
|
||||
}`,
|
||||
[]string{"name"},
|
||||
},
|
||||
// create failure - existing internal table
|
||||
{
|
||||
"create failure - existing internal table",
|
||||
"",
|
||||
`{
|
||||
"name": "_users",
|
||||
"name": "_admins",
|
||||
"schema": [
|
||||
{"name":"test","type":"text"}
|
||||
]
|
||||
}`,
|
||||
[]string{"name"},
|
||||
},
|
||||
// create failure - name starting with underscore
|
||||
{
|
||||
"create failure - name starting with underscore",
|
||||
"",
|
||||
`{
|
||||
"name": "_test_new",
|
||||
@ -244,8 +155,8 @@ func TestCollectionUpsertSubmit(t *testing.T) {
|
||||
}`,
|
||||
[]string{"name"},
|
||||
},
|
||||
// create failure - duplicated field names (case insensitive)
|
||||
{
|
||||
"create failure - duplicated field names (case insensitive)",
|
||||
"",
|
||||
`{
|
||||
"name": "test_new",
|
||||
@ -256,8 +167,21 @@ func TestCollectionUpsertSubmit(t *testing.T) {
|
||||
}`,
|
||||
[]string{"schema"},
|
||||
},
|
||||
// create success
|
||||
{
|
||||
"create failure - check type options validators",
|
||||
"",
|
||||
`{
|
||||
"name": "test_new",
|
||||
"type": "auth",
|
||||
"schema": [
|
||||
{"name":"test","type":"text"}
|
||||
],
|
||||
"options": { "minPasswordLength": 3 }
|
||||
}`,
|
||||
[]string{"options"},
|
||||
},
|
||||
{
|
||||
"create success",
|
||||
"",
|
||||
`{
|
||||
"name": "test_new",
|
||||
@ -274,8 +198,8 @@ func TestCollectionUpsertSubmit(t *testing.T) {
|
||||
}`,
|
||||
[]string{},
|
||||
},
|
||||
// update failure - changing field type
|
||||
{
|
||||
"update failure - changing field type",
|
||||
"test_new",
|
||||
`{
|
||||
"schema": [
|
||||
@ -285,8 +209,8 @@ func TestCollectionUpsertSubmit(t *testing.T) {
|
||||
}`,
|
||||
[]string{"schema"},
|
||||
},
|
||||
// update success - rename fields to existing field names (aka. reusing field names)
|
||||
{
|
||||
"update success - rename fields to existing field names (aka. reusing field names)",
|
||||
"test_new",
|
||||
`{
|
||||
"schema": [
|
||||
@ -296,34 +220,43 @@ func TestCollectionUpsertSubmit(t *testing.T) {
|
||||
}`,
|
||||
[]string{},
|
||||
},
|
||||
// update failure - existing name
|
||||
{
|
||||
"demo",
|
||||
`{"name": "demo2"}`,
|
||||
"update failure - existing name",
|
||||
"demo2",
|
||||
`{"name": "demo3"}`,
|
||||
[]string{"name"},
|
||||
},
|
||||
// update failure - changing system collection
|
||||
{
|
||||
models.ProfileCollectionName,
|
||||
"update failure - changing system collection",
|
||||
"nologin",
|
||||
`{
|
||||
"name": "update",
|
||||
"system": false,
|
||||
"schema": [
|
||||
{"id":"koih1lqx","name":"userId","type":"text"}
|
||||
{"id":"koih1lqx","name":"abc","type":"text"}
|
||||
],
|
||||
"listRule": "userId = '123'",
|
||||
"viewRule": "userId = '123'",
|
||||
"createRule": "userId = '123'",
|
||||
"updateRule": "userId = '123'",
|
||||
"deleteRule": "userId = '123'"
|
||||
"listRule": "abc = '123'",
|
||||
"viewRule": "abc = '123'",
|
||||
"createRule": "abc = '123'",
|
||||
"updateRule": "abc = '123'",
|
||||
"deleteRule": "abc = '123'"
|
||||
}`,
|
||||
[]string{"name", "system", "schema"},
|
||||
[]string{"name", "system"},
|
||||
},
|
||||
// update failure - all fields
|
||||
{
|
||||
"demo",
|
||||
"update failure - changing collection type",
|
||||
"demo3",
|
||||
`{
|
||||
"type": "auth"
|
||||
}`,
|
||||
[]string{"type"},
|
||||
},
|
||||
{
|
||||
"update failure - all fields",
|
||||
"demo2",
|
||||
`{
|
||||
"name": "test ?!@#$",
|
||||
"type": "invalid",
|
||||
"system": true,
|
||||
"schema": [
|
||||
{"name":"","type":"text"}
|
||||
@ -332,15 +265,17 @@ func TestCollectionUpsertSubmit(t *testing.T) {
|
||||
"viewRule": "missing = '123'",
|
||||
"createRule": "missing = '123'",
|
||||
"updateRule": "missing = '123'",
|
||||
"deleteRule": "missing = '123'"
|
||||
"deleteRule": "missing = '123'",
|
||||
"options": {"test": 123}
|
||||
}`,
|
||||
[]string{"name", "system", "schema", "listRule", "viewRule", "createRule", "updateRule", "deleteRule"},
|
||||
[]string{"name", "type", "system", "schema", "listRule", "viewRule", "createRule", "updateRule", "deleteRule"},
|
||||
},
|
||||
// update success - update all fields
|
||||
{
|
||||
"demo",
|
||||
"update success - update all fields",
|
||||
"clients",
|
||||
`{
|
||||
"name": "demo_update",
|
||||
"type": "auth",
|
||||
"schema": [
|
||||
{"id":"_2hlxbmp","name":"test","type":"text"}
|
||||
],
|
||||
@ -348,13 +283,14 @@ func TestCollectionUpsertSubmit(t *testing.T) {
|
||||
"viewRule": "test='123'",
|
||||
"createRule": "test='123'",
|
||||
"updateRule": "test='123'",
|
||||
"deleteRule": "test='123'"
|
||||
"deleteRule": "test='123'",
|
||||
"options": {"minPasswordLength": 10}
|
||||
}`,
|
||||
[]string{},
|
||||
},
|
||||
// update failure - rename the schema field of the last updated collection
|
||||
// (fail due to filters old field references)
|
||||
{
|
||||
"update failure - rename the schema field of the last updated collection",
|
||||
"demo_update",
|
||||
`{
|
||||
"schema": [
|
||||
@ -363,9 +299,9 @@ func TestCollectionUpsertSubmit(t *testing.T) {
|
||||
}`,
|
||||
[]string{"listRule", "viewRule", "createRule", "updateRule", "deleteRule"},
|
||||
},
|
||||
// update success - rename the schema field of the last updated collection
|
||||
// (cleared filter references)
|
||||
{
|
||||
"update success - rename the schema field of the last updated collection",
|
||||
"demo_update",
|
||||
`{
|
||||
"schema": [
|
||||
@ -379,21 +315,21 @@ func TestCollectionUpsertSubmit(t *testing.T) {
|
||||
}`,
|
||||
[]string{},
|
||||
},
|
||||
// update success - system collection
|
||||
{
|
||||
models.ProfileCollectionName,
|
||||
"update success - system collection",
|
||||
"nologin",
|
||||
`{
|
||||
"listRule": "userId='123'",
|
||||
"viewRule": "userId='123'",
|
||||
"createRule": "userId='123'",
|
||||
"updateRule": "userId='123'",
|
||||
"deleteRule": "userId='123'"
|
||||
"listRule": "name='123'",
|
||||
"viewRule": "name='123'",
|
||||
"createRule": "name='123'",
|
||||
"updateRule": "name='123'",
|
||||
"deleteRule": "name='123'"
|
||||
}`,
|
||||
[]string{},
|
||||
},
|
||||
}
|
||||
|
||||
for i, s := range scenarios {
|
||||
for _, s := range scenarios {
|
||||
collection := &models.Collection{}
|
||||
if s.existingName != "" {
|
||||
var err error
|
||||
@ -408,7 +344,7 @@ func TestCollectionUpsertSubmit(t *testing.T) {
|
||||
// load data
|
||||
loadErr := json.Unmarshal([]byte(s.jsonData), form)
|
||||
if loadErr != nil {
|
||||
t.Errorf("(%d) Failed to load form data: %v", i, loadErr)
|
||||
t.Errorf("[%s] Failed to load form data: %v", s.testName, loadErr)
|
||||
continue
|
||||
}
|
||||
|
||||
@ -424,7 +360,7 @@ func TestCollectionUpsertSubmit(t *testing.T) {
|
||||
result := form.Submit(interceptor)
|
||||
errs, ok := result.(validation.Errors)
|
||||
if !ok && result != nil {
|
||||
t.Errorf("(%d) Failed to parse errors %v", i, result)
|
||||
t.Errorf("[%s] Failed to parse errors %v", s.testName, result)
|
||||
continue
|
||||
}
|
||||
|
||||
@ -434,16 +370,16 @@ func TestCollectionUpsertSubmit(t *testing.T) {
|
||||
expectInterceptorCall = 0
|
||||
}
|
||||
if interceptorCalls != expectInterceptorCall {
|
||||
t.Errorf("(%d) Expected interceptor to be called %d, got %d", i, expectInterceptorCall, interceptorCalls)
|
||||
t.Errorf("[%s] Expected interceptor to be called %d, got %d", s.testName, expectInterceptorCall, interceptorCalls)
|
||||
}
|
||||
|
||||
// check errors
|
||||
if len(errs) > len(s.expectedErrors) {
|
||||
t.Errorf("(%d) Expected error keys %v, got %v", i, s.expectedErrors, errs)
|
||||
t.Errorf("[%s] Expected error keys %v, got %v", s.testName, s.expectedErrors, errs)
|
||||
}
|
||||
for _, k := range s.expectedErrors {
|
||||
if _, ok := errs[k]; !ok {
|
||||
t.Errorf("(%d) Missing expected error key %q in %v", i, k, errs)
|
||||
t.Errorf("[%s] Missing expected error key %q in %v", s.testName, k, errs)
|
||||
}
|
||||
}
|
||||
|
||||
@ -453,42 +389,46 @@ func TestCollectionUpsertSubmit(t *testing.T) {
|
||||
|
||||
collection, _ = app.Dao().FindCollectionByNameOrId(form.Name)
|
||||
if collection == nil {
|
||||
t.Errorf("(%d) Expected to find collection %q, got nil", i, form.Name)
|
||||
t.Errorf("[%s] Expected to find collection %q, got nil", s.testName, form.Name)
|
||||
continue
|
||||
}
|
||||
|
||||
if form.Name != collection.Name {
|
||||
t.Errorf("(%d) Expected Name %q, got %q", i, collection.Name, form.Name)
|
||||
t.Errorf("[%s] Expected Name %q, got %q", s.testName, collection.Name, form.Name)
|
||||
}
|
||||
|
||||
if form.Type != collection.Type {
|
||||
t.Errorf("[%s] Expected Type %q, got %q", s.testName, collection.Type, form.Type)
|
||||
}
|
||||
|
||||
if form.System != collection.System {
|
||||
t.Errorf("(%d) Expected System %v, got %v", i, collection.System, form.System)
|
||||
t.Errorf("[%s] Expected System %v, got %v", s.testName, collection.System, form.System)
|
||||
}
|
||||
|
||||
if cast.ToString(form.ListRule) != cast.ToString(collection.ListRule) {
|
||||
t.Errorf("(%d) Expected ListRule %v, got %v", i, collection.ListRule, form.ListRule)
|
||||
t.Errorf("[%s] Expected ListRule %v, got %v", s.testName, collection.ListRule, form.ListRule)
|
||||
}
|
||||
|
||||
if cast.ToString(form.ViewRule) != cast.ToString(collection.ViewRule) {
|
||||
t.Errorf("(%d) Expected ViewRule %v, got %v", i, collection.ViewRule, form.ViewRule)
|
||||
t.Errorf("[%s] Expected ViewRule %v, got %v", s.testName, collection.ViewRule, form.ViewRule)
|
||||
}
|
||||
|
||||
if cast.ToString(form.CreateRule) != cast.ToString(collection.CreateRule) {
|
||||
t.Errorf("(%d) Expected CreateRule %v, got %v", i, collection.CreateRule, form.CreateRule)
|
||||
t.Errorf("[%s] Expected CreateRule %v, got %v", s.testName, collection.CreateRule, form.CreateRule)
|
||||
}
|
||||
|
||||
if cast.ToString(form.UpdateRule) != cast.ToString(collection.UpdateRule) {
|
||||
t.Errorf("(%d) Expected UpdateRule %v, got %v", i, collection.UpdateRule, form.UpdateRule)
|
||||
t.Errorf("[%s] Expected UpdateRule %v, got %v", s.testName, collection.UpdateRule, form.UpdateRule)
|
||||
}
|
||||
|
||||
if cast.ToString(form.DeleteRule) != cast.ToString(collection.DeleteRule) {
|
||||
t.Errorf("(%d) Expected DeleteRule %v, got %v", i, collection.DeleteRule, form.DeleteRule)
|
||||
t.Errorf("[%s] Expected DeleteRule %v, got %v", s.testName, collection.DeleteRule, form.DeleteRule)
|
||||
}
|
||||
|
||||
formSchema, _ := form.Schema.MarshalJSON()
|
||||
collectionSchema, _ := collection.Schema.MarshalJSON()
|
||||
if string(formSchema) != string(collectionSchema) {
|
||||
t.Errorf("(%d) Expected Schema %v, got %v", i, string(collectionSchema), string(formSchema))
|
||||
t.Errorf("[%s] Expected Schema %v, got %v", s.testName, string(collectionSchema), string(formSchema))
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -497,7 +437,7 @@ func TestCollectionUpsertSubmitInterceptors(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
collection, err := app.Dao().FindCollectionByNameOrId("demo")
|
||||
collection, err := app.Dao().FindCollectionByNameOrId("demo2")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@ -547,7 +487,7 @@ func TestCollectionUpsertWithCustomId(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
existingCollection, err := app.Dao().FindCollectionByNameOrId("demo3")
|
||||
existingCollection, err := app.Dao().FindCollectionByNameOrId("demo2")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@ -621,27 +561,27 @@ func TestCollectionUpsertWithCustomId(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
form := forms.NewCollectionUpsert(app, scenario.collection)
|
||||
for _, s := range scenarios {
|
||||
form := forms.NewCollectionUpsert(app, s.collection)
|
||||
|
||||
// load data
|
||||
loadErr := json.Unmarshal([]byte(scenario.jsonData), form)
|
||||
loadErr := json.Unmarshal([]byte(s.jsonData), form)
|
||||
if loadErr != nil {
|
||||
t.Errorf("[%s] Failed to load form data: %v", scenario.name, loadErr)
|
||||
t.Errorf("[%s] Failed to load form data: %v", s.name, loadErr)
|
||||
continue
|
||||
}
|
||||
|
||||
submitErr := form.Submit()
|
||||
hasErr := submitErr != nil
|
||||
|
||||
if hasErr != scenario.expectError {
|
||||
t.Errorf("[%s] Expected hasErr to be %v, got %v (%v)", scenario.name, scenario.expectError, hasErr, submitErr)
|
||||
if hasErr != s.expectError {
|
||||
t.Errorf("[%s] Expected hasErr to be %v, got %v (%v)", s.name, s.expectError, hasErr, submitErr)
|
||||
}
|
||||
|
||||
if !hasErr && form.Id != "" {
|
||||
_, err := app.Dao().FindCollectionByNameOrId(form.Id)
|
||||
if err != nil {
|
||||
t.Errorf("[%s] Expected to find record with id %s, got %v", scenario.name, form.Id, err)
|
||||
t.Errorf("[%s] Expected to find record with id %s, got %v", s.name, form.Id, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -11,48 +11,31 @@ import (
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
)
|
||||
|
||||
// CollectionsImport specifies a form model to bulk import
|
||||
// CollectionsImport is a form model to bulk import
|
||||
// (create, replace and delete) collections from a user provided list.
|
||||
type CollectionsImport struct {
|
||||
config CollectionsImportConfig
|
||||
app core.App
|
||||
dao *daos.Dao
|
||||
|
||||
Collections []*models.Collection `form:"collections" json:"collections"`
|
||||
DeleteMissing bool `form:"deleteMissing" json:"deleteMissing"`
|
||||
}
|
||||
|
||||
// CollectionsImportConfig is the [CollectionsImport] factory initializer config.
|
||||
//
|
||||
// NB! App is a required struct member.
|
||||
type CollectionsImportConfig struct {
|
||||
App core.App
|
||||
Dao *daos.Dao
|
||||
}
|
||||
|
||||
// NewCollectionsImport creates a new [CollectionsImport] form with
|
||||
// initializer config created from the provided [core.App] instance.
|
||||
// initialized with from the provided [core.App] instance.
|
||||
//
|
||||
// If you want to submit the form as part of another transaction, use
|
||||
// [NewCollectionsImportWithConfig] with explicitly set Dao.
|
||||
// If you want to submit the form as part of a transaction,
|
||||
// you can change the default Dao via [SetDao()].
|
||||
func NewCollectionsImport(app core.App) *CollectionsImport {
|
||||
return NewCollectionsImportWithConfig(CollectionsImportConfig{
|
||||
App: app,
|
||||
})
|
||||
return &CollectionsImport{
|
||||
app: app,
|
||||
dao: app.Dao(),
|
||||
}
|
||||
}
|
||||
|
||||
// NewCollectionsImportWithConfig creates a new [CollectionsImport]
|
||||
// form with the provided config or panics on invalid configuration.
|
||||
func NewCollectionsImportWithConfig(config CollectionsImportConfig) *CollectionsImport {
|
||||
form := &CollectionsImport{config: config}
|
||||
|
||||
if form.config.App == nil {
|
||||
panic("Missing required config.App instance.")
|
||||
}
|
||||
|
||||
if form.config.Dao == nil {
|
||||
form.config.Dao = form.config.App.Dao()
|
||||
}
|
||||
|
||||
return form
|
||||
// SetDao replaces the default form Dao instance with the provided one.
|
||||
func (form *CollectionsImport) SetDao(dao *daos.Dao) {
|
||||
form.dao = dao
|
||||
}
|
||||
|
||||
// Validate makes the form validatable by implementing [validation.Validatable] interface.
|
||||
@ -79,7 +62,7 @@ func (form *CollectionsImport) Submit(interceptors ...InterceptorFunc) error {
|
||||
}
|
||||
|
||||
return runInterceptors(func() error {
|
||||
return form.config.Dao.RunInTransaction(func(txDao *daos.Dao) error {
|
||||
return form.dao.RunInTransaction(func(txDao *daos.Dao) error {
|
||||
importErr := txDao.ImportCollections(
|
||||
form.Collections,
|
||||
form.DeleteMissing,
|
||||
@ -95,7 +78,7 @@ func (form *CollectionsImport) Submit(interceptors ...InterceptorFunc) error {
|
||||
}
|
||||
|
||||
// generic/db failure
|
||||
if form.config.App.IsDebug() {
|
||||
if form.app.IsDebug() {
|
||||
log.Println("Internal import failure:", importErr)
|
||||
}
|
||||
return validation.Errors{"collections": validation.NewError(
|
||||
@ -121,13 +104,12 @@ func (form *CollectionsImport) beforeRecordsSync(txDao *daos.Dao, mappedNew, map
|
||||
upsertModel = collection
|
||||
}
|
||||
|
||||
upsertForm := NewCollectionUpsertWithConfig(CollectionUpsertConfig{
|
||||
App: form.config.App,
|
||||
Dao: txDao,
|
||||
}, upsertModel)
|
||||
upsertForm := NewCollectionUpsert(form.app, upsertModel)
|
||||
upsertForm.SetDao(txDao)
|
||||
|
||||
// load form fields with the refreshed collection state
|
||||
upsertForm.Id = collection.Id
|
||||
upsertForm.Type = collection.Type
|
||||
upsertForm.Name = collection.Name
|
||||
upsertForm.System = collection.System
|
||||
upsertForm.ListRule = collection.ListRule
|
||||
@ -136,6 +118,7 @@ func (form *CollectionsImport) beforeRecordsSync(txDao *daos.Dao, mappedNew, map
|
||||
upsertForm.UpdateRule = collection.UpdateRule
|
||||
upsertForm.DeleteRule = collection.DeleteRule
|
||||
upsertForm.Schema = collection.Schema
|
||||
upsertForm.Options = collection.Options
|
||||
|
||||
if err := upsertForm.Validate(); err != nil {
|
||||
// serialize the validation error(s)
|
||||
|
@ -10,16 +10,6 @@ import (
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
)
|
||||
|
||||
func TestCollectionsImportPanic(t *testing.T) {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("The form did not panic")
|
||||
}
|
||||
}()
|
||||
|
||||
forms.NewCollectionsImport(nil)
|
||||
}
|
||||
|
||||
func TestCollectionsImportValidate(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
@ -62,7 +52,7 @@ func TestCollectionsImportSubmit(t *testing.T) {
|
||||
"collections": []
|
||||
}`,
|
||||
expectError: true,
|
||||
expectCollectionsCount: 5,
|
||||
expectCollectionsCount: 7,
|
||||
expectEvents: nil,
|
||||
},
|
||||
{
|
||||
@ -92,7 +82,7 @@ func TestCollectionsImportSubmit(t *testing.T) {
|
||||
]
|
||||
}`,
|
||||
expectError: true,
|
||||
expectCollectionsCount: 5,
|
||||
expectCollectionsCount: 7,
|
||||
expectEvents: map[string]int{
|
||||
"OnModelBeforeCreate": 2,
|
||||
},
|
||||
@ -124,7 +114,7 @@ func TestCollectionsImportSubmit(t *testing.T) {
|
||||
]
|
||||
}`,
|
||||
expectError: false,
|
||||
expectCollectionsCount: 7,
|
||||
expectCollectionsCount: 9,
|
||||
expectEvents: map[string]int{
|
||||
"OnModelBeforeCreate": 2,
|
||||
"OnModelAfterCreate": 2,
|
||||
@ -147,7 +137,7 @@ func TestCollectionsImportSubmit(t *testing.T) {
|
||||
]
|
||||
}`,
|
||||
expectError: true,
|
||||
expectCollectionsCount: 5,
|
||||
expectCollectionsCount: 7,
|
||||
expectEvents: map[string]int{
|
||||
"OnModelBeforeCreate": 1,
|
||||
},
|
||||
@ -158,8 +148,8 @@ func TestCollectionsImportSubmit(t *testing.T) {
|
||||
"deleteMissing": true,
|
||||
"collections": [
|
||||
{
|
||||
"id":"3f2888f8-075d-49fe-9d09-ea7e951000dc",
|
||||
"name":"demo",
|
||||
"id":"sz5l5z67tg7gku0",
|
||||
"name":"demo2",
|
||||
"schema":[
|
||||
{
|
||||
"id":"_2hlxbmp",
|
||||
@ -189,19 +179,22 @@ func TestCollectionsImportSubmit(t *testing.T) {
|
||||
]
|
||||
}`,
|
||||
expectError: true,
|
||||
expectCollectionsCount: 5,
|
||||
expectCollectionsCount: 7,
|
||||
expectEvents: map[string]int{
|
||||
"OnModelBeforeDelete": 5,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "modified + new collection",
|
||||
jsonData: `{
|
||||
"collections": [
|
||||
{
|
||||
"id":"3f2888f8-075d-49fe-9d09-ea7e951000dc",
|
||||
"name":"demo",
|
||||
"id":"sz5l5z67tg7gku0",
|
||||
"name":"demo2",
|
||||
"schema":[
|
||||
{
|
||||
"id":"_2hlxbmp",
|
||||
"name":"title",
|
||||
"name":"title_new",
|
||||
"type":"text",
|
||||
"system":false,
|
||||
"required":true,
|
||||
@ -237,7 +230,7 @@ func TestCollectionsImportSubmit(t *testing.T) {
|
||||
]
|
||||
}`,
|
||||
expectError: false,
|
||||
expectCollectionsCount: 7,
|
||||
expectCollectionsCount: 9,
|
||||
expectEvents: map[string]int{
|
||||
"OnModelBeforeUpdate": 1,
|
||||
"OnModelAfterUpdate": 1,
|
||||
@ -251,45 +244,44 @@ func TestCollectionsImportSubmit(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":[
|
||||
"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":"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": "x8zzktwe",
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"system": false,
|
||||
"required": false,
|
||||
"unique": false,
|
||||
"options": {
|
||||
"min": null,
|
||||
"max": null,
|
||||
"pattern": ""
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id":"3f2888f8-075d-49fe-9d09-ea7e951000dc",
|
||||
"name":"demo",
|
||||
"id":"sz5l5z67tg7gku0",
|
||||
"name":"demo2",
|
||||
"schema":[
|
||||
{
|
||||
"id":"_2hlxbmp",
|
||||
@ -308,7 +300,7 @@ func TestCollectionsImportSubmit(t *testing.T) {
|
||||
},
|
||||
{
|
||||
"id": "test_deleted_collection_name_reuse",
|
||||
"name": "demo2",
|
||||
"name": "demo1",
|
||||
"schema": [
|
||||
{
|
||||
"id":"fz6iql2m",
|
||||
@ -326,8 +318,8 @@ func TestCollectionsImportSubmit(t *testing.T) {
|
||||
"OnModelAfterUpdate": 2,
|
||||
"OnModelBeforeCreate": 1,
|
||||
"OnModelAfterCreate": 1,
|
||||
"OnModelBeforeDelete": 3,
|
||||
"OnModelAfterDelete": 3,
|
||||
"OnModelBeforeDelete": 5,
|
||||
"OnModelAfterDelete": 5,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
135
forms/record_email_change_confirm.go
Normal file
135
forms/record_email_change_confirm.go
Normal file
@ -0,0 +1,135 @@
|
||||
package forms
|
||||
|
||||
import (
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/daos"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/models/schema"
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
)
|
||||
|
||||
// RecordEmailChangeConfirm is an auth record email change confirmation form.
|
||||
type RecordEmailChangeConfirm struct {
|
||||
app core.App
|
||||
dao *daos.Dao
|
||||
collection *models.Collection
|
||||
|
||||
Token string `form:"token" json:"token"`
|
||||
Password string `form:"password" json:"password"`
|
||||
}
|
||||
|
||||
// NewRecordEmailChangeConfirm creates a new [RecordEmailChangeConfirm] form
|
||||
// initialized with from the provided [core.App] and [models.Collection] instances.
|
||||
//
|
||||
// If you want to submit the form as part of a transaction,
|
||||
// you can change the default Dao via [SetDao()].
|
||||
func NewRecordEmailChangeConfirm(app core.App, collection *models.Collection) *RecordEmailChangeConfirm {
|
||||
return &RecordEmailChangeConfirm{
|
||||
app: app,
|
||||
dao: app.Dao(),
|
||||
collection: collection,
|
||||
}
|
||||
}
|
||||
|
||||
// SetDao replaces the default form Dao instance with the provided one.
|
||||
func (form *RecordEmailChangeConfirm) SetDao(dao *daos.Dao) {
|
||||
form.dao = dao
|
||||
}
|
||||
|
||||
// Validate makes the form validatable by implementing [validation.Validatable] interface.
|
||||
func (form *RecordEmailChangeConfirm) Validate() error {
|
||||
return validation.ValidateStruct(form,
|
||||
validation.Field(
|
||||
&form.Token,
|
||||
validation.Required,
|
||||
validation.By(form.checkToken),
|
||||
),
|
||||
validation.Field(
|
||||
&form.Password,
|
||||
validation.Required,
|
||||
validation.Length(1, 100),
|
||||
validation.By(form.checkPassword),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
func (form *RecordEmailChangeConfirm) checkToken(value any) error {
|
||||
v, _ := value.(string)
|
||||
if v == "" {
|
||||
return nil // nothing to check
|
||||
}
|
||||
|
||||
authRecord, _, err := form.parseToken(v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if authRecord.Collection().Id != form.collection.Id {
|
||||
return validation.NewError("validation_token_collection_mismatch", "The provided token is for different auth collection.")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (form *RecordEmailChangeConfirm) checkPassword(value any) error {
|
||||
v, _ := value.(string)
|
||||
if v == "" {
|
||||
return nil // nothing to check
|
||||
}
|
||||
|
||||
authRecord, _, _ := form.parseToken(form.Token)
|
||||
if authRecord == nil || !authRecord.ValidatePassword(v) {
|
||||
return validation.NewError("validation_invalid_password", "Missing or invalid auth record password.")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (form *RecordEmailChangeConfirm) parseToken(token string) (*models.Record, string, error) {
|
||||
// check token payload
|
||||
claims, _ := security.ParseUnverifiedJWT(token)
|
||||
newEmail, _ := claims["newEmail"].(string)
|
||||
if newEmail == "" {
|
||||
return nil, "", validation.NewError("validation_invalid_token_payload", "Invalid token payload - newEmail must be set.")
|
||||
}
|
||||
|
||||
// ensure that there aren't other users with the new email
|
||||
if !form.dao.IsRecordValueUnique(form.collection.Id, schema.FieldNameEmail, newEmail) {
|
||||
return nil, "", validation.NewError("validation_existing_token_email", "The new email address is already registered: "+newEmail)
|
||||
}
|
||||
|
||||
// verify that the token is not expired and its signature is valid
|
||||
authRecord, err := form.dao.FindAuthRecordByToken(
|
||||
token,
|
||||
form.app.Settings().RecordEmailChangeToken.Secret,
|
||||
)
|
||||
if err != nil || authRecord == nil {
|
||||
return nil, "", validation.NewError("validation_invalid_token", "Invalid or expired token.")
|
||||
}
|
||||
|
||||
return authRecord, newEmail, nil
|
||||
}
|
||||
|
||||
// Submit validates and submits the auth record email change confirmation form.
|
||||
// On success returns the updated auth record associated to `form.Token`.
|
||||
func (form *RecordEmailChangeConfirm) Submit() (*models.Record, error) {
|
||||
if err := form.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
authRecord, newEmail, err := form.parseToken(form.Token)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
authRecord.SetEmail(newEmail)
|
||||
authRecord.SetVerified(true)
|
||||
authRecord.RefreshTokenKey() // invalidate old tokens
|
||||
|
||||
if err := form.dao.SaveRecord(authRecord); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return authRecord, nil
|
||||
}
|
126
forms/record_email_change_confirm_test.go
Normal file
126
forms/record_email_change_confirm_test.go
Normal file
@ -0,0 +1,126 @@
|
||||
package forms_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
"github.com/pocketbase/pocketbase/forms"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
)
|
||||
|
||||
func TestRecordEmailChangeConfirmValidateAndSubmit(t *testing.T) {
|
||||
testApp, _ := tests.NewTestApp()
|
||||
defer testApp.Cleanup()
|
||||
|
||||
authCollection, err := testApp.Dao().FindCollectionByNameOrId("users")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
scenarios := []struct {
|
||||
jsonData string
|
||||
expectedErrors []string
|
||||
}{
|
||||
// empty payload
|
||||
{"{}", []string{"token", "password"}},
|
||||
// empty data
|
||||
{
|
||||
`{"token": "", "password": ""}`,
|
||||
[]string{"token", "password"},
|
||||
},
|
||||
// invalid token payload
|
||||
{
|
||||
`{
|
||||
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyMjA4OTg1MjYxfQ.quDgaCi2rGTRx3qO06CrFvHdeCua_5J7CCVWSaFhkus",
|
||||
"password": "123456"
|
||||
}`,
|
||||
[]string{"token", "password"},
|
||||
},
|
||||
// expired token
|
||||
{
|
||||
`{
|
||||
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZW1haWwiOiJ0ZXN0QGV4YW1wbGUuY29tIiwibmV3RW1haWwiOiJ0ZXN0X25ld0BleGFtcGxlLmNvbSIsImV4cCI6MTYwOTQ1NTY2MX0.n1OJXJEACMNPT9aMTO48cVJexIiZEtHsz4UNBIfMcf4",
|
||||
"password": "123456"
|
||||
}`,
|
||||
[]string{"token", "password"},
|
||||
},
|
||||
// existing new email
|
||||
{
|
||||
`{
|
||||
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZW1haWwiOiJ0ZXN0QGV4YW1wbGUuY29tIiwibmV3RW1haWwiOiJ0ZXN0MkBleGFtcGxlLmNvbSIsImV4cCI6MjIwODk4NTI2MX0.Q_o6zpc2URggTU0mWv2CS0rIPbQhFdmrjZ-ASwHh1Ww",
|
||||
"password": "1234567890"
|
||||
}`,
|
||||
[]string{"token", "password"},
|
||||
},
|
||||
// wrong confirmation password
|
||||
{
|
||||
`{
|
||||
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZW1haWwiOiJ0ZXN0QGV4YW1wbGUuY29tIiwibmV3RW1haWwiOiJ0ZXN0X25ld0BleGFtcGxlLmNvbSIsImV4cCI6MjIwODk4NTI2MX0.hmR7Ye23C68tS1LgHgYgT7NBJczTad34kzcT4sqW3FY",
|
||||
"password": "123456"
|
||||
}`,
|
||||
[]string{"password"},
|
||||
},
|
||||
// valid data
|
||||
{
|
||||
`{
|
||||
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZW1haWwiOiJ0ZXN0QGV4YW1wbGUuY29tIiwibmV3RW1haWwiOiJ0ZXN0X25ld0BleGFtcGxlLmNvbSIsImV4cCI6MjIwODk4NTI2MX0.hmR7Ye23C68tS1LgHgYgT7NBJczTad34kzcT4sqW3FY",
|
||||
"password": "1234567890"
|
||||
}`,
|
||||
[]string{},
|
||||
},
|
||||
}
|
||||
|
||||
for i, s := range scenarios {
|
||||
form := forms.NewRecordEmailChangeConfirm(testApp, authCollection)
|
||||
|
||||
// load data
|
||||
loadErr := json.Unmarshal([]byte(s.jsonData), form)
|
||||
if loadErr != nil {
|
||||
t.Errorf("(%d) Failed to load form data: %v", i, loadErr)
|
||||
continue
|
||||
}
|
||||
|
||||
record, err := form.Submit()
|
||||
|
||||
// parse errors
|
||||
errs, ok := err.(validation.Errors)
|
||||
if !ok && err != nil {
|
||||
t.Errorf("(%d) Failed to parse errors %v", i, err)
|
||||
continue
|
||||
}
|
||||
|
||||
// check errors
|
||||
if len(errs) > len(s.expectedErrors) {
|
||||
t.Errorf("(%d) Expected error keys %v, got %v", i, s.expectedErrors, errs)
|
||||
}
|
||||
for _, k := range s.expectedErrors {
|
||||
if _, ok := errs[k]; !ok {
|
||||
t.Errorf("(%d) Missing expected error key %q in %v", i, k, errs)
|
||||
}
|
||||
}
|
||||
|
||||
if len(errs) > 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
claims, _ := security.ParseUnverifiedJWT(form.Token)
|
||||
newEmail, _ := claims["newEmail"].(string)
|
||||
|
||||
// check whether the user was updated
|
||||
// ---
|
||||
if record.Email() != newEmail {
|
||||
t.Errorf("(%d) Expected record email %q, got %q", i, newEmail, record.Email())
|
||||
}
|
||||
|
||||
if !record.Verified() {
|
||||
t.Errorf("(%d) Expected record to be verified, got false", i)
|
||||
}
|
||||
|
||||
// shouldn't validate second time due to refreshed record token
|
||||
if err := form.Validate(); err == nil {
|
||||
t.Errorf("(%d) Expected error, got nil", i)
|
||||
}
|
||||
}
|
||||
}
|
70
forms/record_email_change_request.go
Normal file
70
forms/record_email_change_request.go
Normal file
@ -0,0 +1,70 @@
|
||||
package forms
|
||||
|
||||
import (
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
"github.com/go-ozzo/ozzo-validation/v4/is"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/daos"
|
||||
"github.com/pocketbase/pocketbase/mails"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/models/schema"
|
||||
)
|
||||
|
||||
// RecordEmailChangeRequest is an auth record email change request form.
|
||||
type RecordEmailChangeRequest struct {
|
||||
app core.App
|
||||
dao *daos.Dao
|
||||
record *models.Record
|
||||
|
||||
NewEmail string `form:"newEmail" json:"newEmail"`
|
||||
}
|
||||
|
||||
// NewRecordEmailChangeRequest creates a new [RecordEmailChangeRequest] form
|
||||
// initialized with from the provided [core.App] and [models.Record] instances.
|
||||
//
|
||||
// If you want to submit the form as part of a transaction,
|
||||
// you can change the default Dao via [SetDao()].
|
||||
func NewRecordEmailChangeRequest(app core.App, record *models.Record) *RecordEmailChangeRequest {
|
||||
return &RecordEmailChangeRequest{
|
||||
app: app,
|
||||
dao: app.Dao(),
|
||||
record: record,
|
||||
}
|
||||
}
|
||||
|
||||
// SetDao replaces the default form Dao instance with the provided one.
|
||||
func (form *RecordEmailChangeRequest) SetDao(dao *daos.Dao) {
|
||||
form.dao = dao
|
||||
}
|
||||
|
||||
// Validate makes the form validatable by implementing [validation.Validatable] interface.
|
||||
func (form *RecordEmailChangeRequest) Validate() error {
|
||||
return validation.ValidateStruct(form,
|
||||
validation.Field(
|
||||
&form.NewEmail,
|
||||
validation.Required,
|
||||
validation.Length(1, 255),
|
||||
is.EmailFormat,
|
||||
validation.By(form.checkUniqueEmail),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
func (form *RecordEmailChangeRequest) checkUniqueEmail(value any) error {
|
||||
v, _ := value.(string)
|
||||
|
||||
if !form.dao.IsRecordValueUnique(form.record.Collection().Id, schema.FieldNameEmail, v) {
|
||||
return validation.NewError("validation_record_email_exists", "User email already exists.")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Submit validates and sends the change email request.
|
||||
func (form *RecordEmailChangeRequest) Submit() error {
|
||||
if err := form.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return mails.SendRecordChangeEmail(form.app, form.record, form.NewEmail)
|
||||
}
|
@ -9,34 +9,11 @@ import (
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
)
|
||||
|
||||
func TestUserEmailChangeRequestPanic1(t *testing.T) {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("The form did not panic")
|
||||
}
|
||||
}()
|
||||
|
||||
forms.NewUserEmailChangeRequest(nil, nil)
|
||||
}
|
||||
|
||||
func TestUserEmailChangeRequestPanic2(t *testing.T) {
|
||||
func TestRecordEmailChangeRequestValidateAndSubmit(t *testing.T) {
|
||||
testApp, _ := tests.NewTestApp()
|
||||
defer testApp.Cleanup()
|
||||
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("The form did not panic")
|
||||
}
|
||||
}()
|
||||
|
||||
forms.NewUserEmailChangeRequest(testApp, nil)
|
||||
}
|
||||
|
||||
func TestUserEmailChangeRequestValidateAndSubmit(t *testing.T) {
|
||||
testApp, _ := tests.NewTestApp()
|
||||
defer testApp.Cleanup()
|
||||
|
||||
user, err := testApp.Dao().FindUserByEmail("test@example.com")
|
||||
user, err := testApp.Dao().FindAuthRecordByEmail("users", "test@example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@ -59,7 +36,7 @@ func TestUserEmailChangeRequestValidateAndSubmit(t *testing.T) {
|
||||
},
|
||||
// existing email token
|
||||
{
|
||||
`{"newEmail": "test@example.com"}`,
|
||||
`{"newEmail": "test2@example.com"}`,
|
||||
[]string{"newEmail"},
|
||||
},
|
||||
// valid new email
|
||||
@ -71,7 +48,7 @@ func TestUserEmailChangeRequestValidateAndSubmit(t *testing.T) {
|
||||
|
||||
for i, s := range scenarios {
|
||||
testApp.TestMailer.TotalSend = 0 // reset
|
||||
form := forms.NewUserEmailChangeRequest(testApp, user)
|
||||
form := forms.NewRecordEmailChangeRequest(testApp, user)
|
||||
|
||||
// load data
|
||||
loadErr := json.Unmarshal([]byte(s.jsonData), form)
|
234
forms/record_oauth2_login.go
Normal file
234
forms/record_oauth2_login.go
Normal file
@ -0,0 +1,234 @@
|
||||
package forms
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
"github.com/go-ozzo/ozzo-validation/v4/is"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/daos"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/tools/auth"
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
// RecordOAuth2Login is an auth record OAuth2 login form.
|
||||
type RecordOAuth2Login struct {
|
||||
app core.App
|
||||
dao *daos.Dao
|
||||
collection *models.Collection
|
||||
|
||||
// Optional auth record that will be used if no external
|
||||
// auth relation is found (if it is from the same collection)
|
||||
loggedAuthRecord *models.Record
|
||||
|
||||
// The name of the OAuth2 client provider (eg. "google")
|
||||
Provider string `form:"provider" json:"provider"`
|
||||
|
||||
// The authorization code returned from the initial request.
|
||||
Code string `form:"code" json:"code"`
|
||||
|
||||
// The code verifier sent with the initial request as part of the code_challenge.
|
||||
CodeVerifier string `form:"codeVerifier" json:"codeVerifier"`
|
||||
|
||||
// The redirect url sent with the initial request.
|
||||
RedirectUrl string `form:"redirectUrl" json:"redirectUrl"`
|
||||
|
||||
// Additional data that will be used for creating a new auth record
|
||||
// if an existing OAuth2 account doesn't exist.
|
||||
CreateData map[string]any `form:"createData" json:"createData"`
|
||||
}
|
||||
|
||||
// NewRecordOAuth2Login creates a new [RecordOAuth2Login] form with
|
||||
// initialized with from the provided [core.App] instance.
|
||||
//
|
||||
// If you want to submit the form as part of a transaction,
|
||||
// you can change the default Dao via [SetDao()].
|
||||
func NewRecordOAuth2Login(app core.App, collection *models.Collection, optAuthRecord *models.Record) *RecordOAuth2Login {
|
||||
form := &RecordOAuth2Login{
|
||||
app: app,
|
||||
dao: app.Dao(),
|
||||
collection: collection,
|
||||
loggedAuthRecord: optAuthRecord,
|
||||
}
|
||||
|
||||
return form
|
||||
}
|
||||
|
||||
// SetDao replaces the default form Dao instance with the provided one.
|
||||
func (form *RecordOAuth2Login) SetDao(dao *daos.Dao) {
|
||||
form.dao = dao
|
||||
}
|
||||
|
||||
// Validate makes the form validatable by implementing [validation.Validatable] interface.
|
||||
func (form *RecordOAuth2Login) Validate() error {
|
||||
return validation.ValidateStruct(form,
|
||||
validation.Field(&form.Provider, validation.Required, validation.By(form.checkProviderName)),
|
||||
validation.Field(&form.Code, validation.Required),
|
||||
validation.Field(&form.CodeVerifier, validation.Required),
|
||||
validation.Field(&form.RedirectUrl, validation.Required, is.URL),
|
||||
)
|
||||
}
|
||||
|
||||
func (form *RecordOAuth2Login) checkProviderName(value any) error {
|
||||
name, _ := value.(string)
|
||||
|
||||
config, ok := form.app.Settings().NamedAuthProviderConfigs()[name]
|
||||
if !ok || !config.Enabled {
|
||||
return validation.NewError("validation_invalid_provider", fmt.Sprintf("%q is missing or is not enabled.", name))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Submit validates and submits the form.
|
||||
//
|
||||
// If an auth record doesn't exist, it will make an attempt to create it
|
||||
// based on the fetched OAuth2 profile data via a local [RecordUpsert] form.
|
||||
// You can intercept/modify the create form by setting the optional beforeCreateFuncs argument.
|
||||
//
|
||||
// On success returns the authorized record model and the fetched provider's data.
|
||||
func (form *RecordOAuth2Login) Submit(
|
||||
beforeCreateFuncs ...func(createForm *RecordUpsert, authRecord *models.Record, authUser *auth.AuthUser) error,
|
||||
) (*models.Record, *auth.AuthUser, error) {
|
||||
if err := form.Validate(); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
if !form.collection.AuthOptions().AllowOAuth2Auth {
|
||||
return nil, nil, errors.New("OAuth2 authentication is not allowed for the auth collection.")
|
||||
}
|
||||
|
||||
provider, err := auth.NewProviderByName(form.Provider)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
// load provider configuration
|
||||
providerConfig := form.app.Settings().NamedAuthProviderConfigs()[form.Provider]
|
||||
if err := providerConfig.SetupProvider(provider); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
provider.SetRedirectUrl(form.RedirectUrl)
|
||||
|
||||
// fetch token
|
||||
token, err := provider.FetchToken(
|
||||
form.Code,
|
||||
oauth2.SetAuthURLParam("code_verifier", form.CodeVerifier),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
// fetch external auth user
|
||||
authUser, err := provider.FetchAuthUser(token)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
var authRecord *models.Record
|
||||
|
||||
// check for existing relation with the auth record
|
||||
rel, _ := form.dao.FindExternalAuthByProvider(form.Provider, authUser.Id)
|
||||
switch {
|
||||
case rel != nil:
|
||||
authRecord, err = form.dao.FindRecordById(form.collection.Id, rel.RecordId)
|
||||
if err != nil {
|
||||
return nil, authUser, err
|
||||
}
|
||||
case form.loggedAuthRecord != nil && form.loggedAuthRecord.Collection().Id == form.collection.Id:
|
||||
// fallback to the logged auth record (if any)
|
||||
authRecord = form.loggedAuthRecord
|
||||
case authUser.Email != "":
|
||||
// look for an existing auth record by the external auth record's email
|
||||
authRecord, _ = form.dao.FindAuthRecordByEmail(form.collection.Id, authUser.Email)
|
||||
}
|
||||
|
||||
saveErr := form.dao.RunInTransaction(func(txDao *daos.Dao) error {
|
||||
if authRecord == nil {
|
||||
authRecord = models.NewRecord(form.collection)
|
||||
authRecord.RefreshId()
|
||||
authRecord.MarkAsNew()
|
||||
createForm := NewRecordUpsert(form.app, authRecord)
|
||||
createForm.SetFullManageAccess(true)
|
||||
createForm.SetDao(txDao)
|
||||
if authUser.Username != "" {
|
||||
createForm.Username = form.dao.SuggestUniqueAuthRecordUsername(form.collection.Id, authUser.Username)
|
||||
}
|
||||
|
||||
// load custom data
|
||||
createForm.LoadData(form.CreateData)
|
||||
|
||||
// load the OAuth2 profile data as fallback
|
||||
if createForm.Email == "" {
|
||||
createForm.Email = authUser.Email
|
||||
}
|
||||
createForm.Verified = false
|
||||
if createForm.Email == authUser.Email {
|
||||
// mark as verified as long as it matches the OAuth2 data (even if the email is empty)
|
||||
createForm.Verified = true
|
||||
}
|
||||
if createForm.Password == "" {
|
||||
createForm.Password = security.RandomString(30)
|
||||
createForm.PasswordConfirm = createForm.Password
|
||||
}
|
||||
|
||||
for _, f := range beforeCreateFuncs {
|
||||
if f == nil {
|
||||
continue
|
||||
}
|
||||
if err := f(createForm, authRecord, authUser); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// create the new auth record
|
||||
if err := createForm.Submit(); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
// update the existing auth record empty email if the authUser has one
|
||||
// (this is in case previously the auth record was created
|
||||
// with an OAuth2 provider that didn't return an email address)
|
||||
if authRecord.Email() == "" && authUser.Email != "" {
|
||||
authRecord.SetEmail(authUser.Email)
|
||||
if err := txDao.SaveRecord(authRecord); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// update the existing auth record verified state
|
||||
// (only if the auth record doesn't have an email or the auth record email match with the one in authUser)
|
||||
if !authRecord.Verified() && (authRecord.Email() == "" || authRecord.Email() == authUser.Email) {
|
||||
authRecord.SetVerified(true)
|
||||
if err := txDao.SaveRecord(authRecord); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// create ExternalAuth relation if missing
|
||||
if rel == nil {
|
||||
rel = &models.ExternalAuth{
|
||||
CollectionId: authRecord.Collection().Id,
|
||||
RecordId: authRecord.Id,
|
||||
Provider: form.Provider,
|
||||
ProviderId: authUser.Id,
|
||||
}
|
||||
if err := txDao.SaveExternalAuth(rel); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
if saveErr != nil {
|
||||
return nil, authUser, saveErr
|
||||
}
|
||||
|
||||
return authRecord, authUser, nil
|
||||
}
|
@ -9,55 +9,60 @@ import (
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
)
|
||||
|
||||
func TestUserOauth2LoginPanic(t *testing.T) {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("The form did not panic")
|
||||
}
|
||||
}()
|
||||
|
||||
forms.NewUserOauth2Login(nil)
|
||||
}
|
||||
|
||||
func TestUserOauth2LoginValidate(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
scenarios := []struct {
|
||||
testName string
|
||||
collectionName string
|
||||
jsonData string
|
||||
expectedErrors []string
|
||||
}{
|
||||
// empty payload
|
||||
{"{}", []string{"provider", "code", "codeVerifier", "redirectUrl"}},
|
||||
// empty data
|
||||
{
|
||||
"empty payload",
|
||||
"users",
|
||||
"{}",
|
||||
[]string{"provider", "code", "codeVerifier", "redirectUrl"},
|
||||
},
|
||||
{
|
||||
"empty data",
|
||||
"users",
|
||||
`{"provider":"","code":"","codeVerifier":"","redirectUrl":""}`,
|
||||
[]string{"provider", "code", "codeVerifier", "redirectUrl"},
|
||||
},
|
||||
// missing provider
|
||||
{
|
||||
"missing provider",
|
||||
"users",
|
||||
`{"provider":"missing","code":"123","codeVerifier":"123","redirectUrl":"https://example.com"}`,
|
||||
[]string{"provider"},
|
||||
},
|
||||
// disabled provider
|
||||
{
|
||||
"disabled provider",
|
||||
"users",
|
||||
`{"provider":"github","code":"123","codeVerifier":"123","redirectUrl":"https://example.com"}`,
|
||||
[]string{"provider"},
|
||||
},
|
||||
// enabled provider
|
||||
{
|
||||
"enabled provider",
|
||||
"users",
|
||||
`{"provider":"gitlab","code":"123","codeVerifier":"123","redirectUrl":"https://example.com"}`,
|
||||
[]string{},
|
||||
},
|
||||
}
|
||||
|
||||
for i, s := range scenarios {
|
||||
form := forms.NewUserOauth2Login(app)
|
||||
for _, s := range scenarios {
|
||||
authCollection, _ := app.Dao().FindCollectionByNameOrId(s.collectionName)
|
||||
if authCollection == nil {
|
||||
t.Errorf("[%s] Failed to fetch auth collection", s.testName)
|
||||
}
|
||||
|
||||
form := forms.NewRecordOAuth2Login(app, authCollection, nil)
|
||||
|
||||
// load data
|
||||
loadErr := json.Unmarshal([]byte(s.jsonData), form)
|
||||
if loadErr != nil {
|
||||
t.Errorf("(%d) Failed to load form data: %v", i, loadErr)
|
||||
t.Errorf("[%s] Failed to load form data: %v", s.testName, loadErr)
|
||||
continue
|
||||
}
|
||||
|
||||
@ -66,17 +71,17 @@ func TestUserOauth2LoginValidate(t *testing.T) {
|
||||
// parse errors
|
||||
errs, ok := err.(validation.Errors)
|
||||
if !ok && err != nil {
|
||||
t.Errorf("(%d) Failed to parse errors %v", i, err)
|
||||
t.Errorf("[%s] Failed to parse errors %v", s.testName, err)
|
||||
continue
|
||||
}
|
||||
|
||||
// check errors
|
||||
if len(errs) > len(s.expectedErrors) {
|
||||
t.Errorf("(%d) Expected error keys %v, got %v", i, s.expectedErrors, errs)
|
||||
t.Errorf("[%s] Expected error keys %v, got %v", s.testName, s.expectedErrors, errs)
|
||||
}
|
||||
for _, k := range s.expectedErrors {
|
||||
if _, ok := errs[k]; !ok {
|
||||
t.Errorf("(%d) Missing expected error key %q in %v", i, k, errs)
|
||||
t.Errorf("[%s] Missing expected error key %q in %v", s.testName, k, errs)
|
||||
}
|
||||
}
|
||||
}
|
77
forms/record_password_login.go
Normal file
77
forms/record_password_login.go
Normal file
@ -0,0 +1,77 @@
|
||||
package forms
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
"github.com/go-ozzo/ozzo-validation/v4/is"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/daos"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
)
|
||||
|
||||
// RecordPasswordLogin is record username/email + password login form.
|
||||
type RecordPasswordLogin struct {
|
||||
app core.App
|
||||
dao *daos.Dao
|
||||
collection *models.Collection
|
||||
|
||||
Identity string `form:"identity" json:"identity"`
|
||||
Password string `form:"password" json:"password"`
|
||||
}
|
||||
|
||||
// NewRecordPasswordLogin creates a new [RecordPasswordLogin] form initialized
|
||||
// with from the provided [core.App] and [models.Collection] instance.
|
||||
//
|
||||
// If you want to submit the form as part of a transaction,
|
||||
// you can change the default Dao via [SetDao()].
|
||||
func NewRecordPasswordLogin(app core.App, collection *models.Collection) *RecordPasswordLogin {
|
||||
return &RecordPasswordLogin{
|
||||
app: app,
|
||||
dao: app.Dao(),
|
||||
collection: collection,
|
||||
}
|
||||
}
|
||||
|
||||
// SetDao replaces the default form Dao instance with the provided one.
|
||||
func (form *RecordPasswordLogin) SetDao(dao *daos.Dao) {
|
||||
form.dao = dao
|
||||
}
|
||||
|
||||
// Validate makes the form validatable by implementing [validation.Validatable] interface.
|
||||
func (form *RecordPasswordLogin) Validate() error {
|
||||
return validation.ValidateStruct(form,
|
||||
validation.Field(&form.Identity, validation.Required, validation.Length(1, 255)),
|
||||
validation.Field(&form.Password, validation.Required, validation.Length(1, 255)),
|
||||
)
|
||||
}
|
||||
|
||||
// Submit validates and submits the form.
|
||||
// On success returns the authorized record model.
|
||||
func (form *RecordPasswordLogin) Submit() (*models.Record, error) {
|
||||
if err := form.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
authOptions := form.collection.AuthOptions()
|
||||
|
||||
if !authOptions.AllowEmailAuth && !authOptions.AllowUsernameAuth {
|
||||
return nil, errors.New("Password authentication is not allowed for the collection.")
|
||||
}
|
||||
|
||||
var record *models.Record
|
||||
var fetchErr error
|
||||
|
||||
if authOptions.AllowEmailAuth &&
|
||||
(!authOptions.AllowUsernameAuth || is.EmailFormat.Validate(form.Identity) == nil) {
|
||||
record, fetchErr = form.dao.FindAuthRecordByEmail(form.collection.Id, form.Identity)
|
||||
} else {
|
||||
record, fetchErr = form.dao.FindAuthRecordByUsername(form.collection.Id, form.Identity)
|
||||
}
|
||||
|
||||
if fetchErr != nil || !record.ValidatePassword(form.Password) {
|
||||
return nil, errors.New("Invalid login credentials.")
|
||||
}
|
||||
|
||||
return record, nil
|
||||
}
|
130
forms/record_password_login_test.go
Normal file
130
forms/record_password_login_test.go
Normal file
@ -0,0 +1,130 @@
|
||||
package forms_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/pocketbase/pocketbase/forms"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
)
|
||||
|
||||
func TestRecordEmailLoginValidateAndSubmit(t *testing.T) {
|
||||
testApp, _ := tests.NewTestApp()
|
||||
defer testApp.Cleanup()
|
||||
|
||||
scenarios := []struct {
|
||||
testName string
|
||||
collectionName string
|
||||
identity string
|
||||
password string
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
"empty data",
|
||||
"users",
|
||||
"",
|
||||
"",
|
||||
true,
|
||||
},
|
||||
|
||||
// username
|
||||
{
|
||||
"existing username + wrong password",
|
||||
"users",
|
||||
"users75657",
|
||||
"invalid",
|
||||
true,
|
||||
},
|
||||
{
|
||||
"missing username + valid password",
|
||||
"users",
|
||||
"clients57772", // not in the "users" collection
|
||||
"1234567890",
|
||||
true,
|
||||
},
|
||||
{
|
||||
"existing username + valid password but in restricted username auth collection",
|
||||
"clients",
|
||||
"clients57772",
|
||||
"1234567890",
|
||||
true,
|
||||
},
|
||||
{
|
||||
"existing username + valid password but in restricted username and email auth collection",
|
||||
"nologin",
|
||||
"test_username",
|
||||
"1234567890",
|
||||
true,
|
||||
},
|
||||
{
|
||||
"existing username + valid password",
|
||||
"users",
|
||||
"users75657",
|
||||
"1234567890",
|
||||
false,
|
||||
},
|
||||
|
||||
// email
|
||||
{
|
||||
"existing email + wrong password",
|
||||
"users",
|
||||
"test@example.com",
|
||||
"invalid",
|
||||
true,
|
||||
},
|
||||
{
|
||||
"missing email + valid password",
|
||||
"users",
|
||||
"test_missing@example.com",
|
||||
"1234567890",
|
||||
true,
|
||||
},
|
||||
{
|
||||
"existing username + valid password but in restricted username auth collection",
|
||||
"clients",
|
||||
"test@example.com",
|
||||
"1234567890",
|
||||
false,
|
||||
},
|
||||
{
|
||||
"existing username + valid password but in restricted username and email auth collection",
|
||||
"nologin",
|
||||
"test@example.com",
|
||||
"1234567890",
|
||||
true,
|
||||
},
|
||||
{
|
||||
"existing email + valid password",
|
||||
"users",
|
||||
"test@example.com",
|
||||
"1234567890",
|
||||
false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
authCollection, err := testApp.Dao().FindCollectionByNameOrId(s.collectionName)
|
||||
if err != nil {
|
||||
t.Errorf("[%s] Failed to fetch auth collection: %v", s.testName, err)
|
||||
}
|
||||
|
||||
form := forms.NewRecordPasswordLogin(testApp, authCollection)
|
||||
form.Identity = s.identity
|
||||
form.Password = s.password
|
||||
|
||||
record, err := form.Submit()
|
||||
|
||||
hasErr := err != nil
|
||||
if hasErr != s.expectError {
|
||||
t.Errorf("[%s] Expected hasErr to be %v, got %v (%v)", s.testName, s.expectError, hasErr, err)
|
||||
continue
|
||||
}
|
||||
|
||||
if hasErr {
|
||||
continue
|
||||
}
|
||||
|
||||
if record.Email() != s.identity && record.Username() != s.identity {
|
||||
t.Errorf("[%s] Expected record with identity %q, got \n%v", s.testName, s.identity, record)
|
||||
}
|
||||
}
|
||||
}
|
96
forms/record_password_reset_confirm.go
Normal file
96
forms/record_password_reset_confirm.go
Normal file
@ -0,0 +1,96 @@
|
||||
package forms
|
||||
|
||||
import (
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/daos"
|
||||
"github.com/pocketbase/pocketbase/forms/validators"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
)
|
||||
|
||||
// RecordPasswordResetConfirm is an auth record password reset confirmation form.
|
||||
type RecordPasswordResetConfirm struct {
|
||||
app core.App
|
||||
collection *models.Collection
|
||||
dao *daos.Dao
|
||||
|
||||
Token string `form:"token" json:"token"`
|
||||
Password string `form:"password" json:"password"`
|
||||
PasswordConfirm string `form:"passwordConfirm" json:"passwordConfirm"`
|
||||
}
|
||||
|
||||
// NewRecordPasswordResetConfirm creates a new [RecordPasswordResetConfirm]
|
||||
// form initialized with from the provided [core.App] instance.
|
||||
//
|
||||
// If you want to submit the form as part of a transaction,
|
||||
// you can change the default Dao via [SetDao()].
|
||||
func NewRecordPasswordResetConfirm(app core.App, collection *models.Collection) *RecordPasswordResetConfirm {
|
||||
return &RecordPasswordResetConfirm{
|
||||
app: app,
|
||||
dao: app.Dao(),
|
||||
collection: collection,
|
||||
}
|
||||
}
|
||||
|
||||
// SetDao replaces the default form Dao instance with the provided one.
|
||||
func (form *RecordPasswordResetConfirm) SetDao(dao *daos.Dao) {
|
||||
form.dao = dao
|
||||
}
|
||||
|
||||
// Validate makes the form validatable by implementing [validation.Validatable] interface.
|
||||
func (form *RecordPasswordResetConfirm) Validate() error {
|
||||
minPasswordLength := form.collection.AuthOptions().MinPasswordLength
|
||||
|
||||
return validation.ValidateStruct(form,
|
||||
validation.Field(&form.Token, validation.Required, validation.By(form.checkToken)),
|
||||
validation.Field(&form.Password, validation.Required, validation.Length(minPasswordLength, 100)),
|
||||
validation.Field(&form.PasswordConfirm, validation.Required, validation.By(validators.Compare(form.Password))),
|
||||
)
|
||||
}
|
||||
|
||||
func (form *RecordPasswordResetConfirm) checkToken(value any) error {
|
||||
v, _ := value.(string)
|
||||
if v == "" {
|
||||
return nil // nothing to check
|
||||
}
|
||||
|
||||
record, err := form.dao.FindAuthRecordByToken(
|
||||
v,
|
||||
form.app.Settings().RecordPasswordResetToken.Secret,
|
||||
)
|
||||
if err != nil || record == nil {
|
||||
return validation.NewError("validation_invalid_token", "Invalid or expired token.")
|
||||
}
|
||||
|
||||
if record.Collection().Id != form.collection.Id {
|
||||
return validation.NewError("validation_token_collection_mismatch", "The provided token is for different auth collection.")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Submit validates and submits the form.
|
||||
// On success returns the updated auth record associated to `form.Token`.
|
||||
func (form *RecordPasswordResetConfirm) Submit() (*models.Record, error) {
|
||||
if err := form.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
authRecord, err := form.dao.FindAuthRecordByToken(
|
||||
form.Token,
|
||||
form.app.Settings().RecordPasswordResetToken.Secret,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := authRecord.SetPassword(form.Password); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := form.dao.SaveRecord(authRecord); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return authRecord, nil
|
||||
}
|
117
forms/record_password_reset_confirm_test.go
Normal file
117
forms/record_password_reset_confirm_test.go
Normal file
@ -0,0 +1,117 @@
|
||||
package forms_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
"github.com/pocketbase/pocketbase/forms"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
)
|
||||
|
||||
func TestRecordPasswordResetConfirmValidateAndSubmit(t *testing.T) {
|
||||
testApp, _ := tests.NewTestApp()
|
||||
defer testApp.Cleanup()
|
||||
|
||||
authCollection, err := testApp.Dao().FindCollectionByNameOrId("users")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
scenarios := []struct {
|
||||
jsonData string
|
||||
expectedErrors []string
|
||||
}{
|
||||
// empty data (Validate call check)
|
||||
{
|
||||
`{}`,
|
||||
[]string{"token", "password", "passwordConfirm"},
|
||||
},
|
||||
// expired token
|
||||
{
|
||||
`{
|
||||
"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImNvbGxlY3Rpb25JZCI6Il9wYl91c2Vyc19hdXRoXyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiZXhwIjoxNjQwOTkxNjYxfQ.TayHoXkOTM0w8InkBEb86npMJEaf6YVUrxrRmMgFjeY",
|
||||
"password":"12345678",
|
||||
"passwordConfirm":"12345678"
|
||||
}`,
|
||||
[]string{"token"},
|
||||
},
|
||||
// valid token but invalid passwords lengths
|
||||
{
|
||||
`{
|
||||
"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImNvbGxlY3Rpb25JZCI6Il9wYl91c2Vyc19hdXRoXyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiZXhwIjoyMjA4OTg1MjYxfQ.R_4FOSUHIuJQ5Crl3PpIPCXMsoHzuTaNlccpXg_3FOg",
|
||||
"password":"1234567",
|
||||
"passwordConfirm":"1234567"
|
||||
}`,
|
||||
[]string{"password"},
|
||||
},
|
||||
// valid token but mismatched passwordConfirm
|
||||
{
|
||||
`{
|
||||
"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImNvbGxlY3Rpb25JZCI6Il9wYl91c2Vyc19hdXRoXyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiZXhwIjoyMjA4OTg1MjYxfQ.R_4FOSUHIuJQ5Crl3PpIPCXMsoHzuTaNlccpXg_3FOg",
|
||||
"password":"12345678",
|
||||
"passwordConfirm":"12345679"
|
||||
}`,
|
||||
[]string{"passwordConfirm"},
|
||||
},
|
||||
// valid token and password
|
||||
{
|
||||
`{
|
||||
"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImNvbGxlY3Rpb25JZCI6Il9wYl91c2Vyc19hdXRoXyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiZXhwIjoyMjA4OTg1MjYxfQ.R_4FOSUHIuJQ5Crl3PpIPCXMsoHzuTaNlccpXg_3FOg",
|
||||
"password":"12345678",
|
||||
"passwordConfirm":"12345678"
|
||||
}`,
|
||||
[]string{},
|
||||
},
|
||||
}
|
||||
|
||||
for i, s := range scenarios {
|
||||
form := forms.NewRecordPasswordResetConfirm(testApp, authCollection)
|
||||
|
||||
// load data
|
||||
loadErr := json.Unmarshal([]byte(s.jsonData), form)
|
||||
if loadErr != nil {
|
||||
t.Errorf("(%d) Failed to load form data: %v", i, loadErr)
|
||||
continue
|
||||
}
|
||||
|
||||
record, submitErr := form.Submit()
|
||||
|
||||
// parse errors
|
||||
errs, ok := submitErr.(validation.Errors)
|
||||
if !ok && submitErr != nil {
|
||||
t.Errorf("(%d) Failed to parse errors %v", i, submitErr)
|
||||
continue
|
||||
}
|
||||
|
||||
// check errors
|
||||
if len(errs) > len(s.expectedErrors) {
|
||||
t.Errorf("(%d) Expected error keys %v, got %v", i, s.expectedErrors, errs)
|
||||
}
|
||||
for _, k := range s.expectedErrors {
|
||||
if _, ok := errs[k]; !ok {
|
||||
t.Errorf("(%d) Missing expected error key %q in %v", i, k, errs)
|
||||
}
|
||||
}
|
||||
|
||||
if len(errs) > 0 || len(s.expectedErrors) > 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
claims, _ := security.ParseUnverifiedJWT(form.Token)
|
||||
tokenRecordId := claims["id"]
|
||||
|
||||
if record.Id != tokenRecordId {
|
||||
t.Errorf("(%d) Expected record with id %s, got %v", i, tokenRecordId, record)
|
||||
}
|
||||
|
||||
if !record.LastResetSentAt().IsZero() {
|
||||
t.Errorf("(%d) Expected record.LastResetSentAt to be empty, got %v", i, record.LastResetSentAt())
|
||||
}
|
||||
|
||||
if !record.ValidatePassword(form.Password) {
|
||||
t.Errorf("(%d) Expected the record password to have been updated to %q", i, form.Password)
|
||||
}
|
||||
}
|
||||
}
|
86
forms/record_password_reset_request.go
Normal file
86
forms/record_password_reset_request.go
Normal file
@ -0,0 +1,86 @@
|
||||
package forms
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
"github.com/go-ozzo/ozzo-validation/v4/is"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/daos"
|
||||
"github.com/pocketbase/pocketbase/mails"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/models/schema"
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
)
|
||||
|
||||
// RecordPasswordResetRequest is an auth record reset password request form.
|
||||
type RecordPasswordResetRequest struct {
|
||||
app core.App
|
||||
dao *daos.Dao
|
||||
collection *models.Collection
|
||||
resendThreshold float64 // in seconds
|
||||
|
||||
Email string `form:"email" json:"email"`
|
||||
}
|
||||
|
||||
// NewRecordPasswordResetRequest creates a new [RecordPasswordResetRequest]
|
||||
// form initialized with from the provided [core.App] instance.
|
||||
//
|
||||
// If you want to submit the form as part of a transaction,
|
||||
// you can change the default Dao via [SetDao()].
|
||||
func NewRecordPasswordResetRequest(app core.App, collection *models.Collection) *RecordPasswordResetRequest {
|
||||
return &RecordPasswordResetRequest{
|
||||
app: app,
|
||||
dao: app.Dao(),
|
||||
collection: collection,
|
||||
resendThreshold: 120, // 2 min
|
||||
}
|
||||
}
|
||||
|
||||
// SetDao replaces the default form Dao instance with the provided one.
|
||||
func (form *RecordPasswordResetRequest) SetDao(dao *daos.Dao) {
|
||||
form.dao = dao
|
||||
}
|
||||
|
||||
// Validate makes the form validatable by implementing [validation.Validatable] interface.
|
||||
//
|
||||
// This method doesn't checks whether auth record with `form.Email` exists (this is done on Submit).
|
||||
func (form *RecordPasswordResetRequest) Validate() error {
|
||||
return validation.ValidateStruct(form,
|
||||
validation.Field(
|
||||
&form.Email,
|
||||
validation.Required,
|
||||
validation.Length(1, 255),
|
||||
is.EmailFormat,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// Submit validates and submits the form.
|
||||
// On success, sends a password reset email to the `form.Email` auth record.
|
||||
func (form *RecordPasswordResetRequest) Submit() error {
|
||||
if err := form.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
authRecord, err := form.dao.FindFirstRecordByData(form.collection.Id, schema.FieldNameEmail, form.Email)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
lastResetSentAt := authRecord.LastResetSentAt().Time()
|
||||
if now.Sub(lastResetSentAt).Seconds() < form.resendThreshold {
|
||||
return errors.New("You've already requested a password reset.")
|
||||
}
|
||||
|
||||
if err := mails.SendRecordPasswordReset(form.app, authRecord); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// update last sent timestamp
|
||||
authRecord.Set(schema.FieldNameLastResetSentAt, types.NowDateTime())
|
||||
|
||||
return form.dao.SaveRecord(authRecord)
|
||||
}
|
@ -5,86 +5,20 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
"github.com/pocketbase/pocketbase/forms"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
)
|
||||
|
||||
func TestUserPasswordResetRequestPanic(t *testing.T) {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("The form did not panic")
|
||||
}
|
||||
}()
|
||||
|
||||
forms.NewUserPasswordResetRequest(nil)
|
||||
}
|
||||
|
||||
func TestUserPasswordResetRequestValidate(t *testing.T) {
|
||||
func TestRecordPasswordResetRequestSubmit(t *testing.T) {
|
||||
testApp, _ := tests.NewTestApp()
|
||||
defer testApp.Cleanup()
|
||||
|
||||
scenarios := []struct {
|
||||
jsonData string
|
||||
expectedErrors []string
|
||||
}{
|
||||
// empty data
|
||||
{
|
||||
`{}`,
|
||||
[]string{"email"},
|
||||
},
|
||||
// empty fields
|
||||
{
|
||||
`{"email":""}`,
|
||||
[]string{"email"},
|
||||
},
|
||||
// invalid email format
|
||||
{
|
||||
`{"email":"invalid"}`,
|
||||
[]string{"email"},
|
||||
},
|
||||
// valid email
|
||||
{
|
||||
`{"email":"new@example.com"}`,
|
||||
[]string{},
|
||||
},
|
||||
authCollection, err := testApp.Dao().FindCollectionByNameOrId("users")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
for i, s := range scenarios {
|
||||
form := forms.NewUserPasswordResetRequest(testApp)
|
||||
|
||||
// load data
|
||||
loadErr := json.Unmarshal([]byte(s.jsonData), form)
|
||||
if loadErr != nil {
|
||||
t.Errorf("(%d) Failed to load form data: %v", i, loadErr)
|
||||
continue
|
||||
}
|
||||
|
||||
// parse errors
|
||||
result := form.Validate()
|
||||
errs, ok := result.(validation.Errors)
|
||||
if !ok && result != nil {
|
||||
t.Errorf("(%d) Failed to parse errors %v", i, result)
|
||||
continue
|
||||
}
|
||||
|
||||
// check errors
|
||||
if len(errs) > len(s.expectedErrors) {
|
||||
t.Errorf("(%d) Expected error keys %v, got %v", i, s.expectedErrors, errs)
|
||||
}
|
||||
for _, k := range s.expectedErrors {
|
||||
if _, ok := errs[k]; !ok {
|
||||
t.Errorf("(%d) Missing expected error key %q in %v", i, k, errs)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserPasswordResetRequestSubmit(t *testing.T) {
|
||||
testApp, _ := tests.NewTestApp()
|
||||
defer testApp.Cleanup()
|
||||
|
||||
scenarios := []struct {
|
||||
jsonData string
|
||||
expectError bool
|
||||
@ -121,7 +55,7 @@ func TestUserPasswordResetRequestSubmit(t *testing.T) {
|
||||
|
||||
for i, s := range scenarios {
|
||||
testApp.TestMailer.TotalSend = 0 // reset
|
||||
form := forms.NewUserPasswordResetRequest(testApp)
|
||||
form := forms.NewRecordPasswordResetRequest(testApp, authCollection)
|
||||
|
||||
// load data
|
||||
loadErr := json.Unmarshal([]byte(s.jsonData), form)
|
||||
@ -150,14 +84,14 @@ func TestUserPasswordResetRequestSubmit(t *testing.T) {
|
||||
}
|
||||
|
||||
// check whether LastResetSentAt was updated
|
||||
user, err := testApp.Dao().FindUserByEmail(form.Email)
|
||||
user, err := testApp.Dao().FindAuthRecordByEmail(authCollection.Id, form.Email)
|
||||
if err != nil {
|
||||
t.Errorf("(%d) Expected user with email %q to exist, got nil", i, form.Email)
|
||||
continue
|
||||
}
|
||||
|
||||
if user.LastResetSentAt.Time().Sub(now.Time()) < 0 {
|
||||
t.Errorf("(%d) Expected LastResetSentAt to be after %v, got %v", i, now, user.LastResetSentAt)
|
||||
if user.LastResetSentAt().Time().Sub(now.Time()) < 0 {
|
||||
t.Errorf("(%d) Expected LastResetSentAt to be after %v, got %v", i, now, user.LastResetSentAt())
|
||||
}
|
||||
}
|
||||
}
|
@ -8,8 +8,10 @@ import (
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
"github.com/go-ozzo/ozzo-validation/v4/is"
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/daos"
|
||||
@ -18,70 +20,88 @@ import (
|
||||
"github.com/pocketbase/pocketbase/models/schema"
|
||||
"github.com/pocketbase/pocketbase/tools/list"
|
||||
"github.com/pocketbase/pocketbase/tools/rest"
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
"github.com/spf13/cast"
|
||||
)
|
||||
|
||||
// RecordUpsert specifies a [models.Record] upsert (create/update) form.
|
||||
// username value regex pattern
|
||||
var usernameRegex = regexp.MustCompile(`^[\w][\w\.]*$`)
|
||||
|
||||
// RecordUpsert is a [models.Record] upsert (create/update) form.
|
||||
type RecordUpsert struct {
|
||||
config RecordUpsertConfig
|
||||
record *models.Record
|
||||
app core.App
|
||||
dao *daos.Dao
|
||||
manageAccess bool
|
||||
record *models.Record
|
||||
|
||||
filesToDelete []string // names list
|
||||
filesToUpload []*rest.UploadedFile
|
||||
filesToUpload map[string][]*rest.UploadedFile
|
||||
|
||||
// base model fields
|
||||
Id string `json:"id"`
|
||||
|
||||
// auth collection fields
|
||||
// ---
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
EmailVisibility bool `json:"emailVisibility"`
|
||||
Verified bool `json:"verified"`
|
||||
Password string `json:"password"`
|
||||
PasswordConfirm string `json:"passwordConfirm"`
|
||||
OldPassword string `json:"oldPassword"`
|
||||
// ---
|
||||
|
||||
Id string `form:"id" json:"id"`
|
||||
Data map[string]any `json:"data"`
|
||||
}
|
||||
|
||||
// RecordUpsertConfig is the [RecordUpsert] factory initializer config.
|
||||
//
|
||||
// NB! App is required struct member.
|
||||
type RecordUpsertConfig struct {
|
||||
App core.App
|
||||
Dao *daos.Dao
|
||||
}
|
||||
|
||||
// NewRecordUpsert creates a new [RecordUpsert] form with initializer
|
||||
// config created from the provided [core.App] and [models.Record] instances
|
||||
// (for create you could pass a pointer to an empty Record - `models.NewRecord(collection)`).
|
||||
// (for create you could pass a pointer to an empty Record - models.NewRecord(collection)).
|
||||
//
|
||||
// If you want to submit the form as part of another transaction, use
|
||||
// [NewRecordUpsertWithConfig] with explicitly set Dao.
|
||||
// If you want to submit the form as part of a transaction,
|
||||
// you can change the default Dao via [SetDao()].
|
||||
func NewRecordUpsert(app core.App, record *models.Record) *RecordUpsert {
|
||||
return NewRecordUpsertWithConfig(RecordUpsertConfig{
|
||||
App: app,
|
||||
}, record)
|
||||
}
|
||||
|
||||
// NewRecordUpsertWithConfig creates a new [RecordUpsert] form
|
||||
// with the provided config and [models.Record] instance or panics on invalid configuration
|
||||
// (for create you could pass a pointer to an empty Record - `models.NewRecord(collection)`).
|
||||
func NewRecordUpsertWithConfig(config RecordUpsertConfig, record *models.Record) *RecordUpsert {
|
||||
form := &RecordUpsert{
|
||||
config: config,
|
||||
app: app,
|
||||
dao: app.Dao(),
|
||||
record: record,
|
||||
filesToDelete: []string{},
|
||||
filesToUpload: []*rest.UploadedFile{},
|
||||
filesToUpload: map[string][]*rest.UploadedFile{},
|
||||
}
|
||||
|
||||
if form.config.App == nil || form.record == nil {
|
||||
panic("Invalid initializer config or nil upsert model.")
|
||||
}
|
||||
|
||||
if form.config.Dao == nil {
|
||||
form.config.Dao = form.config.App.Dao()
|
||||
}
|
||||
|
||||
form.Id = record.Id
|
||||
|
||||
form.Data = map[string]any{}
|
||||
for _, field := range record.Collection().Schema.Fields() {
|
||||
form.Data[field.Name] = record.GetDataValue(field.Name)
|
||||
}
|
||||
form.loadFormDefaults()
|
||||
|
||||
return form
|
||||
}
|
||||
|
||||
// SetFullManageAccess sets the manageAccess bool flag of the current
|
||||
// form to enable/disable directly changing some system record fields
|
||||
// (often used with auth collection records).
|
||||
func (form *RecordUpsert) SetFullManageAccess(fullManageAccess bool) {
|
||||
form.manageAccess = fullManageAccess
|
||||
}
|
||||
|
||||
// SetDao replaces the default form Dao instance with the provided one.
|
||||
func (form *RecordUpsert) SetDao(dao *daos.Dao) {
|
||||
form.dao = dao
|
||||
}
|
||||
|
||||
func (form *RecordUpsert) loadFormDefaults() {
|
||||
form.Id = form.record.Id
|
||||
|
||||
if form.record.Collection().IsAuth() {
|
||||
form.Username = form.record.Username()
|
||||
form.Email = form.record.Email()
|
||||
form.EmailVisibility = form.record.EmailVisibility()
|
||||
form.Verified = form.record.Verified()
|
||||
}
|
||||
|
||||
form.Data = map[string]any{}
|
||||
for _, field := range form.record.Collection().Schema.Fields() {
|
||||
form.Data[field.Name] = form.record.Get(field.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func (form *RecordUpsert) getContentType(r *http.Request) string {
|
||||
t := r.Header.Get("Content-Type")
|
||||
for i, c := range t {
|
||||
@ -92,26 +112,38 @@ func (form *RecordUpsert) getContentType(r *http.Request) string {
|
||||
return t
|
||||
}
|
||||
|
||||
func (form *RecordUpsert) extractRequestData(r *http.Request) (map[string]any, error) {
|
||||
func (form *RecordUpsert) extractRequestData(r *http.Request, keyPrefix string) (map[string]any, error) {
|
||||
switch form.getContentType(r) {
|
||||
case "application/json":
|
||||
return form.extractJsonData(r)
|
||||
return form.extractJsonData(r, keyPrefix)
|
||||
case "multipart/form-data":
|
||||
return form.extractMultipartFormData(r)
|
||||
return form.extractMultipartFormData(r, keyPrefix)
|
||||
default:
|
||||
return nil, errors.New("Unsupported request Content-Type.")
|
||||
}
|
||||
}
|
||||
|
||||
func (form *RecordUpsert) extractJsonData(r *http.Request) (map[string]any, error) {
|
||||
func (form *RecordUpsert) extractJsonData(r *http.Request, keyPrefix string) (map[string]any, error) {
|
||||
result := map[string]any{}
|
||||
|
||||
err := rest.ReadJsonBodyCopy(r, &result)
|
||||
err := rest.CopyJsonBody(r, &result)
|
||||
|
||||
if keyPrefix != "" {
|
||||
parts := strings.Split(keyPrefix, ".")
|
||||
for _, part := range parts {
|
||||
if result[part] == nil {
|
||||
break
|
||||
}
|
||||
if v, ok := result[part].(map[string]any); ok {
|
||||
result = v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (form *RecordUpsert) extractMultipartFormData(r *http.Request) (map[string]any, error) {
|
||||
func (form *RecordUpsert) extractMultipartFormData(r *http.Request, keyPrefix string) (map[string]any, error) {
|
||||
result := map[string]any{}
|
||||
|
||||
// parse form data (if not already)
|
||||
@ -121,7 +153,14 @@ func (form *RecordUpsert) extractMultipartFormData(r *http.Request) (map[string]
|
||||
|
||||
arrayValueSupportTypes := schema.ArraybleFieldTypes()
|
||||
|
||||
for key, values := range r.PostForm {
|
||||
form.filesToUpload = map[string][]*rest.UploadedFile{}
|
||||
|
||||
for fullKey, values := range r.PostForm {
|
||||
key := fullKey
|
||||
if keyPrefix != "" {
|
||||
key = strings.TrimPrefix(key, keyPrefix+".")
|
||||
}
|
||||
|
||||
if len(values) == 0 {
|
||||
result[key] = nil
|
||||
continue
|
||||
@ -135,6 +174,44 @@ func (form *RecordUpsert) extractMultipartFormData(r *http.Request) (map[string]
|
||||
}
|
||||
}
|
||||
|
||||
// load uploaded files (if any)
|
||||
for _, field := range form.record.Collection().Schema.Fields() {
|
||||
if field.Type != schema.FieldTypeFile {
|
||||
continue // not a file field
|
||||
}
|
||||
|
||||
key := field.Name
|
||||
fullKey := key
|
||||
if keyPrefix != "" {
|
||||
fullKey = keyPrefix + "." + key
|
||||
}
|
||||
|
||||
files, err := rest.FindUploadedFiles(r, fullKey)
|
||||
if err != nil || len(files) == 0 {
|
||||
if err != nil && err != http.ErrMissingFile && form.app.IsDebug() {
|
||||
log.Printf("%q uploaded file error: %v\n", fullKey, err)
|
||||
}
|
||||
|
||||
// skip invalid or missing file(s)
|
||||
continue
|
||||
}
|
||||
|
||||
options, ok := field.Options.(*schema.FileOptions)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
if form.filesToUpload[key] == nil {
|
||||
form.filesToUpload[key] = []*rest.UploadedFile{}
|
||||
}
|
||||
|
||||
if options.MaxSelect == 1 {
|
||||
form.filesToUpload[key] = append(form.filesToUpload[key], files[0])
|
||||
} else if options.MaxSelect > 1 {
|
||||
form.filesToUpload[key] = append(form.filesToUpload[key], files...)
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
@ -144,35 +221,66 @@ func (form *RecordUpsert) normalizeData() error {
|
||||
form.Data[field.Name] = field.PrepareValue(v)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoadData loads and normalizes json OR multipart/form-data request data.
|
||||
// LoadRequest extracts the json or multipart/form-data request data
|
||||
// and lods it into the form.
|
||||
//
|
||||
// File upload is supported only via multipart/form-data.
|
||||
//
|
||||
// To REPLACE previously uploaded file(s) you can suffix the field name
|
||||
// with the file index (eg. `myfile.0`) and set the new value.
|
||||
// For single file upload fields, you can skip the index and directly
|
||||
// assign the file value to the field name (eg. `myfile`).
|
||||
//
|
||||
// To DELETE previously uploaded file(s) you can suffix the field name
|
||||
// with the file index (eg. `myfile.0`) and set it to null or empty string.
|
||||
// with the file index or filename (eg. `myfile.0`) and set it to null or empty string.
|
||||
// For single file upload fields, you can skip the index and directly
|
||||
// reset the field using its field name (eg. `myfile`).
|
||||
func (form *RecordUpsert) LoadData(r *http.Request) error {
|
||||
requestData, err := form.extractRequestData(r)
|
||||
// reset the field using its field name (eg. `myfile = null`).
|
||||
func (form *RecordUpsert) LoadRequest(r *http.Request, keyPrefix string) error {
|
||||
requestData, err := form.extractRequestData(r, keyPrefix)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if id, ok := requestData["id"]; ok {
|
||||
form.Id = cast.ToString(id)
|
||||
return form.LoadData(requestData)
|
||||
}
|
||||
|
||||
// LoadData loads and normalizes the provided data into the form.
|
||||
//
|
||||
// To DELETE previously uploaded file(s) you can suffix the field name
|
||||
// with the file index or filename (eg. `myfile.0`) and set it to null or empty string.
|
||||
// For single file upload fields, you can skip the index and directly
|
||||
// reset the field using its field name (eg. `myfile = null`).
|
||||
func (form *RecordUpsert) LoadData(requestData map[string]any) error {
|
||||
// load base system fields
|
||||
if v, ok := requestData["id"]; ok {
|
||||
form.Id = cast.ToString(v)
|
||||
}
|
||||
|
||||
// extend base data with the extracted one
|
||||
extendedData := form.record.Data()
|
||||
// load auth system fields
|
||||
if form.record.Collection().IsAuth() {
|
||||
if v, ok := requestData["username"]; ok {
|
||||
form.Username = cast.ToString(v)
|
||||
}
|
||||
if v, ok := requestData["email"]; ok {
|
||||
form.Email = cast.ToString(v)
|
||||
}
|
||||
if v, ok := requestData["emailVisibility"]; ok {
|
||||
form.EmailVisibility = cast.ToBool(v)
|
||||
}
|
||||
if v, ok := requestData["verified"]; ok {
|
||||
form.Verified = cast.ToBool(v)
|
||||
}
|
||||
if v, ok := requestData["password"]; ok {
|
||||
form.Password = cast.ToString(v)
|
||||
}
|
||||
if v, ok := requestData["passwordConfirm"]; ok {
|
||||
form.PasswordConfirm = cast.ToString(v)
|
||||
}
|
||||
if v, ok := requestData["oldPassword"]; ok {
|
||||
form.OldPassword = cast.ToString(v)
|
||||
}
|
||||
}
|
||||
|
||||
// extend the record schema data with the request data
|
||||
extendedData := form.record.SchemaData()
|
||||
rawData, err := json.Marshal(requestData)
|
||||
if err != nil {
|
||||
return err
|
||||
@ -243,17 +351,8 @@ func (form *RecordUpsert) LoadData(r *http.Request) error {
|
||||
// Check for new uploaded file
|
||||
// -----------------------------------------------------------
|
||||
|
||||
if form.getContentType(r) != "multipart/form-data" {
|
||||
continue // file upload is supported only via multipart/form-data
|
||||
}
|
||||
|
||||
files, err := rest.FindUploadedFiles(r, key)
|
||||
if err != nil {
|
||||
if form.config.App.IsDebug() {
|
||||
log.Printf("%q uploaded file error: %v\n", key, err)
|
||||
}
|
||||
|
||||
continue // skip invalid or missing file(s)
|
||||
if len(form.filesToUpload[key]) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// refresh oldNames list
|
||||
@ -264,12 +363,10 @@ func (form *RecordUpsert) LoadData(r *http.Request) error {
|
||||
if len(oldNames) > 0 {
|
||||
form.filesToDelete = list.ToUniqueStringSlice(append(form.filesToDelete, oldNames...))
|
||||
}
|
||||
form.filesToUpload = append(form.filesToUpload, files[0])
|
||||
form.Data[key] = files[0].Name()
|
||||
form.Data[key] = form.filesToUpload[key][0].Name()
|
||||
} else if options.MaxSelect > 1 {
|
||||
// append the id of each uploaded file instance
|
||||
form.filesToUpload = append(form.filesToUpload, files...)
|
||||
for _, file := range files {
|
||||
for _, file := range form.filesToUpload[key] {
|
||||
oldNames = append(oldNames, file.Name())
|
||||
}
|
||||
form.Data[key] = oldNames
|
||||
@ -282,7 +379,7 @@ func (form *RecordUpsert) LoadData(r *http.Request) error {
|
||||
// Validate makes the form validatable by implementing [validation.Validatable] interface.
|
||||
func (form *RecordUpsert) Validate() error {
|
||||
// base form fields validator
|
||||
baseFieldsErrors := validation.ValidateStruct(form,
|
||||
baseFieldsRules := []*validation.FieldRules{
|
||||
validation.Field(
|
||||
&form.Id,
|
||||
validation.When(
|
||||
@ -291,26 +388,159 @@ func (form *RecordUpsert) Validate() error {
|
||||
validation.Match(idRegex),
|
||||
).Else(validation.In(form.record.Id)),
|
||||
),
|
||||
)
|
||||
if baseFieldsErrors != nil {
|
||||
return baseFieldsErrors
|
||||
}
|
||||
|
||||
// auth fields validators
|
||||
if form.record.Collection().IsAuth() {
|
||||
baseFieldsRules = append(baseFieldsRules,
|
||||
validation.Field(
|
||||
&form.Username,
|
||||
// require only on update, because on create we fallback to auto generated username
|
||||
validation.When(!form.record.IsNew(), validation.Required),
|
||||
validation.Length(4, 100),
|
||||
validation.Match(usernameRegex),
|
||||
validation.By(form.checkUniqueUsername),
|
||||
),
|
||||
validation.Field(
|
||||
&form.Email,
|
||||
validation.When(
|
||||
form.record.Collection().AuthOptions().RequireEmail,
|
||||
validation.Required,
|
||||
),
|
||||
// don't allow direct email change (or unset) if the form doesn't have manage access permissions
|
||||
// (aka. allow only admin or authorized auth models to directly update the field)
|
||||
validation.When(
|
||||
!form.record.IsNew() && !form.manageAccess,
|
||||
validation.In(form.record.Email()),
|
||||
),
|
||||
validation.Length(1, 255),
|
||||
is.EmailFormat,
|
||||
validation.By(form.checkEmailDomain),
|
||||
validation.By(form.checkUniqueEmail),
|
||||
),
|
||||
validation.Field(
|
||||
&form.Verified,
|
||||
// don't allow changing verified if the form doesn't have manage access permissions
|
||||
// (aka. allow only admin or authorized auth models to directly change the field)
|
||||
validation.When(
|
||||
!form.manageAccess,
|
||||
validation.In(form.record.Verified()),
|
||||
),
|
||||
),
|
||||
validation.Field(
|
||||
&form.Password,
|
||||
validation.When(form.record.IsNew(), validation.Required),
|
||||
validation.Length(form.record.Collection().AuthOptions().MinPasswordLength, 72),
|
||||
),
|
||||
validation.Field(
|
||||
&form.PasswordConfirm,
|
||||
validation.When(
|
||||
(form.record.IsNew() || form.Password != ""),
|
||||
validation.Required,
|
||||
),
|
||||
validation.By(validators.Compare(form.Password)),
|
||||
),
|
||||
validation.Field(
|
||||
&form.OldPassword,
|
||||
// require old password only on update when:
|
||||
// - form.manageAccess is not set
|
||||
// - changing the existing password
|
||||
validation.When(
|
||||
!form.record.IsNew() && !form.manageAccess && form.Password != "",
|
||||
validation.Required,
|
||||
validation.By(form.checkOldPassword),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
if err := validation.ValidateStruct(form, baseFieldsRules...); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// record data validator
|
||||
dataValidator := validators.NewRecordDataValidator(
|
||||
form.config.Dao,
|
||||
return validators.NewRecordDataValidator(
|
||||
form.dao,
|
||||
form.record,
|
||||
form.filesToUpload,
|
||||
)
|
||||
|
||||
return dataValidator.Validate(form.Data)
|
||||
).Validate(form.Data)
|
||||
}
|
||||
|
||||
// DrySubmit performs a form submit within a transaction and reverts it.
|
||||
// For actual record persistence, check the `form.Submit()` method.
|
||||
//
|
||||
// This method doesn't handle file uploads/deletes or trigger any app events!
|
||||
func (form *RecordUpsert) DrySubmit(callback func(txDao *daos.Dao) error) error {
|
||||
func (form *RecordUpsert) checkUniqueUsername(value any) error {
|
||||
v, _ := value.(string)
|
||||
if v == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
isUnique := form.dao.IsRecordValueUnique(
|
||||
form.record.Collection().Id,
|
||||
schema.FieldNameUsername,
|
||||
v,
|
||||
form.record.Id,
|
||||
)
|
||||
if !isUnique {
|
||||
return validation.NewError("validation_invalid_username", "The username is invalid or already in use.")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (form *RecordUpsert) checkUniqueEmail(value any) error {
|
||||
v, _ := value.(string)
|
||||
if v == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
isUnique := form.dao.IsRecordValueUnique(
|
||||
form.record.Collection().Id,
|
||||
schema.FieldNameEmail,
|
||||
v,
|
||||
form.record.Id,
|
||||
)
|
||||
if !isUnique {
|
||||
return validation.NewError("validation_invalid_email", "The email is invalid or already in use.")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (form *RecordUpsert) checkEmailDomain(value any) error {
|
||||
val, _ := value.(string)
|
||||
if val == "" {
|
||||
return nil // nothing to check
|
||||
}
|
||||
|
||||
domain := val[strings.LastIndex(val, "@")+1:]
|
||||
only := form.record.Collection().AuthOptions().OnlyEmailDomains
|
||||
except := form.record.Collection().AuthOptions().ExceptEmailDomains
|
||||
|
||||
// only domains check
|
||||
if len(only) > 0 && !list.ExistInSlice(domain, only) {
|
||||
return validation.NewError("validation_email_domain_not_allowed", "Email domain is not allowed.")
|
||||
}
|
||||
|
||||
// except domains check
|
||||
if len(except) > 0 && list.ExistInSlice(domain, except) {
|
||||
return validation.NewError("validation_email_domain_not_allowed", "Email domain is not allowed.")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (form *RecordUpsert) checkOldPassword(value any) error {
|
||||
v, _ := value.(string)
|
||||
if v == "" {
|
||||
return nil // nothing to check
|
||||
}
|
||||
|
||||
if !form.record.ValidatePassword(v) {
|
||||
return validation.NewError("validation_invalid_old_password", "Missing or invalid old password.")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (form *RecordUpsert) ValidateAndFill() error {
|
||||
if err := form.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
@ -319,16 +549,67 @@ func (form *RecordUpsert) DrySubmit(callback func(txDao *daos.Dao) error) error
|
||||
|
||||
// custom insertion id can be set only on create
|
||||
if isNew && form.Id != "" {
|
||||
form.record.MarkAsNew()
|
||||
form.record.SetId(form.Id)
|
||||
form.record.MarkAsNew()
|
||||
}
|
||||
|
||||
// bulk load form data
|
||||
if err := form.record.Load(form.Data); err != nil {
|
||||
// set auth fields
|
||||
if form.record.Collection().IsAuth() {
|
||||
// generate a default username during create (if missing)
|
||||
if form.record.IsNew() && form.Username == "" {
|
||||
baseUsername := form.record.Collection().Name + security.RandomStringWithAlphabet(5, "123456789")
|
||||
form.Username = form.dao.SuggestUniqueAuthRecordUsername(form.record.Collection().Id, baseUsername)
|
||||
}
|
||||
|
||||
if form.Username != "" {
|
||||
if err := form.record.SetUsername(form.Username); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if isNew || form.manageAccess {
|
||||
if err := form.record.SetEmail(form.Email); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := form.record.SetEmailVisibility(form.EmailVisibility); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if form.manageAccess {
|
||||
if err := form.record.SetVerified(form.Verified); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if form.Password != "" {
|
||||
if err := form.record.SetPassword(form.Password); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// bulk load the remaining form data
|
||||
form.record.Load(form.Data)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DrySubmit performs a form submit within a transaction and reverts it.
|
||||
// For actual record persistence, check the `form.Submit()` method.
|
||||
//
|
||||
// This method doesn't handle file uploads/deletes or trigger any app events!
|
||||
func (form *RecordUpsert) DrySubmit(callback func(txDao *daos.Dao) error) error {
|
||||
isNew := form.record.IsNew()
|
||||
|
||||
if err := form.ValidateAndFill(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return form.config.Dao.RunInTransaction(func(txDao *daos.Dao) error {
|
||||
// use the default app.Dao to prevent changing the transaction form.Dao
|
||||
// and causing "transaction has already been committed or rolled back" error
|
||||
return form.app.Dao().RunInTransaction(func(txDao *daos.Dao) error {
|
||||
tx, ok := txDao.DB().(*dbx.Tx)
|
||||
if !ok {
|
||||
return errors.New("failed to get transaction db")
|
||||
@ -362,31 +643,20 @@ func (form *RecordUpsert) DrySubmit(callback func(txDao *daos.Dao) error) error
|
||||
// You can optionally provide a list of InterceptorFunc to further
|
||||
// modify the form behavior before persisting it.
|
||||
func (form *RecordUpsert) Submit(interceptors ...InterceptorFunc) error {
|
||||
if err := form.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// custom insertion id can be set only on create
|
||||
if form.record.IsNew() && form.Id != "" {
|
||||
form.record.MarkAsNew()
|
||||
form.record.SetId(form.Id)
|
||||
}
|
||||
|
||||
// bulk load form data
|
||||
if err := form.record.Load(form.Data); err != nil {
|
||||
if err := form.ValidateAndFill(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return runInterceptors(func() error {
|
||||
return form.config.Dao.RunInTransaction(func(txDao *daos.Dao) error {
|
||||
return form.dao.RunInTransaction(func(txDao *daos.Dao) error {
|
||||
// persist record model
|
||||
if err := txDao.SaveRecord(form.record); err != nil {
|
||||
return err
|
||||
return fmt.Errorf("Failed to save the record: %v", err)
|
||||
}
|
||||
|
||||
// upload new files (if any)
|
||||
if err := form.processFilesToUpload(); err != nil {
|
||||
return err
|
||||
return fmt.Errorf("Failed to process the upload files: %v", err)
|
||||
}
|
||||
|
||||
// delete old files (if any)
|
||||
@ -402,30 +672,33 @@ func (form *RecordUpsert) Submit(interceptors ...InterceptorFunc) error {
|
||||
|
||||
func (form *RecordUpsert) processFilesToUpload() error {
|
||||
if len(form.filesToUpload) == 0 {
|
||||
return nil // nothing to upload
|
||||
return nil // no parsed file fields
|
||||
}
|
||||
|
||||
if !form.record.HasId() {
|
||||
return errors.New("The record is not persisted yet.")
|
||||
}
|
||||
|
||||
fs, err := form.config.App.NewFilesystem()
|
||||
fs, err := form.app.NewFilesystem()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer fs.Close()
|
||||
|
||||
var uploadErrors []error
|
||||
for i := len(form.filesToUpload) - 1; i >= 0; i-- {
|
||||
file := form.filesToUpload[i]
|
||||
path := form.record.BaseFilesPath() + "/" + file.Name()
|
||||
|
||||
if err := fs.Upload(file.Bytes(), path); err == nil {
|
||||
// remove the uploaded file from the list
|
||||
form.filesToUpload = append(form.filesToUpload[:i], form.filesToUpload[i+1:]...)
|
||||
} else {
|
||||
// store the upload error
|
||||
uploadErrors = append(uploadErrors, fmt.Errorf("File %d: %v", i, err))
|
||||
for fieldKey := range form.filesToUpload {
|
||||
for i := len(form.filesToUpload[fieldKey]) - 1; i >= 0; i-- {
|
||||
file := form.filesToUpload[fieldKey][i]
|
||||
path := form.record.BaseFilesPath() + "/" + file.Name()
|
||||
|
||||
if err := fs.UploadMultipart(file.Header(), path); err == nil {
|
||||
// remove the uploaded file from the list
|
||||
form.filesToUpload[fieldKey] = append(form.filesToUpload[fieldKey][:i], form.filesToUpload[fieldKey][i+1:]...)
|
||||
} else {
|
||||
// store the upload error
|
||||
uploadErrors = append(uploadErrors, fmt.Errorf("File %d: %v", i, err))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -445,7 +718,7 @@ func (form *RecordUpsert) processFilesToDelete() error {
|
||||
return errors.New("The record is not persisted yet.")
|
||||
}
|
||||
|
||||
fs, err := form.config.App.NewFilesystem()
|
||||
fs, err := form.app.NewFilesystem()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
@ -10,7 +10,6 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
"github.com/labstack/echo/v5"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/daos"
|
||||
@ -20,36 +19,28 @@ import (
|
||||
"github.com/pocketbase/pocketbase/tools/list"
|
||||
)
|
||||
|
||||
func TestRecordUpsertPanic1(t *testing.T) {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("The form did not panic")
|
||||
}
|
||||
}()
|
||||
func hasRecordFile(app core.App, record *models.Record, filename string) bool {
|
||||
fs, _ := app.NewFilesystem()
|
||||
defer fs.Close()
|
||||
|
||||
forms.NewRecordUpsert(nil, nil)
|
||||
}
|
||||
fileKey := filepath.Join(
|
||||
record.Collection().Id,
|
||||
record.Id,
|
||||
filename,
|
||||
)
|
||||
|
||||
func TestRecordUpsertPanic2(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
exists, _ := fs.Exists(fileKey)
|
||||
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("The form did not panic")
|
||||
}
|
||||
}()
|
||||
|
||||
forms.NewRecordUpsert(app, nil)
|
||||
return exists
|
||||
}
|
||||
|
||||
func TestNewRecordUpsert(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
collection, _ := app.Dao().FindCollectionByNameOrId("demo")
|
||||
collection, _ := app.Dao().FindCollectionByNameOrId("demo2")
|
||||
record := models.NewRecord(collection)
|
||||
record.SetDataValue("title", "test_value")
|
||||
record.Set("title", "test_value")
|
||||
|
||||
form := forms.NewRecordUpsert(app, record)
|
||||
|
||||
@ -59,12 +50,11 @@ func TestNewRecordUpsert(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordUpsertLoadDataUnsupported(t *testing.T) {
|
||||
func TestRecordUpsertLoadRequestUnsupported(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
collection, _ := app.Dao().FindCollectionByNameOrId("demo4")
|
||||
record, err := app.Dao().FindFirstRecordByData(collection, "id", "054f9f24-0a0a-4e09-87b1-bc7ff2b336a2")
|
||||
record, err := app.Dao().FindRecordById("demo2", "0yxhwia2amd8gec")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@ -75,37 +65,40 @@ func TestRecordUpsertLoadDataUnsupported(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/", strings.NewReader(testData))
|
||||
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationForm)
|
||||
|
||||
if err := form.LoadData(req); err == nil {
|
||||
t.Fatal("Expected LoadData to fail, got nil")
|
||||
if err := form.LoadRequest(req, ""); err == nil {
|
||||
t.Fatal("Expected LoadRequest to fail, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordUpsertLoadDataJson(t *testing.T) {
|
||||
func TestRecordUpsertLoadRequestJson(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
collection, _ := app.Dao().FindCollectionByNameOrId("demo4")
|
||||
record, err := app.Dao().FindFirstRecordByData(collection, "id", "054f9f24-0a0a-4e09-87b1-bc7ff2b336a2")
|
||||
record, err := app.Dao().FindRecordById("demo1", "84nmscqy84lsi1t")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
testData := map[string]any{
|
||||
"id": "test_id",
|
||||
"title": "test123",
|
||||
"unknown": "test456",
|
||||
// file fields unset/delete
|
||||
"onefile": nil,
|
||||
"manyfiles.0": "",
|
||||
"manyfiles.1": "test.png", // should be ignored
|
||||
"onlyimages": nil,
|
||||
"a": map[string]any{
|
||||
"b": map[string]any{
|
||||
"id": "test_id",
|
||||
"text": "test123",
|
||||
"unknown": "test456",
|
||||
// file fields unset/delete
|
||||
"file_one": nil,
|
||||
"file_many.0": "", // delete by index
|
||||
"file_many.1": "test.png", // should be ignored
|
||||
"file_many.300_WlbFWSGmW9.png": nil, // delete by filename
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
form := forms.NewRecordUpsert(app, record)
|
||||
jsonBody, _ := json.Marshal(testData)
|
||||
req := httptest.NewRequest(http.MethodGet, "/", bytes.NewReader(jsonBody))
|
||||
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
|
||||
loadErr := form.LoadData(req)
|
||||
loadErr := form.LoadRequest(req, "a.b")
|
||||
if loadErr != nil {
|
||||
t.Fatal(loadErr)
|
||||
}
|
||||
@ -114,7 +107,7 @@ func TestRecordUpsertLoadDataJson(t *testing.T) {
|
||||
t.Fatalf("Expect id field to be %q, got %q", "test_id", form.Id)
|
||||
}
|
||||
|
||||
if v, ok := form.Data["title"]; !ok || v != "test123" {
|
||||
if v, ok := form.Data["text"]; !ok || v != "test123" {
|
||||
t.Fatalf("Expect title field to be %q, got %q", "test123", v)
|
||||
}
|
||||
|
||||
@ -122,50 +115,43 @@ func TestRecordUpsertLoadDataJson(t *testing.T) {
|
||||
t.Fatalf("Didn't expect unknown field to be set, got %v", v)
|
||||
}
|
||||
|
||||
onefile, ok := form.Data["onefile"]
|
||||
fileOne, ok := form.Data["file_one"]
|
||||
if !ok {
|
||||
t.Fatal("Expect onefile field to be set")
|
||||
t.Fatal("Expect file_one field to be set")
|
||||
}
|
||||
if onefile != "" {
|
||||
t.Fatalf("Expect onefile field to be empty string, got %v", onefile)
|
||||
if fileOne != "" {
|
||||
t.Fatalf("Expect file_one field to be empty string, got %v", fileOne)
|
||||
}
|
||||
|
||||
manyfiles, ok := form.Data["manyfiles"]
|
||||
if !ok || manyfiles == nil {
|
||||
t.Fatal("Expect manyfiles field to be set")
|
||||
fileMany, ok := form.Data["file_many"]
|
||||
if !ok || fileMany == nil {
|
||||
t.Fatal("Expect file_many field to be set")
|
||||
}
|
||||
manyfilesRemains := len(list.ToUniqueStringSlice(manyfiles))
|
||||
manyfilesRemains := len(list.ToUniqueStringSlice(fileMany))
|
||||
if manyfilesRemains != 1 {
|
||||
t.Fatalf("Expect only 1 manyfiles to remain, got \n%v", manyfiles)
|
||||
}
|
||||
|
||||
onlyimages := form.Data["onlyimages"]
|
||||
if len(list.ToUniqueStringSlice(onlyimages)) != 0 {
|
||||
t.Fatalf("Expect onlyimages field to be deleted, got \n%v", onlyimages)
|
||||
t.Fatalf("Expect only 1 file_many to remain, got \n%v", fileMany)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordUpsertLoadDataMultipart(t *testing.T) {
|
||||
func TestRecordUpsertLoadRequestMultipart(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
collection, _ := app.Dao().FindCollectionByNameOrId("demo4")
|
||||
record, err := app.Dao().FindFirstRecordByData(collection, "id", "054f9f24-0a0a-4e09-87b1-bc7ff2b336a2")
|
||||
record, err := app.Dao().FindRecordById("demo1", "84nmscqy84lsi1t")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
formData, mp, err := tests.MockMultipartData(map[string]string{
|
||||
"id": "test_id",
|
||||
"title": "test123",
|
||||
"unknown": "test456",
|
||||
"a.b.id": "test_id",
|
||||
"a.b.text": "test123",
|
||||
"a.b.unknown": "test456",
|
||||
// file fields unset/delete
|
||||
"onefile": "",
|
||||
"manyfiles.0": "", // delete by index
|
||||
"manyfiles.b635c395-6837-49e5-8535-b0a6ebfbdbf3.png": "", // delete by name
|
||||
"manyfiles.1": "test.png", // should be ignored
|
||||
"onlyimages": "",
|
||||
}, "onlyimages")
|
||||
"a.b.file_one": "",
|
||||
"a.b.file_many.0": "",
|
||||
"a.b.file_many.300_WlbFWSGmW9.png": "test.png", // delete by name
|
||||
"a.b.file_many.1": "test.png", // should be ignored
|
||||
}, "file_many")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@ -173,7 +159,7 @@ func TestRecordUpsertLoadDataMultipart(t *testing.T) {
|
||||
form := forms.NewRecordUpsert(app, record)
|
||||
req := httptest.NewRequest(http.MethodGet, "/", formData)
|
||||
req.Header.Set(echo.HeaderContentType, mp.FormDataContentType())
|
||||
loadErr := form.LoadData(req)
|
||||
loadErr := form.LoadRequest(req, "a.b")
|
||||
if loadErr != nil {
|
||||
t.Fatal(loadErr)
|
||||
}
|
||||
@ -182,117 +168,58 @@ func TestRecordUpsertLoadDataMultipart(t *testing.T) {
|
||||
t.Fatalf("Expect id field to be %q, got %q", "test_id", form.Id)
|
||||
}
|
||||
|
||||
if v, ok := form.Data["title"]; !ok || v != "test123" {
|
||||
t.Fatalf("Expect title field to be %q, got %q", "test123", v)
|
||||
if v, ok := form.Data["text"]; !ok || v != "test123" {
|
||||
t.Fatalf("Expect text field to be %q, got %q", "test123", v)
|
||||
}
|
||||
|
||||
if v, ok := form.Data["unknown"]; ok {
|
||||
t.Fatalf("Didn't expect unknown field to be set, got %v", v)
|
||||
}
|
||||
|
||||
onefile, ok := form.Data["onefile"]
|
||||
fileOne, ok := form.Data["file_one"]
|
||||
if !ok {
|
||||
t.Fatal("Expect onefile field to be set")
|
||||
t.Fatal("Expect file_one field to be set")
|
||||
}
|
||||
if onefile != "" {
|
||||
t.Fatalf("Expect onefile field to be empty string, got %v", onefile)
|
||||
if fileOne != "" {
|
||||
t.Fatalf("Expect file_one field to be empty string, got %v", fileOne)
|
||||
}
|
||||
|
||||
manyfiles, ok := form.Data["manyfiles"]
|
||||
if !ok || manyfiles == nil {
|
||||
t.Fatal("Expect manyfiles field to be set")
|
||||
fileMany, ok := form.Data["file_many"]
|
||||
if !ok || fileMany == nil {
|
||||
t.Fatal("Expect file_many field to be set")
|
||||
}
|
||||
manyfilesRemains := len(list.ToUniqueStringSlice(manyfiles))
|
||||
if manyfilesRemains != 0 {
|
||||
t.Fatalf("Expect 0 manyfiles to remain, got %v", manyfiles)
|
||||
}
|
||||
|
||||
onlyimages, ok := form.Data["onlyimages"]
|
||||
if !ok || onlyimages == nil {
|
||||
t.Fatal("Expect onlyimages field to be set")
|
||||
}
|
||||
onlyimagesRemains := len(list.ToUniqueStringSlice(onlyimages))
|
||||
expectedRemains := 1 // -2 removed + 1 new upload
|
||||
if onlyimagesRemains != expectedRemains {
|
||||
t.Fatalf("Expect onlyimages to be %d, got %d (%v)", expectedRemains, onlyimagesRemains, onlyimages)
|
||||
manyfilesRemains := len(list.ToUniqueStringSlice(fileMany))
|
||||
expectedRemains := 2 // -2 from 3 removed + 1 new upload
|
||||
if manyfilesRemains != expectedRemains {
|
||||
t.Fatalf("Expect file_many to be %d, got %d (%v)", expectedRemains, manyfilesRemains, fileMany)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordUpsertValidateFailure(t *testing.T) {
|
||||
func TestRecordUpsertLoadData(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
collection, _ := app.Dao().FindCollectionByNameOrId("demo4")
|
||||
record, err := app.Dao().FindFirstRecordByData(collection, "id", "054f9f24-0a0a-4e09-87b1-bc7ff2b336a2")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// try with invalid test data to check whether the RecordDataValidator is triggered
|
||||
formData, mp, err := tests.MockMultipartData(map[string]string{
|
||||
"id": "",
|
||||
"unknown": "test456", // should be ignored
|
||||
"title": "a",
|
||||
"onerel": "00000000-84ab-4057-a592-4604a731f78f",
|
||||
}, "manyfiles", "manyfiles")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
expectedErrors := []string{"title", "onerel", "manyfiles"}
|
||||
|
||||
form := forms.NewRecordUpsert(app, record)
|
||||
req := httptest.NewRequest(http.MethodGet, "/", formData)
|
||||
req.Header.Set(echo.HeaderContentType, mp.FormDataContentType())
|
||||
form.LoadData(req)
|
||||
|
||||
result := form.Validate()
|
||||
|
||||
// parse errors
|
||||
errs, ok := result.(validation.Errors)
|
||||
if !ok && result != nil {
|
||||
t.Fatalf("Failed to parse errors %v", result)
|
||||
}
|
||||
|
||||
// check errors
|
||||
if len(errs) > len(expectedErrors) {
|
||||
t.Fatalf("Expected error keys %v, got %v", expectedErrors, errs)
|
||||
}
|
||||
for _, k := range expectedErrors {
|
||||
if _, ok := errs[k]; !ok {
|
||||
t.Errorf("Missing expected error key %q in %v", k, errs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordUpsertValidateSuccess(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
collection, _ := app.Dao().FindCollectionByNameOrId("demo4")
|
||||
record, err := app.Dao().FindFirstRecordByData(collection, "id", "054f9f24-0a0a-4e09-87b1-bc7ff2b336a2")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
formData, mp, err := tests.MockMultipartData(map[string]string{
|
||||
"id": record.Id,
|
||||
"unknown": "test456", // should be ignored
|
||||
"title": "abc",
|
||||
"onerel": "054f9f24-0a0a-4e09-87b1-bc7ff2b336a2",
|
||||
}, "manyfiles", "onefile")
|
||||
record, err := app.Dao().FindRecordById("demo2", "llvuca81nly1qls")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
form := forms.NewRecordUpsert(app, record)
|
||||
req := httptest.NewRequest(http.MethodGet, "/", formData)
|
||||
req.Header.Set(echo.HeaderContentType, mp.FormDataContentType())
|
||||
form.LoadData(req)
|
||||
|
||||
result := form.Validate()
|
||||
if result != nil {
|
||||
t.Fatal(result)
|
||||
loadErr := form.LoadData(map[string]any{
|
||||
"title": "test_new",
|
||||
"active": true,
|
||||
})
|
||||
if loadErr != nil {
|
||||
t.Fatal(loadErr)
|
||||
}
|
||||
|
||||
if v, ok := form.Data["title"]; !ok || v != "test_new" {
|
||||
t.Fatalf("Expect title field to be %v, got %v", "test_new", v)
|
||||
}
|
||||
|
||||
if v, ok := form.Data["active"]; !ok || v != true {
|
||||
t.Fatalf("Expect active field to be %v, got %v", true, v)
|
||||
}
|
||||
}
|
||||
|
||||
@ -300,15 +227,15 @@ func TestRecordUpsertDrySubmitFailure(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
collection, _ := app.Dao().FindCollectionByNameOrId("demo4")
|
||||
recordBefore, err := app.Dao().FindFirstRecordByData(collection, "id", "054f9f24-0a0a-4e09-87b1-bc7ff2b336a2")
|
||||
collection, _ := app.Dao().FindCollectionByNameOrId("demo1")
|
||||
recordBefore, err := app.Dao().FindRecordById(collection.Id, "al1h9ijdeojtsjy")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
formData, mp, err := tests.MockMultipartData(map[string]string{
|
||||
"title": "a",
|
||||
"onerel": "00000000-84ab-4057-a592-4604a731f78f",
|
||||
"title": "abc",
|
||||
"rel_one": "missing",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@ -317,7 +244,7 @@ func TestRecordUpsertDrySubmitFailure(t *testing.T) {
|
||||
form := forms.NewRecordUpsert(app, recordBefore)
|
||||
req := httptest.NewRequest(http.MethodGet, "/", formData)
|
||||
req.Header.Set(echo.HeaderContentType, mp.FormDataContentType())
|
||||
form.LoadData(req)
|
||||
form.LoadRequest(req, "")
|
||||
|
||||
callbackCalls := 0
|
||||
|
||||
@ -336,17 +263,17 @@ func TestRecordUpsertDrySubmitFailure(t *testing.T) {
|
||||
|
||||
// ensure that the record changes weren't persisted
|
||||
// ---
|
||||
recordAfter, err := app.Dao().FindFirstRecordByData(collection, "id", recordBefore.Id)
|
||||
recordAfter, err := app.Dao().FindRecordById(collection.Id, recordBefore.Id)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if recordAfter.GetStringDataValue("title") == "a" {
|
||||
t.Fatalf("Expected record.title to be %v, got %v", recordAfter.GetStringDataValue("title"), "a")
|
||||
if recordAfter.GetString("title") == "abc" {
|
||||
t.Fatalf("Expected record.title to be %v, got %v", recordAfter.GetString("title"), "abc")
|
||||
}
|
||||
|
||||
if recordAfter.GetStringDataValue("onerel") == "00000000-84ab-4057-a592-4604a731f78f" {
|
||||
t.Fatalf("Expected record.onerel to be %s, got %s", recordBefore.GetStringDataValue("onerel"), recordAfter.GetStringDataValue("onerel"))
|
||||
if recordAfter.GetString("rel_one") == "missing" {
|
||||
t.Fatalf("Expected record.rel_one to be %s, got %s", recordBefore.GetString("rel_one"), "missing")
|
||||
}
|
||||
}
|
||||
|
||||
@ -354,16 +281,16 @@ func TestRecordUpsertDrySubmitSuccess(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
collection, _ := app.Dao().FindCollectionByNameOrId("demo4")
|
||||
recordBefore, err := app.Dao().FindFirstRecordByData(collection, "id", "054f9f24-0a0a-4e09-87b1-bc7ff2b336a2")
|
||||
collection, _ := app.Dao().FindCollectionByNameOrId("demo1")
|
||||
recordBefore, err := app.Dao().FindRecordById(collection.Id, "84nmscqy84lsi1t")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
formData, mp, err := tests.MockMultipartData(map[string]string{
|
||||
"title": "dry_test",
|
||||
"onefile": "",
|
||||
}, "manyfiles")
|
||||
"title": "dry_test",
|
||||
"file_one": "",
|
||||
}, "file_many")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@ -371,7 +298,7 @@ func TestRecordUpsertDrySubmitSuccess(t *testing.T) {
|
||||
form := forms.NewRecordUpsert(app, recordBefore)
|
||||
req := httptest.NewRequest(http.MethodGet, "/", formData)
|
||||
req.Header.Set(echo.HeaderContentType, mp.FormDataContentType())
|
||||
form.LoadData(req)
|
||||
form.LoadRequest(req, "")
|
||||
|
||||
callbackCalls := 0
|
||||
|
||||
@ -390,21 +317,21 @@ func TestRecordUpsertDrySubmitSuccess(t *testing.T) {
|
||||
|
||||
// ensure that the record changes weren't persisted
|
||||
// ---
|
||||
recordAfter, err := app.Dao().FindFirstRecordByData(collection, "id", recordBefore.Id)
|
||||
recordAfter, err := app.Dao().FindRecordById(collection.Id, recordBefore.Id)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if recordAfter.GetStringDataValue("title") == "dry_test" {
|
||||
t.Fatalf("Expected record.title to be %v, got %v", recordAfter.GetStringDataValue("title"), "dry_test")
|
||||
if recordAfter.GetString("title") == "dry_test" {
|
||||
t.Fatalf("Expected record.title to be %v, got %v", recordAfter.GetString("title"), "dry_test")
|
||||
}
|
||||
if recordAfter.GetStringDataValue("onefile") == "" {
|
||||
t.Fatal("Expected record.onefile to be set, got empty string")
|
||||
if recordAfter.GetString("file_one") == "" {
|
||||
t.Fatal("Expected record.file_one to not be changed, got empty string")
|
||||
}
|
||||
|
||||
// file wasn't removed
|
||||
if !hasRecordFile(app, recordAfter, recordAfter.GetStringDataValue("onefile")) {
|
||||
t.Fatal("onefile file should not have been deleted")
|
||||
if !hasRecordFile(app, recordAfter, recordAfter.GetString("file_one")) {
|
||||
t.Fatal("file_one file should not have been deleted")
|
||||
}
|
||||
}
|
||||
|
||||
@ -412,16 +339,23 @@ func TestRecordUpsertSubmitFailure(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
collection, _ := app.Dao().FindCollectionByNameOrId("demo4")
|
||||
recordBefore, err := app.Dao().FindFirstRecordByData(collection, "id", "054f9f24-0a0a-4e09-87b1-bc7ff2b336a2")
|
||||
collection, err := app.Dao().FindCollectionByNameOrId("demo1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
recordBefore, err := app.Dao().FindRecordById(collection.Id, "84nmscqy84lsi1t")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
formData, mp, err := tests.MockMultipartData(map[string]string{
|
||||
"title": "a",
|
||||
"onefile": "",
|
||||
})
|
||||
"text": "abc",
|
||||
"bool": "false",
|
||||
"select_one": "invalid",
|
||||
"file_many": "invalid",
|
||||
"email": "invalid",
|
||||
}, "file_one")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@ -429,7 +363,7 @@ func TestRecordUpsertSubmitFailure(t *testing.T) {
|
||||
form := forms.NewRecordUpsert(app, recordBefore)
|
||||
req := httptest.NewRequest(http.MethodGet, "/", formData)
|
||||
req.Header.Set(echo.HeaderContentType, mp.FormDataContentType())
|
||||
form.LoadData(req)
|
||||
form.LoadRequest(req, "")
|
||||
|
||||
interceptorCalls := 0
|
||||
interceptor := func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
|
||||
@ -454,22 +388,32 @@ func TestRecordUpsertSubmitFailure(t *testing.T) {
|
||||
|
||||
// ensure that the record changes weren't persisted
|
||||
// ---
|
||||
recordAfter, err := app.Dao().FindFirstRecordByData(collection, "id", recordBefore.Id)
|
||||
recordAfter, err := app.Dao().FindRecordById(collection.Id, recordBefore.Id)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if recordAfter.GetStringDataValue("title") == "a" {
|
||||
t.Fatalf("Expected record.title to be %v, got %v", recordAfter.GetStringDataValue("title"), "a")
|
||||
if v := recordAfter.Get("text"); v == "abc" {
|
||||
t.Fatalf("Expected record.text not to change, got %v", v)
|
||||
}
|
||||
if v := recordAfter.Get("bool"); v == false {
|
||||
t.Fatalf("Expected record.bool not to change, got %v", v)
|
||||
}
|
||||
if v := recordAfter.Get("select_one"); v == "invalid" {
|
||||
t.Fatalf("Expected record.select_one not to change, got %v", v)
|
||||
}
|
||||
if v := recordAfter.Get("email"); v == "invalid" {
|
||||
t.Fatalf("Expected record.email not to change, got %v", v)
|
||||
}
|
||||
if v := recordAfter.GetStringSlice("file_many"); len(v) != 3 {
|
||||
t.Fatalf("Expected record.file_many not to change, got %v", v)
|
||||
}
|
||||
|
||||
if recordAfter.GetStringDataValue("onefile") == "" {
|
||||
t.Fatal("Expected record.onefile to be set, got empty string")
|
||||
}
|
||||
|
||||
// file wasn't removed
|
||||
if !hasRecordFile(app, recordAfter, recordAfter.GetStringDataValue("onefile")) {
|
||||
t.Fatal("onefile file should not have been deleted")
|
||||
// ensure the files weren't removed
|
||||
for _, f := range recordAfter.GetStringSlice("file_many") {
|
||||
if !hasRecordFile(app, recordAfter, f) {
|
||||
t.Fatal("file_many file should not have been deleted")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -477,17 +421,18 @@ func TestRecordUpsertSubmitSuccess(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
collection, _ := app.Dao().FindCollectionByNameOrId("demo4")
|
||||
recordBefore, err := app.Dao().FindFirstRecordByData(collection, "id", "054f9f24-0a0a-4e09-87b1-bc7ff2b336a2")
|
||||
collection, _ := app.Dao().FindCollectionByNameOrId("demo1")
|
||||
recordBefore, err := app.Dao().FindRecordById(collection.Id, "84nmscqy84lsi1t")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
formData, mp, err := tests.MockMultipartData(map[string]string{
|
||||
"title": "test_save",
|
||||
"onefile": "",
|
||||
"onlyimages": "",
|
||||
}, "manyfiles.1", "manyfiles") // replace + new file
|
||||
"text": "test_save",
|
||||
"bool": "true",
|
||||
"select_one": "optionA",
|
||||
"file_one": "",
|
||||
}, "file_many.1", "file_many") // replace + new file
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@ -495,7 +440,7 @@ func TestRecordUpsertSubmitSuccess(t *testing.T) {
|
||||
form := forms.NewRecordUpsert(app, recordBefore)
|
||||
req := httptest.NewRequest(http.MethodGet, "/", formData)
|
||||
req.Header.Set(echo.HeaderContentType, mp.FormDataContentType())
|
||||
form.LoadData(req)
|
||||
form.LoadRequest(req, "")
|
||||
|
||||
interceptorCalls := 0
|
||||
interceptor := func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
|
||||
@ -518,29 +463,24 @@ func TestRecordUpsertSubmitSuccess(t *testing.T) {
|
||||
|
||||
// ensure that the record changes were persisted
|
||||
// ---
|
||||
recordAfter, err := app.Dao().FindFirstRecordByData(collection, "id", recordBefore.Id)
|
||||
recordAfter, err := app.Dao().FindRecordById(collection.Id, recordBefore.Id)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if recordAfter.GetStringDataValue("title") != "test_save" {
|
||||
t.Fatalf("Expected record.title to be %v, got %v", recordAfter.GetStringDataValue("title"), "test_save")
|
||||
if v := recordAfter.GetString("text"); v != "test_save" {
|
||||
t.Fatalf("Expected record.text to be %v, got %v", v, "test_save")
|
||||
}
|
||||
|
||||
if hasRecordFile(app, recordAfter, recordAfter.GetStringDataValue("onefile")) {
|
||||
t.Fatal("Expected record.onefile to be deleted")
|
||||
if hasRecordFile(app, recordAfter, recordAfter.GetString("file_one")) {
|
||||
t.Fatal("Expected record.file_one to be deleted")
|
||||
}
|
||||
|
||||
onlyimages := (recordAfter.GetStringSliceDataValue("onlyimages"))
|
||||
if len(onlyimages) != 0 {
|
||||
t.Fatalf("Expected all onlyimages files to be deleted, got %d (%v)", len(onlyimages), onlyimages)
|
||||
fileMany := (recordAfter.GetStringSlice("file_many"))
|
||||
if len(fileMany) != 4 { // 1 replace + 1 new
|
||||
t.Fatalf("Expected 4 record.file_many, got %d (%v)", len(fileMany), fileMany)
|
||||
}
|
||||
|
||||
manyfiles := (recordAfter.GetStringSliceDataValue("manyfiles"))
|
||||
if len(manyfiles) != 3 {
|
||||
t.Fatalf("Expected 3 manyfiles, got %d (%v)", len(manyfiles), manyfiles)
|
||||
}
|
||||
for _, f := range manyfiles {
|
||||
for _, f := range fileMany {
|
||||
if !hasRecordFile(app, recordAfter, f) {
|
||||
t.Fatalf("Expected file %q to exist", f)
|
||||
}
|
||||
@ -551,8 +491,8 @@ func TestRecordUpsertSubmitInterceptors(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
collection, _ := app.Dao().FindCollectionByNameOrId("demo4")
|
||||
record, err := app.Dao().FindFirstRecordByData(collection, "id", "054f9f24-0a0a-4e09-87b1-bc7ff2b336a2")
|
||||
collection, _ := app.Dao().FindCollectionByNameOrId("demo3")
|
||||
record, err := app.Dao().FindRecordById(collection.Id, "mk5fmymtx4wsprk")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@ -574,7 +514,7 @@ func TestRecordUpsertSubmitInterceptors(t *testing.T) {
|
||||
interceptor2Called := false
|
||||
interceptor2 := func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
|
||||
return func() error {
|
||||
interceptorRecordTitle = record.GetStringDataValue("title") // to check if the record was filled
|
||||
interceptorRecordTitle = record.GetString("title") // to check if the record was filled
|
||||
interceptor2Called = true
|
||||
return testErr
|
||||
}
|
||||
@ -598,27 +538,16 @@ func TestRecordUpsertSubmitInterceptors(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func hasRecordFile(app core.App, record *models.Record, filename string) bool {
|
||||
fs, _ := app.NewFilesystem()
|
||||
defer fs.Close()
|
||||
|
||||
fileKey := filepath.Join(
|
||||
record.Collection().Id,
|
||||
record.Id,
|
||||
filename,
|
||||
)
|
||||
|
||||
exists, _ := fs.Exists(fileKey)
|
||||
|
||||
return exists
|
||||
}
|
||||
|
||||
func TestRecordUpsertWithCustomId(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
collection, _ := app.Dao().FindCollectionByNameOrId("demo3")
|
||||
existingRecord, err := app.Dao().FindFirstRecordByData(collection, "id", "2c542824-9de1-42fe-8924-e57c86267760")
|
||||
collection, err := app.Dao().FindCollectionByNameOrId("demo3")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
existingRecord, err := app.Dao().FindRecordById(collection.Id, "mk5fmymtx4wsprk")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@ -694,7 +623,7 @@ func TestRecordUpsertWithCustomId(t *testing.T) {
|
||||
form := forms.NewRecordUpsert(app, scenario.record)
|
||||
req := httptest.NewRequest(http.MethodGet, "/", formData)
|
||||
req.Header.Set(echo.HeaderContentType, mp.FormDataContentType())
|
||||
form.LoadData(req)
|
||||
form.LoadRequest(req, "")
|
||||
|
||||
dryErr := form.DrySubmit(nil)
|
||||
hasDryErr := dryErr != nil
|
||||
@ -711,10 +640,191 @@ func TestRecordUpsertWithCustomId(t *testing.T) {
|
||||
}
|
||||
|
||||
if id, ok := scenario.data["id"]; ok && id != "" && !hasSubmitErr {
|
||||
_, err := app.Dao().FindRecordById(collection, id, nil)
|
||||
_, err := app.Dao().FindRecordById(collection.Id, id)
|
||||
if err != nil {
|
||||
t.Errorf("[%s] Expected to find record with id %s, got %v", scenario.name, id, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordUpsertAuthRecord(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
testName string
|
||||
existingId string
|
||||
data map[string]any
|
||||
manageAccess bool
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
"empty create data",
|
||||
"",
|
||||
map[string]any{},
|
||||
false,
|
||||
true,
|
||||
},
|
||||
{
|
||||
"empty update data",
|
||||
"4q1xlclmfloku33",
|
||||
map[string]any{},
|
||||
false,
|
||||
false,
|
||||
},
|
||||
{
|
||||
"minimum valid create data",
|
||||
"",
|
||||
map[string]any{
|
||||
"password": "12345678",
|
||||
"passwordConfirm": "12345678",
|
||||
},
|
||||
false,
|
||||
false,
|
||||
},
|
||||
{
|
||||
"create with all allowed auth fields",
|
||||
"",
|
||||
map[string]any{
|
||||
"username": "test_new",
|
||||
"email": "test_new@example.com",
|
||||
"emailVisibility": true,
|
||||
"password": "12345678",
|
||||
"passwordConfirm": "12345678",
|
||||
},
|
||||
false,
|
||||
false,
|
||||
},
|
||||
|
||||
// verified
|
||||
{
|
||||
"try to set verified without managed access",
|
||||
"",
|
||||
map[string]any{
|
||||
"verified": true,
|
||||
"password": "12345678",
|
||||
"passwordConfirm": "12345678",
|
||||
},
|
||||
false,
|
||||
true,
|
||||
},
|
||||
{
|
||||
"try to update verified without managed access",
|
||||
"4q1xlclmfloku33",
|
||||
map[string]any{
|
||||
"verified": true,
|
||||
},
|
||||
false,
|
||||
true,
|
||||
},
|
||||
{
|
||||
"set verified with managed access",
|
||||
"",
|
||||
map[string]any{
|
||||
"verified": true,
|
||||
"password": "12345678",
|
||||
"passwordConfirm": "12345678",
|
||||
},
|
||||
true,
|
||||
false,
|
||||
},
|
||||
{
|
||||
"update verified with managed access",
|
||||
"4q1xlclmfloku33",
|
||||
map[string]any{
|
||||
"verified": true,
|
||||
},
|
||||
true,
|
||||
false,
|
||||
},
|
||||
|
||||
// email
|
||||
{
|
||||
"try to update email without managed access",
|
||||
"4q1xlclmfloku33",
|
||||
map[string]any{
|
||||
"email": "test_update@example.com",
|
||||
},
|
||||
false,
|
||||
true,
|
||||
},
|
||||
{
|
||||
"update email with managed access",
|
||||
"4q1xlclmfloku33",
|
||||
map[string]any{
|
||||
"email": "test_update@example.com",
|
||||
},
|
||||
true,
|
||||
false,
|
||||
},
|
||||
|
||||
// password
|
||||
{
|
||||
"try to update password without managed access",
|
||||
"4q1xlclmfloku33",
|
||||
map[string]any{
|
||||
"password": "1234567890",
|
||||
"passwordConfirm": "1234567890",
|
||||
},
|
||||
false,
|
||||
true,
|
||||
},
|
||||
{
|
||||
"update password without managed access but with oldPassword",
|
||||
"4q1xlclmfloku33",
|
||||
map[string]any{
|
||||
"oldPassword": "1234567890",
|
||||
"password": "1234567890",
|
||||
"passwordConfirm": "1234567890",
|
||||
},
|
||||
false,
|
||||
false,
|
||||
},
|
||||
{
|
||||
"update email with managed access (without oldPassword)",
|
||||
"4q1xlclmfloku33",
|
||||
map[string]any{
|
||||
"password": "1234567890",
|
||||
"passwordConfirm": "1234567890",
|
||||
},
|
||||
true,
|
||||
false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
collection, err := app.Dao().FindCollectionByNameOrId("users")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
record := models.NewRecord(collection)
|
||||
if s.existingId != "" {
|
||||
var err error
|
||||
record, err = app.Dao().FindRecordById(collection.Id, s.existingId)
|
||||
if err != nil {
|
||||
t.Errorf("[%s] Failed to fetch auth record with id %s", s.testName, s.existingId)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
form := forms.NewRecordUpsert(app, record)
|
||||
form.SetFullManageAccess(s.manageAccess)
|
||||
if err := form.LoadData(s.data); err != nil {
|
||||
t.Errorf("[%s] Failed to load form data", s.testName)
|
||||
continue
|
||||
}
|
||||
|
||||
submitErr := form.Submit()
|
||||
|
||||
hasErr := submitErr != nil
|
||||
if hasErr != s.expectError {
|
||||
t.Errorf("[%s] Expected hasErr %v, got %v (%v)", s.testName, s.expectError, hasErr, submitErr)
|
||||
}
|
||||
|
||||
if !hasErr && record.Username() == "" {
|
||||
t.Errorf("[%s] Expected username to be set, got empty string: \n%v", s.testName, record)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
103
forms/record_verification_confirm.go
Normal file
103
forms/record_verification_confirm.go
Normal file
@ -0,0 +1,103 @@
|
||||
package forms
|
||||
|
||||
import (
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/daos"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
"github.com/spf13/cast"
|
||||
)
|
||||
|
||||
// RecordVerificationConfirm is an auth record email verification confirmation form.
|
||||
type RecordVerificationConfirm struct {
|
||||
app core.App
|
||||
collection *models.Collection
|
||||
dao *daos.Dao
|
||||
|
||||
Token string `form:"token" json:"token"`
|
||||
}
|
||||
|
||||
// NewRecordVerificationConfirm creates a new [RecordVerificationConfirm]
|
||||
// form initialized with from the provided [core.App] instance.
|
||||
//
|
||||
// If you want to submit the form as part of a transaction,
|
||||
// you can change the default Dao via [SetDao()].
|
||||
func NewRecordVerificationConfirm(app core.App, collection *models.Collection) *RecordVerificationConfirm {
|
||||
return &RecordVerificationConfirm{
|
||||
app: app,
|
||||
dao: app.Dao(),
|
||||
collection: collection,
|
||||
}
|
||||
}
|
||||
|
||||
// SetDao replaces the default form Dao instance with the provided one.
|
||||
func (form *RecordVerificationConfirm) SetDao(dao *daos.Dao) {
|
||||
form.dao = dao
|
||||
}
|
||||
|
||||
// Validate makes the form validatable by implementing [validation.Validatable] interface.
|
||||
func (form *RecordVerificationConfirm) Validate() error {
|
||||
return validation.ValidateStruct(form,
|
||||
validation.Field(&form.Token, validation.Required, validation.By(form.checkToken)),
|
||||
)
|
||||
}
|
||||
|
||||
func (form *RecordVerificationConfirm) checkToken(value any) error {
|
||||
v, _ := value.(string)
|
||||
if v == "" {
|
||||
return nil // nothing to check
|
||||
}
|
||||
|
||||
claims, _ := security.ParseUnverifiedJWT(v)
|
||||
email := cast.ToString(claims["email"])
|
||||
if email == "" {
|
||||
return validation.NewError("validation_invalid_token_claims", "Missing email token claim.")
|
||||
}
|
||||
|
||||
record, err := form.dao.FindAuthRecordByToken(
|
||||
v,
|
||||
form.app.Settings().RecordVerificationToken.Secret,
|
||||
)
|
||||
if err != nil || record == nil {
|
||||
return validation.NewError("validation_invalid_token", "Invalid or expired token.")
|
||||
}
|
||||
|
||||
if record.Collection().Id != form.collection.Id {
|
||||
return validation.NewError("validation_token_collection_mismatch", "The provided token is for different auth collection.")
|
||||
}
|
||||
|
||||
if record.Email() != email {
|
||||
return validation.NewError("validation_token_email_mismatch", "The record email doesn't match with the requested token claims.")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Submit validates and submits the form.
|
||||
// On success returns the verified auth record associated to `form.Token`.
|
||||
func (form *RecordVerificationConfirm) Submit() (*models.Record, error) {
|
||||
if err := form.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
record, err := form.dao.FindAuthRecordByToken(
|
||||
form.Token,
|
||||
form.app.Settings().RecordVerificationToken.Secret,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if record.Verified() {
|
||||
return record, nil // already verified
|
||||
}
|
||||
|
||||
record.SetVerified(true)
|
||||
|
||||
if err := form.dao.SaveRecord(record); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return record, nil
|
||||
}
|
79
forms/record_verification_confirm_test.go
Normal file
79
forms/record_verification_confirm_test.go
Normal file
@ -0,0 +1,79 @@
|
||||
package forms_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/pocketbase/pocketbase/forms"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
)
|
||||
|
||||
func TestRecordVerificationConfirmValidateAndSubmit(t *testing.T) {
|
||||
testApp, _ := tests.NewTestApp()
|
||||
defer testApp.Cleanup()
|
||||
|
||||
authCollection, err := testApp.Dao().FindCollectionByNameOrId("users")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
scenarios := []struct {
|
||||
jsonData string
|
||||
expectError bool
|
||||
}{
|
||||
// empty data (Validate call check)
|
||||
{
|
||||
`{}`,
|
||||
true,
|
||||
},
|
||||
// expired token (Validate call check)
|
||||
{
|
||||
`{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImNvbGxlY3Rpb25JZCI6Il9wYl91c2Vyc19hdXRoXyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiZXhwIjoxNjQwOTkxNjYxfQ.Avbt9IP8sBisVz_2AGrlxLDvangVq4PhL2zqQVYLKlE"}`,
|
||||
true,
|
||||
},
|
||||
// valid token (already verified record)
|
||||
{
|
||||
`{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6Im9hcDY0MGNvdDR5cnUycyIsImVtYWlsIjoidGVzdDJAZXhhbXBsZS5jb20iLCJjb2xsZWN0aW9uSWQiOiJfcGJfdXNlcnNfYXV0aF8iLCJ0eXBlIjoiYXV0aFJlY29yZCIsImV4cCI6MjIwODk4NTI2MX0.PsOABmYUzGbd088g8iIBL4-pf7DUZm0W5Ju6lL5JVRg"}`,
|
||||
false,
|
||||
},
|
||||
// valid token (unverified record)
|
||||
{
|
||||
`{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRxMXhsY2xtZmxva3UzMyIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImNvbGxlY3Rpb25JZCI6Il9wYl91c2Vyc19hdXRoXyIsInR5cGUiOiJhdXRoUmVjb3JkIiwiZXhwIjoyMjA4OTg1MjYxfQ.hL16TVmStHFdHLc4a860bRqJ3sFfzjv0_NRNzwsvsrc"}`,
|
||||
false,
|
||||
},
|
||||
}
|
||||
|
||||
for i, s := range scenarios {
|
||||
form := forms.NewRecordVerificationConfirm(testApp, authCollection)
|
||||
|
||||
// load data
|
||||
loadErr := json.Unmarshal([]byte(s.jsonData), form)
|
||||
if loadErr != nil {
|
||||
t.Errorf("(%d) Failed to load form data: %v", i, loadErr)
|
||||
continue
|
||||
}
|
||||
|
||||
record, err := form.Submit()
|
||||
|
||||
hasErr := err != nil
|
||||
if hasErr != s.expectError {
|
||||
t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, s.expectError, hasErr, err)
|
||||
}
|
||||
|
||||
if hasErr {
|
||||
continue
|
||||
}
|
||||
|
||||
claims, _ := security.ParseUnverifiedJWT(form.Token)
|
||||
tokenRecordId := claims["id"]
|
||||
|
||||
if record.Id != tokenRecordId {
|
||||
t.Errorf("(%d) Expected record.Id %q, got %q", i, tokenRecordId, record.Id)
|
||||
}
|
||||
|
||||
if !record.Verified() {
|
||||
t.Errorf("(%d) Expected record.Verified() to be true, got false", i)
|
||||
}
|
||||
}
|
||||
}
|
94
forms/record_verification_request.go
Normal file
94
forms/record_verification_request.go
Normal file
@ -0,0 +1,94 @@
|
||||
package forms
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
"github.com/go-ozzo/ozzo-validation/v4/is"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/daos"
|
||||
"github.com/pocketbase/pocketbase/mails"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/models/schema"
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
)
|
||||
|
||||
// RecordVerificationRequest is an auth record email verification request form.
|
||||
type RecordVerificationRequest struct {
|
||||
app core.App
|
||||
collection *models.Collection
|
||||
dao *daos.Dao
|
||||
resendThreshold float64 // in seconds
|
||||
|
||||
Email string `form:"email" json:"email"`
|
||||
}
|
||||
|
||||
// NewRecordVerificationRequest creates a new [RecordVerificationRequest]
|
||||
// form initialized with from the provided [core.App] instance.
|
||||
//
|
||||
// If you want to submit the form as part of a transaction,
|
||||
// you can change the default Dao via [SetDao()].
|
||||
func NewRecordVerificationRequest(app core.App, collection *models.Collection) *RecordVerificationRequest {
|
||||
return &RecordVerificationRequest{
|
||||
app: app,
|
||||
dao: app.Dao(),
|
||||
collection: collection,
|
||||
resendThreshold: 120, // 2 min
|
||||
}
|
||||
}
|
||||
|
||||
// SetDao replaces the default form Dao instance with the provided one.
|
||||
func (form *RecordVerificationRequest) SetDao(dao *daos.Dao) {
|
||||
form.dao = dao
|
||||
}
|
||||
|
||||
// Validate makes the form validatable by implementing [validation.Validatable] interface.
|
||||
//
|
||||
// // This method doesn't verify that auth record with `form.Email` exists (this is done on Submit).
|
||||
func (form *RecordVerificationRequest) Validate() error {
|
||||
return validation.ValidateStruct(form,
|
||||
validation.Field(
|
||||
&form.Email,
|
||||
validation.Required,
|
||||
validation.Length(1, 255),
|
||||
is.EmailFormat,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// Submit validates and sends a verification request email
|
||||
// to the `form.Email` auth record.
|
||||
func (form *RecordVerificationRequest) Submit() error {
|
||||
if err := form.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
record, err := form.dao.FindFirstRecordByData(
|
||||
form.collection.Id,
|
||||
schema.FieldNameEmail,
|
||||
form.Email,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if record.GetBool(schema.FieldNameVerified) {
|
||||
return nil // already verified
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
lastVerificationSentAt := record.LastVerificationSentAt().Time()
|
||||
if (now.Sub(lastVerificationSentAt)).Seconds() < form.resendThreshold {
|
||||
return errors.New("A verification email was already sent.")
|
||||
}
|
||||
|
||||
if err := mails.SendRecordVerification(form.app, record); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// update last sent timestamp
|
||||
record.Set(schema.FieldNameLastVerificationSentAt, types.NowDateTime())
|
||||
|
||||
return form.dao.SaveRecord(record)
|
||||
}
|
@ -5,86 +5,20 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
"github.com/pocketbase/pocketbase/forms"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
)
|
||||
|
||||
func TestUserVerificationRequestPanic(t *testing.T) {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("The form did not panic")
|
||||
}
|
||||
}()
|
||||
|
||||
forms.NewUserVerificationRequest(nil)
|
||||
}
|
||||
|
||||
func TestUserVerificationRequestValidate(t *testing.T) {
|
||||
func TestRecordVerificationRequestSubmit(t *testing.T) {
|
||||
testApp, _ := tests.NewTestApp()
|
||||
defer testApp.Cleanup()
|
||||
|
||||
scenarios := []struct {
|
||||
jsonData string
|
||||
expectedErrors []string
|
||||
}{
|
||||
// empty data
|
||||
{
|
||||
`{}`,
|
||||
[]string{"email"},
|
||||
},
|
||||
// empty fields
|
||||
{
|
||||
`{"email":""}`,
|
||||
[]string{"email"},
|
||||
},
|
||||
// invalid email format
|
||||
{
|
||||
`{"email":"invalid"}`,
|
||||
[]string{"email"},
|
||||
},
|
||||
// valid email
|
||||
{
|
||||
`{"email":"new@example.com"}`,
|
||||
[]string{},
|
||||
},
|
||||
authCollection, err := testApp.Dao().FindCollectionByNameOrId("clients")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
for i, s := range scenarios {
|
||||
form := forms.NewUserVerificationRequest(testApp)
|
||||
|
||||
// load data
|
||||
loadErr := json.Unmarshal([]byte(s.jsonData), form)
|
||||
if loadErr != nil {
|
||||
t.Errorf("(%d) Failed to load form data: %v", i, loadErr)
|
||||
continue
|
||||
}
|
||||
|
||||
// parse errors
|
||||
result := form.Validate()
|
||||
errs, ok := result.(validation.Errors)
|
||||
if !ok && result != nil {
|
||||
t.Errorf("(%d) Failed to parse errors %v", i, result)
|
||||
continue
|
||||
}
|
||||
|
||||
// check errors
|
||||
if len(errs) > len(s.expectedErrors) {
|
||||
t.Errorf("(%d) Expected error keys %v, got %v", i, s.expectedErrors, errs)
|
||||
}
|
||||
for _, k := range s.expectedErrors {
|
||||
if _, ok := errs[k]; !ok {
|
||||
t.Errorf("(%d) Missing expected error key %q in %v", i, k, errs)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserVerificationRequestSubmit(t *testing.T) {
|
||||
testApp, _ := tests.NewTestApp()
|
||||
defer testApp.Cleanup()
|
||||
|
||||
scenarios := []struct {
|
||||
jsonData string
|
||||
expectError bool
|
||||
@ -139,7 +73,7 @@ func TestUserVerificationRequestSubmit(t *testing.T) {
|
||||
|
||||
for i, s := range scenarios {
|
||||
testApp.TestMailer.TotalSend = 0 // reset
|
||||
form := forms.NewUserVerificationRequest(testApp)
|
||||
form := forms.NewRecordVerificationRequest(testApp, authCollection)
|
||||
|
||||
// load data
|
||||
loadErr := json.Unmarshal([]byte(s.jsonData), form)
|
||||
@ -167,15 +101,15 @@ func TestUserVerificationRequestSubmit(t *testing.T) {
|
||||
continue
|
||||
}
|
||||
|
||||
user, err := testApp.Dao().FindUserByEmail(form.Email)
|
||||
user, err := testApp.Dao().FindAuthRecordByEmail(authCollection.Id, form.Email)
|
||||
if err != nil {
|
||||
t.Errorf("(%d) Expected user with email %q to exist, got nil", i, form.Email)
|
||||
continue
|
||||
}
|
||||
|
||||
// check whether LastVerificationSentAt was updated
|
||||
if !user.Verified && user.LastVerificationSentAt.Time().Sub(now.Time()) < 0 {
|
||||
t.Errorf("(%d) Expected LastVerificationSentAt to be after %v, got %v", i, now, user.LastVerificationSentAt)
|
||||
if !user.Verified() && user.LastVerificationSentAt().Time().Sub(now.Time()) < 0 {
|
||||
t.Errorf("(%d) Expected LastVerificationSentAt to be after %v, got %v", i, now, user.LastVerificationSentAt())
|
||||
}
|
||||
}
|
||||
}
|
@ -9,56 +9,36 @@ import (
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
)
|
||||
|
||||
// SettingsUpsert specifies a [core.Settings] upsert (create/update) form.
|
||||
// SettingsUpsert is a [core.Settings] upsert (create/update) form.
|
||||
type SettingsUpsert struct {
|
||||
*core.Settings
|
||||
|
||||
config SettingsUpsertConfig
|
||||
}
|
||||
|
||||
// SettingsUpsertConfig is the [SettingsUpsert] factory initializer config.
|
||||
//
|
||||
// NB! App is required struct member.
|
||||
type SettingsUpsertConfig struct {
|
||||
App core.App
|
||||
Dao *daos.Dao
|
||||
LogsDao *daos.Dao
|
||||
app core.App
|
||||
dao *daos.Dao
|
||||
}
|
||||
|
||||
// NewSettingsUpsert creates a new [SettingsUpsert] form with initializer
|
||||
// config created from the provided [core.App] instance.
|
||||
//
|
||||
// If you want to submit the form as part of another transaction, use
|
||||
// [NewSettingsUpsertWithConfig] with explicitly set Dao.
|
||||
// If you want to submit the form as part of a transaction,
|
||||
// you can change the default Dao via [SetDao()].
|
||||
func NewSettingsUpsert(app core.App) *SettingsUpsert {
|
||||
return NewSettingsUpsertWithConfig(SettingsUpsertConfig{
|
||||
App: app,
|
||||
})
|
||||
}
|
||||
|
||||
// NewSettingsUpsertWithConfig creates a new [SettingsUpsert] form
|
||||
// with the provided config or panics on invalid configuration.
|
||||
func NewSettingsUpsertWithConfig(config SettingsUpsertConfig) *SettingsUpsert {
|
||||
form := &SettingsUpsert{config: config}
|
||||
|
||||
if form.config.App == nil {
|
||||
panic("Missing required config.App instance.")
|
||||
}
|
||||
|
||||
if form.config.Dao == nil {
|
||||
form.config.Dao = form.config.App.Dao()
|
||||
}
|
||||
|
||||
if form.config.LogsDao == nil {
|
||||
form.config.LogsDao = form.config.App.LogsDao()
|
||||
form := &SettingsUpsert{
|
||||
app: app,
|
||||
dao: app.Dao(),
|
||||
}
|
||||
|
||||
// load the application settings into the form
|
||||
form.Settings, _ = config.App.Settings().Clone()
|
||||
form.Settings, _ = app.Settings().Clone()
|
||||
|
||||
return form
|
||||
}
|
||||
|
||||
// SetDao replaces the default form Dao instance with the provided one.
|
||||
func (form *SettingsUpsert) SetDao(dao *daos.Dao) {
|
||||
form.dao = dao
|
||||
}
|
||||
|
||||
// Validate makes the form validatable by implementing [validation.Validatable] interface.
|
||||
func (form *SettingsUpsert) Validate() error {
|
||||
return form.Settings.Validate()
|
||||
@ -75,10 +55,10 @@ func (form *SettingsUpsert) Submit(interceptors ...InterceptorFunc) error {
|
||||
return err
|
||||
}
|
||||
|
||||
encryptionKey := os.Getenv(form.config.App.EncryptionEnv())
|
||||
encryptionKey := os.Getenv(form.app.EncryptionEnv())
|
||||
|
||||
return runInterceptors(func() error {
|
||||
saveErr := form.config.Dao.SaveParam(
|
||||
saveErr := form.dao.SaveParam(
|
||||
models.ParamAppSettings,
|
||||
form.Settings,
|
||||
encryptionKey,
|
||||
@ -88,11 +68,11 @@ func (form *SettingsUpsert) Submit(interceptors ...InterceptorFunc) error {
|
||||
}
|
||||
|
||||
// explicitly trigger old logs deletion
|
||||
form.config.LogsDao.DeleteOldRequests(
|
||||
form.app.LogsDao().DeleteOldRequests(
|
||||
time.Now().AddDate(0, 0, -1*form.Settings.Logs.MaxDays),
|
||||
)
|
||||
|
||||
// merge the application settings with the form ones
|
||||
return form.config.App.Settings().Merge(form.Settings)
|
||||
return form.app.Settings().Merge(form.Settings)
|
||||
}, interceptors...)
|
||||
}
|
||||
|
@ -12,16 +12,6 @@ import (
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
)
|
||||
|
||||
func TestSettingsUpsertPanic(t *testing.T) {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("The form did not panic")
|
||||
}
|
||||
}()
|
||||
|
||||
forms.NewSettingsUpsert(nil)
|
||||
}
|
||||
|
||||
func TestNewSettingsUpsert(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
@ -38,29 +28,7 @@ func TestNewSettingsUpsert(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSettingsUpsertValidate(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
form := forms.NewSettingsUpsert(app)
|
||||
|
||||
// check if settings validations are triggered
|
||||
// (there are already individual tests for each setting)
|
||||
form.Meta.AppName = ""
|
||||
form.Logs.MaxDays = -10
|
||||
|
||||
// parse errors
|
||||
err := form.Validate()
|
||||
jsonResult, _ := json.Marshal(err)
|
||||
|
||||
expected := `{"logs":{"maxDays":"must be no less than 0"},"meta":{"appName":"cannot be blank"}}`
|
||||
|
||||
if string(jsonResult) != expected {
|
||||
t.Errorf("Expected %v, got %v", expected, string(jsonResult))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSettingsUpsertSubmit(t *testing.T) {
|
||||
func TestSettingsUpsertValidateAndSubmit(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
@ -75,19 +43,19 @@ func TestSettingsUpsertSubmit(t *testing.T) {
|
||||
{"{}", true, nil},
|
||||
// failure - invalid data
|
||||
{
|
||||
`{"emailAuth": {"minPasswordLength": 1}, "logs": {"maxDays": -1}}`,
|
||||
`{"meta": {"appName": ""}, "logs": {"maxDays": -1}}`,
|
||||
false,
|
||||
[]string{"emailAuth", "logs"},
|
||||
[]string{"meta", "logs"},
|
||||
},
|
||||
// success - valid data (plain)
|
||||
{
|
||||
`{"emailAuth": {"minPasswordLength": 6}, "logs": {"maxDays": 0}}`,
|
||||
`{"meta": {"appName": "test"}, "logs": {"maxDays": 0}}`,
|
||||
false,
|
||||
nil,
|
||||
},
|
||||
// success - valid data (encrypt)
|
||||
{
|
||||
`{"emailAuth": {"minPasswordLength": 6}, "logs": {"maxDays": 0}}`,
|
||||
`{"meta": {"appName": "test"}, "logs": {"maxDays": 7}}`,
|
||||
true,
|
||||
nil,
|
||||
},
|
||||
|
@ -6,6 +6,7 @@ import (
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/mails"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/models/schema"
|
||||
)
|
||||
|
||||
const (
|
||||
@ -39,7 +40,7 @@ func (form *TestEmailSend) Validate() error {
|
||||
validation.Field(
|
||||
&form.Template,
|
||||
validation.Required,
|
||||
validation.In(templateVerification, templateEmailChange, templatePasswordReset),
|
||||
validation.In(templateVerification, templatePasswordReset, templateEmailChange),
|
||||
),
|
||||
)
|
||||
}
|
||||
@ -50,19 +51,26 @@ func (form *TestEmailSend) Submit() error {
|
||||
return err
|
||||
}
|
||||
|
||||
// create a test user
|
||||
user := &models.User{}
|
||||
user.Id = "__pb_test_id__"
|
||||
user.Email = form.Email
|
||||
user.RefreshTokenKey()
|
||||
// create a test auth record
|
||||
collection := &models.Collection{
|
||||
BaseModel: models.BaseModel{Id: "__pb_test_collection_id__"},
|
||||
Name: "__pb_test_collection_name__",
|
||||
Type: models.CollectionTypeAuth,
|
||||
}
|
||||
|
||||
record := models.NewRecord(collection)
|
||||
record.Id = "__pb_test_id__"
|
||||
record.Set(schema.FieldNameUsername, "pb_test")
|
||||
record.Set(schema.FieldNameEmail, form.Email)
|
||||
record.RefreshTokenKey()
|
||||
|
||||
switch form.Template {
|
||||
case templateVerification:
|
||||
return mails.SendUserVerification(form.app, user)
|
||||
return mails.SendRecordVerification(form.app, record)
|
||||
case templatePasswordReset:
|
||||
return mails.SendUserPasswordReset(form.app, user)
|
||||
return mails.SendRecordPasswordReset(form.app, record)
|
||||
case templateEmailChange:
|
||||
return mails.SendUserChangeEmail(form.app, user, form.Email)
|
||||
return mails.SendRecordChangeEmail(form.app, record, form.Email)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
@ -9,10 +9,7 @@ import (
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
)
|
||||
|
||||
func TestEmailSendValidate(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
func TestEmailSendValidateAndSubmit(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
template string
|
||||
email string
|
||||
@ -27,11 +24,14 @@ func TestEmailSendValidate(t *testing.T) {
|
||||
}
|
||||
|
||||
for i, s := range scenarios {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
form := forms.NewTestEmailSend(app)
|
||||
form.Email = s.email
|
||||
form.Template = s.template
|
||||
|
||||
result := form.Validate()
|
||||
result := form.Submit()
|
||||
|
||||
// parse errors
|
||||
errs, ok := result.(validation.Errors)
|
||||
@ -43,52 +43,28 @@ func TestEmailSendValidate(t *testing.T) {
|
||||
// check errors
|
||||
if len(errs) > len(s.expectedErrors) {
|
||||
t.Errorf("(%d) Expected error keys %v, got %v", i, s.expectedErrors, errs)
|
||||
continue
|
||||
}
|
||||
for _, k := range s.expectedErrors {
|
||||
if _, ok := errs[k]; !ok {
|
||||
t.Errorf("(%d) Missing expected error key %q in %v", i, k, errs)
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmailSendSubmit(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
template string
|
||||
email string
|
||||
expectError bool
|
||||
}{
|
||||
{"", "", true},
|
||||
{"invalid", "test@example.com", true},
|
||||
{"verification", "invalid", true},
|
||||
{"verification", "test@example.com", false},
|
||||
{"password-reset", "test@example.com", false},
|
||||
{"email-change", "test@example.com", false},
|
||||
}
|
||||
|
||||
for i, s := range scenarios {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
form := forms.NewTestEmailSend(app)
|
||||
form.Email = s.email
|
||||
form.Template = s.template
|
||||
|
||||
err := form.Submit()
|
||||
|
||||
hasErr := err != nil
|
||||
if hasErr != s.expectError {
|
||||
t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, s.expectError, hasErr, err)
|
||||
expectedEmails := 1
|
||||
if len(s.expectedErrors) > 0 {
|
||||
expectedEmails = 0
|
||||
}
|
||||
|
||||
if hasErr {
|
||||
if app.TestMailer.TotalSend != expectedEmails {
|
||||
t.Errorf("(%d) Expected %d email(s) to be sent, got %d", i, expectedEmails, app.TestMailer.TotalSend)
|
||||
}
|
||||
|
||||
if len(s.expectedErrors) > 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
if app.TestMailer.TotalSend != 1 {
|
||||
t.Errorf("(%d) Expected one email to be sent, got %d", i, app.TestMailer.TotalSend)
|
||||
}
|
||||
|
||||
expectedContent := "Verify"
|
||||
if s.template == "password-reset" {
|
||||
expectedContent = "Reset password"
|
||||
|
@ -1,143 +0,0 @@
|
||||
package forms
|
||||
|
||||
import (
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/daos"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
)
|
||||
|
||||
// UserEmailChangeConfirm specifies a user email change confirmation form.
|
||||
type UserEmailChangeConfirm struct {
|
||||
config UserEmailChangeConfirmConfig
|
||||
|
||||
Token string `form:"token" json:"token"`
|
||||
Password string `form:"password" json:"password"`
|
||||
}
|
||||
|
||||
// UserEmailChangeConfirmConfig is the [UserEmailChangeConfirm] factory initializer config.
|
||||
//
|
||||
// NB! App is required struct member.
|
||||
type UserEmailChangeConfirmConfig struct {
|
||||
App core.App
|
||||
Dao *daos.Dao
|
||||
}
|
||||
|
||||
// NewUserEmailChangeConfirm creates a new [UserEmailChangeConfirm]
|
||||
// form with initializer config created from the provided [core.App] instance.
|
||||
//
|
||||
// This factory method is used primarily for convenience (and backward compatibility).
|
||||
// If you want to submit the form as part of another transaction, use
|
||||
// [NewUserEmailChangeConfirmWithConfig] with explicitly set Dao.
|
||||
func NewUserEmailChangeConfirm(app core.App) *UserEmailChangeConfirm {
|
||||
return NewUserEmailChangeConfirmWithConfig(UserEmailChangeConfirmConfig{
|
||||
App: app,
|
||||
})
|
||||
}
|
||||
|
||||
// NewUserEmailChangeConfirmWithConfig creates a new [UserEmailChangeConfirm]
|
||||
// form with the provided config or panics on invalid configuration.
|
||||
func NewUserEmailChangeConfirmWithConfig(config UserEmailChangeConfirmConfig) *UserEmailChangeConfirm {
|
||||
form := &UserEmailChangeConfirm{config: config}
|
||||
|
||||
if form.config.App == nil {
|
||||
panic("Missing required config.App instance.")
|
||||
}
|
||||
|
||||
if form.config.Dao == nil {
|
||||
form.config.Dao = form.config.App.Dao()
|
||||
}
|
||||
|
||||
return form
|
||||
}
|
||||
|
||||
// Validate makes the form validatable by implementing [validation.Validatable] interface.
|
||||
func (form *UserEmailChangeConfirm) Validate() error {
|
||||
return validation.ValidateStruct(form,
|
||||
validation.Field(
|
||||
&form.Token,
|
||||
validation.Required,
|
||||
validation.By(form.checkToken),
|
||||
),
|
||||
validation.Field(
|
||||
&form.Password,
|
||||
validation.Required,
|
||||
validation.Length(1, 100),
|
||||
validation.By(form.checkPassword),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
func (form *UserEmailChangeConfirm) checkToken(value any) error {
|
||||
v, _ := value.(string)
|
||||
if v == "" {
|
||||
return nil // nothing to check
|
||||
}
|
||||
|
||||
_, _, err := form.parseToken(v)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func (form *UserEmailChangeConfirm) checkPassword(value any) error {
|
||||
v, _ := value.(string)
|
||||
if v == "" {
|
||||
return nil // nothing to check
|
||||
}
|
||||
|
||||
user, _, _ := form.parseToken(form.Token)
|
||||
if user == nil || !user.ValidatePassword(v) {
|
||||
return validation.NewError("validation_invalid_password", "Missing or invalid user password.")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (form *UserEmailChangeConfirm) parseToken(token string) (*models.User, string, error) {
|
||||
// check token payload
|
||||
claims, _ := security.ParseUnverifiedJWT(token)
|
||||
newEmail, _ := claims["newEmail"].(string)
|
||||
if newEmail == "" {
|
||||
return nil, "", validation.NewError("validation_invalid_token_payload", "Invalid token payload - newEmail must be set.")
|
||||
}
|
||||
|
||||
// ensure that there aren't other users with the new email
|
||||
if !form.config.Dao.IsUserEmailUnique(newEmail, "") {
|
||||
return nil, "", validation.NewError("validation_existing_token_email", "The new email address is already registered: "+newEmail)
|
||||
}
|
||||
|
||||
// verify that the token is not expired and its signature is valid
|
||||
user, err := form.config.Dao.FindUserByToken(
|
||||
token,
|
||||
form.config.App.Settings().UserEmailChangeToken.Secret,
|
||||
)
|
||||
if err != nil || user == nil {
|
||||
return nil, "", validation.NewError("validation_invalid_token", "Invalid or expired token.")
|
||||
}
|
||||
|
||||
return user, newEmail, nil
|
||||
}
|
||||
|
||||
// Submit validates and submits the user email change confirmation form.
|
||||
// On success returns the updated user model associated to `form.Token`.
|
||||
func (form *UserEmailChangeConfirm) Submit() (*models.User, error) {
|
||||
if err := form.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
user, newEmail, err := form.parseToken(form.Token)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
user.Email = newEmail
|
||||
user.Verified = true
|
||||
user.RefreshTokenKey() // invalidate old tokens
|
||||
|
||||
if err := form.config.Dao.SaveUser(user); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
@ -1,131 +0,0 @@
|
||||
package forms_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
"github.com/pocketbase/pocketbase/forms"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
)
|
||||
|
||||
func TestUserEmailChangeConfirmPanic(t *testing.T) {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("The form did not panic")
|
||||
}
|
||||
}()
|
||||
|
||||
forms.NewUserEmailChangeConfirm(nil)
|
||||
}
|
||||
|
||||
func TestUserEmailChangeConfirmValidateAndSubmit(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
scenarios := []struct {
|
||||
jsonData string
|
||||
expectedErrors []string
|
||||
}{
|
||||
// empty payload
|
||||
{"{}", []string{"token", "password"}},
|
||||
// empty data
|
||||
{
|
||||
`{"token": "", "password": ""}`,
|
||||
[]string{"token", "password"},
|
||||
},
|
||||
// invalid token payload
|
||||
{
|
||||
`{
|
||||
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZW1haWwiOiJ0ZXN0QGV4YW1wbGUuY29tIiwiZXhwIjoxODYxOTE2NDYxfQ.VjT3wc3IES--1Vye-1KRuk8RpO5mfdhVp2aKGbNluZ0",
|
||||
"password": "123456"
|
||||
}`,
|
||||
[]string{"token", "password"},
|
||||
},
|
||||
// expired token
|
||||
{
|
||||
`{
|
||||
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZW1haWwiOiJ0ZXN0QGV4YW1wbGUuY29tIiwibmV3RW1haWwiOiJ0ZXN0X25ld0BleGFtcGxlLmNvbSIsImV4cCI6MTY0MDk5MTY2MX0.oPxbpJjcBpdZVBFbIW35FEXTCMkzJ7-RmQdHrz7zP3s",
|
||||
"password": "123456"
|
||||
}`,
|
||||
[]string{"token", "password"},
|
||||
},
|
||||
// existing new email
|
||||
{
|
||||
`{
|
||||
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZW1haWwiOiJ0ZXN0QGV4YW1wbGUuY29tIiwibmV3RW1haWwiOiJ0ZXN0MkBleGFtcGxlLmNvbSIsImV4cCI6MTg2MTkxNjQ2MX0.RwHRZma5YpCwxHdj3y2obeBNy_GQrG6lT9CQHIUz6Ys",
|
||||
"password": "123456"
|
||||
}`,
|
||||
[]string{"token", "password"},
|
||||
},
|
||||
// wrong confirmation password
|
||||
{
|
||||
`{
|
||||
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZW1haWwiOiJ0ZXN0QGV4YW1wbGUuY29tIiwibmV3RW1haWwiOiJ0ZXN0X25ld0BleGFtcGxlLmNvbSIsImV4cCI6MTg2MTkxNjQ2MX0.nS2qDonX25tOf9-6bKCwJXOm1CE88z_EVAA2B72NYM0",
|
||||
"password": "1234"
|
||||
}`,
|
||||
[]string{"password"},
|
||||
},
|
||||
// valid data
|
||||
{
|
||||
`{
|
||||
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZW1haWwiOiJ0ZXN0QGV4YW1wbGUuY29tIiwibmV3RW1haWwiOiJ0ZXN0X25ld0BleGFtcGxlLmNvbSIsImV4cCI6MTg2MTkxNjQ2MX0.nS2qDonX25tOf9-6bKCwJXOm1CE88z_EVAA2B72NYM0",
|
||||
"password": "123456"
|
||||
}`,
|
||||
[]string{},
|
||||
},
|
||||
}
|
||||
|
||||
for i, s := range scenarios {
|
||||
form := forms.NewUserEmailChangeConfirm(app)
|
||||
|
||||
// load data
|
||||
loadErr := json.Unmarshal([]byte(s.jsonData), form)
|
||||
if loadErr != nil {
|
||||
t.Errorf("(%d) Failed to load form data: %v", i, loadErr)
|
||||
continue
|
||||
}
|
||||
|
||||
user, err := form.Submit()
|
||||
|
||||
// parse errors
|
||||
errs, ok := err.(validation.Errors)
|
||||
if !ok && err != nil {
|
||||
t.Errorf("(%d) Failed to parse errors %v", i, err)
|
||||
continue
|
||||
}
|
||||
|
||||
// check errors
|
||||
if len(errs) > len(s.expectedErrors) {
|
||||
t.Errorf("(%d) Expected error keys %v, got %v", i, s.expectedErrors, errs)
|
||||
}
|
||||
for _, k := range s.expectedErrors {
|
||||
if _, ok := errs[k]; !ok {
|
||||
t.Errorf("(%d) Missing expected error key %q in %v", i, k, errs)
|
||||
}
|
||||
}
|
||||
|
||||
if len(s.expectedErrors) > 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
claims, _ := security.ParseUnverifiedJWT(form.Token)
|
||||
newEmail, _ := claims["newEmail"].(string)
|
||||
|
||||
// check whether the user was updated
|
||||
// ---
|
||||
if user.Email != newEmail {
|
||||
t.Errorf("(%d) Expected user email %q, got %q", i, newEmail, user.Email)
|
||||
}
|
||||
|
||||
if !user.Verified {
|
||||
t.Errorf("(%d) Expected user to be verified, got false", i)
|
||||
}
|
||||
|
||||
// shouldn't validate second time due to refreshed user token
|
||||
if err := form.Validate(); err == nil {
|
||||
t.Errorf("(%d) Expected error, got nil", i)
|
||||
}
|
||||
}
|
||||
}
|
@ -1,88 +0,0 @@
|
||||
package forms
|
||||
|
||||
import (
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
"github.com/go-ozzo/ozzo-validation/v4/is"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/daos"
|
||||
"github.com/pocketbase/pocketbase/mails"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
)
|
||||
|
||||
// UserEmailChangeRequest defines a user email change request form.
|
||||
type UserEmailChangeRequest struct {
|
||||
config UserEmailChangeRequestConfig
|
||||
user *models.User
|
||||
|
||||
NewEmail string `form:"newEmail" json:"newEmail"`
|
||||
}
|
||||
|
||||
// UserEmailChangeRequestConfig is the [UserEmailChangeRequest] factory initializer config.
|
||||
//
|
||||
// NB! App is required struct member.
|
||||
type UserEmailChangeRequestConfig struct {
|
||||
App core.App
|
||||
Dao *daos.Dao
|
||||
}
|
||||
|
||||
// NewUserEmailChangeRequest creates a new [UserEmailChangeRequest]
|
||||
// form with initializer config created from the provided [core.App] instance.
|
||||
//
|
||||
// If you want to submit the form as part of another transaction, use
|
||||
// [NewUserEmailChangeConfirmWithConfig] with explicitly set Dao.
|
||||
func NewUserEmailChangeRequest(app core.App, user *models.User) *UserEmailChangeRequest {
|
||||
return NewUserEmailChangeRequestWithConfig(UserEmailChangeRequestConfig{
|
||||
App: app,
|
||||
}, user)
|
||||
}
|
||||
|
||||
// NewUserEmailChangeRequestWithConfig creates a new [UserEmailChangeRequest]
|
||||
// form with the provided config or panics on invalid configuration.
|
||||
func NewUserEmailChangeRequestWithConfig(config UserEmailChangeRequestConfig, user *models.User) *UserEmailChangeRequest {
|
||||
form := &UserEmailChangeRequest{
|
||||
config: config,
|
||||
user: user,
|
||||
}
|
||||
|
||||
if form.config.App == nil || form.user == nil {
|
||||
panic("Invalid initializer config or nil user model.")
|
||||
}
|
||||
|
||||
if form.config.Dao == nil {
|
||||
form.config.Dao = form.config.App.Dao()
|
||||
}
|
||||
|
||||
return form
|
||||
}
|
||||
|
||||
// Validate makes the form validatable by implementing [validation.Validatable] interface.
|
||||
func (form *UserEmailChangeRequest) Validate() error {
|
||||
return validation.ValidateStruct(form,
|
||||
validation.Field(
|
||||
&form.NewEmail,
|
||||
validation.Required,
|
||||
validation.Length(1, 255),
|
||||
is.EmailFormat,
|
||||
validation.By(form.checkUniqueEmail),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
func (form *UserEmailChangeRequest) checkUniqueEmail(value any) error {
|
||||
v, _ := value.(string)
|
||||
|
||||
if !form.config.Dao.IsUserEmailUnique(v, "") {
|
||||
return validation.NewError("validation_user_email_exists", "User email already exists.")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Submit validates and sends the change email request.
|
||||
func (form *UserEmailChangeRequest) Submit() error {
|
||||
if err := form.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return mails.SendUserChangeEmail(form.config.App, form.user, form.NewEmail)
|
||||
}
|
@ -1,80 +0,0 @@
|
||||
package forms
|
||||
|
||||
import (
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
"github.com/go-ozzo/ozzo-validation/v4/is"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/daos"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
)
|
||||
|
||||
// UserEmailLogin specifies a user email/pass login form.
|
||||
type UserEmailLogin struct {
|
||||
config UserEmailLoginConfig
|
||||
|
||||
Email string `form:"email" json:"email"`
|
||||
Password string `form:"password" json:"password"`
|
||||
}
|
||||
|
||||
// UserEmailLoginConfig is the [UserEmailLogin] factory initializer config.
|
||||
//
|
||||
// NB! App is required struct member.
|
||||
type UserEmailLoginConfig struct {
|
||||
App core.App
|
||||
Dao *daos.Dao
|
||||
}
|
||||
|
||||
// NewUserEmailLogin creates a new [UserEmailLogin] form with
|
||||
// initializer config created from the provided [core.App] instance.
|
||||
//
|
||||
// This factory method is used primarily for convenience (and backward compatibility).
|
||||
// If you want to submit the form as part of another transaction, use
|
||||
// [NewUserEmailLoginWithConfig] with explicitly set Dao.
|
||||
func NewUserEmailLogin(app core.App) *UserEmailLogin {
|
||||
return NewUserEmailLoginWithConfig(UserEmailLoginConfig{
|
||||
App: app,
|
||||
})
|
||||
}
|
||||
|
||||
// NewUserEmailLoginWithConfig creates a new [UserEmailLogin]
|
||||
// form with the provided config or panics on invalid configuration.
|
||||
func NewUserEmailLoginWithConfig(config UserEmailLoginConfig) *UserEmailLogin {
|
||||
form := &UserEmailLogin{config: config}
|
||||
|
||||
if form.config.App == nil {
|
||||
panic("Missing required config.App instance.")
|
||||
}
|
||||
|
||||
if form.config.Dao == nil {
|
||||
form.config.Dao = form.config.App.Dao()
|
||||
}
|
||||
|
||||
return form
|
||||
}
|
||||
|
||||
// Validate makes the form validatable by implementing [validation.Validatable] interface.
|
||||
func (form *UserEmailLogin) Validate() error {
|
||||
return validation.ValidateStruct(form,
|
||||
validation.Field(&form.Email, validation.Required, validation.Length(1, 255), is.EmailFormat),
|
||||
validation.Field(&form.Password, validation.Required, validation.Length(1, 255)),
|
||||
)
|
||||
}
|
||||
|
||||
// Submit validates and submits the form.
|
||||
// On success returns the authorized user model.
|
||||
func (form *UserEmailLogin) Submit() (*models.User, error) {
|
||||
if err := form.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
user, err := form.config.Dao.FindUserByEmail(form.Email)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !user.ValidatePassword(form.Password) {
|
||||
return nil, validation.NewError("invalid_login", "Invalid login credentials.")
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
@ -1,116 +0,0 @@
|
||||
package forms_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
"github.com/pocketbase/pocketbase/forms"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
)
|
||||
|
||||
func TestUserEmailLoginPanic(t *testing.T) {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("The form did not panic")
|
||||
}
|
||||
}()
|
||||
|
||||
forms.NewUserEmailLogin(nil)
|
||||
}
|
||||
|
||||
func TestUserEmailLoginValidate(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
scenarios := []struct {
|
||||
jsonData string
|
||||
expectedErrors []string
|
||||
}{
|
||||
// empty payload
|
||||
{"{}", []string{"email", "password"}},
|
||||
// empty data
|
||||
{
|
||||
`{"email": "","password": ""}`,
|
||||
[]string{"email", "password"},
|
||||
},
|
||||
// invalid email
|
||||
{
|
||||
`{"email": "invalid","password": "123"}`,
|
||||
[]string{"email"},
|
||||
},
|
||||
// valid email
|
||||
{
|
||||
`{"email": "test@example.com","password": "123"}`,
|
||||
[]string{},
|
||||
},
|
||||
}
|
||||
|
||||
for i, s := range scenarios {
|
||||
form := forms.NewUserEmailLogin(app)
|
||||
|
||||
// load data
|
||||
loadErr := json.Unmarshal([]byte(s.jsonData), form)
|
||||
if loadErr != nil {
|
||||
t.Errorf("(%d) Failed to load form data: %v", i, loadErr)
|
||||
continue
|
||||
}
|
||||
|
||||
err := form.Validate()
|
||||
|
||||
// parse errors
|
||||
errs, ok := err.(validation.Errors)
|
||||
if !ok && err != nil {
|
||||
t.Errorf("(%d) Failed to parse errors %v", i, err)
|
||||
continue
|
||||
}
|
||||
|
||||
// check errors
|
||||
if len(errs) > len(s.expectedErrors) {
|
||||
t.Errorf("(%d) Expected error keys %v, got %v", i, s.expectedErrors, errs)
|
||||
}
|
||||
for _, k := range s.expectedErrors {
|
||||
if _, ok := errs[k]; !ok {
|
||||
t.Errorf("(%d) Missing expected error key %q in %v", i, k, errs)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserEmailLoginSubmit(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
scenarios := []struct {
|
||||
email string
|
||||
password string
|
||||
expectError bool
|
||||
}{
|
||||
// invalid email
|
||||
{"invalid", "123456", true},
|
||||
// missing user
|
||||
{"missing@example.com", "123456", true},
|
||||
// invalid password
|
||||
{"test@example.com", "123", true},
|
||||
// valid email and password
|
||||
{"test@example.com", "123456", false},
|
||||
}
|
||||
|
||||
for i, s := range scenarios {
|
||||
form := forms.NewUserEmailLogin(app)
|
||||
form.Email = s.email
|
||||
form.Password = s.password
|
||||
|
||||
user, err := form.Submit()
|
||||
|
||||
hasErr := err != nil
|
||||
if hasErr != s.expectError {
|
||||
t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, s.expectError, hasErr, err)
|
||||
continue
|
||||
}
|
||||
|
||||
if !s.expectError && user.Email != s.email {
|
||||
t.Errorf("(%d) Expected user with email %q, got %q", i, s.email, user.Email)
|
||||
}
|
||||
}
|
||||
}
|
@ -1,195 +0,0 @@
|
||||
package forms
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
"github.com/go-ozzo/ozzo-validation/v4/is"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/daos"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/tools/auth"
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
// UserOauth2Login specifies a user Oauth2 login form.
|
||||
type UserOauth2Login struct {
|
||||
config UserOauth2LoginConfig
|
||||
|
||||
// The name of the OAuth2 client provider (eg. "google")
|
||||
Provider string `form:"provider" json:"provider"`
|
||||
|
||||
// The authorization code returned from the initial request.
|
||||
Code string `form:"code" json:"code"`
|
||||
|
||||
// The code verifier sent with the initial request as part of the code_challenge.
|
||||
CodeVerifier string `form:"codeVerifier" json:"codeVerifier"`
|
||||
|
||||
// The redirect url sent with the initial request.
|
||||
RedirectUrl string `form:"redirectUrl" json:"redirectUrl"`
|
||||
}
|
||||
|
||||
// UserOauth2LoginConfig is the [UserOauth2Login] factory initializer config.
|
||||
//
|
||||
// NB! App is required struct member.
|
||||
type UserOauth2LoginConfig struct {
|
||||
App core.App
|
||||
Dao *daos.Dao
|
||||
}
|
||||
|
||||
// NewUserOauth2Login creates a new [UserOauth2Login] form with
|
||||
// initializer config created from the provided [core.App] instance.
|
||||
//
|
||||
// If you want to submit the form as part of another transaction, use
|
||||
// [NewUserOauth2LoginWithConfig] with explicitly set Dao.
|
||||
func NewUserOauth2Login(app core.App) *UserOauth2Login {
|
||||
return NewUserOauth2LoginWithConfig(UserOauth2LoginConfig{
|
||||
App: app,
|
||||
})
|
||||
}
|
||||
|
||||
// NewUserOauth2LoginWithConfig creates a new [UserOauth2Login]
|
||||
// form with the provided config or panics on invalid configuration.
|
||||
func NewUserOauth2LoginWithConfig(config UserOauth2LoginConfig) *UserOauth2Login {
|
||||
form := &UserOauth2Login{config: config}
|
||||
|
||||
if form.config.App == nil {
|
||||
panic("Missing required config.App instance.")
|
||||
}
|
||||
|
||||
if form.config.Dao == nil {
|
||||
form.config.Dao = form.config.App.Dao()
|
||||
}
|
||||
|
||||
return form
|
||||
}
|
||||
|
||||
// Validate makes the form validatable by implementing [validation.Validatable] interface.
|
||||
func (form *UserOauth2Login) Validate() error {
|
||||
return validation.ValidateStruct(form,
|
||||
validation.Field(&form.Provider, validation.Required, validation.By(form.checkProviderName)),
|
||||
validation.Field(&form.Code, validation.Required),
|
||||
validation.Field(&form.CodeVerifier, validation.Required),
|
||||
validation.Field(&form.RedirectUrl, validation.Required, is.URL),
|
||||
)
|
||||
}
|
||||
|
||||
func (form *UserOauth2Login) checkProviderName(value any) error {
|
||||
name, _ := value.(string)
|
||||
|
||||
config, ok := form.config.App.Settings().NamedAuthProviderConfigs()[name]
|
||||
if !ok || !config.Enabled {
|
||||
return validation.NewError("validation_invalid_provider", fmt.Sprintf("%q is missing or is not enabled.", name))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Submit validates and submits the form.
|
||||
// On success returns the authorized user model and the fetched provider's data.
|
||||
func (form *UserOauth2Login) Submit() (*models.User, *auth.AuthUser, error) {
|
||||
if err := form.Validate(); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
provider, err := auth.NewProviderByName(form.Provider)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
// load provider configuration
|
||||
config := form.config.App.Settings().NamedAuthProviderConfigs()[form.Provider]
|
||||
if err := config.SetupProvider(provider); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
provider.SetRedirectUrl(form.RedirectUrl)
|
||||
|
||||
// fetch token
|
||||
token, err := provider.FetchToken(
|
||||
form.Code,
|
||||
oauth2.SetAuthURLParam("code_verifier", form.CodeVerifier),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
// fetch external auth user
|
||||
authData, err := provider.FetchAuthUser(token)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
var user *models.User
|
||||
|
||||
// check for existing relation with the external auth user
|
||||
rel, _ := form.config.Dao.FindExternalAuthByProvider(form.Provider, authData.Id)
|
||||
if rel != nil {
|
||||
user, err = form.config.Dao.FindUserById(rel.UserId)
|
||||
if err != nil {
|
||||
return nil, authData, err
|
||||
}
|
||||
} else if authData.Email != "" {
|
||||
// look for an existing user by the external user's email
|
||||
user, _ = form.config.Dao.FindUserByEmail(authData.Email)
|
||||
}
|
||||
|
||||
if user == nil && !config.AllowRegistrations {
|
||||
return nil, authData, errors.New("New users registration is not allowed for the authorized provider.")
|
||||
}
|
||||
|
||||
saveErr := form.config.Dao.RunInTransaction(func(txDao *daos.Dao) error {
|
||||
if user == nil {
|
||||
user = &models.User{}
|
||||
user.Verified = true
|
||||
user.Email = authData.Email
|
||||
user.SetPassword(security.RandomString(30))
|
||||
|
||||
// create the new user
|
||||
if err := txDao.SaveUser(user); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
// update the existing user empty email if the authData has one
|
||||
// (this in case previously the user was created with
|
||||
// an OAuth2 provider that didn't return an email address)
|
||||
if user.Email == "" && authData.Email != "" {
|
||||
user.Email = authData.Email
|
||||
if err := txDao.SaveUser(user); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// update the existing user verified state
|
||||
// (only if the user doesn't have an email or the user email match with the one in authData)
|
||||
if !user.Verified && (user.Email == "" || user.Email == authData.Email) {
|
||||
user.Verified = true
|
||||
if err := txDao.SaveUser(user); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// create ExternalAuth relation if missing
|
||||
if rel == nil {
|
||||
rel = &models.ExternalAuth{
|
||||
UserId: user.Id,
|
||||
Provider: form.Provider,
|
||||
ProviderId: authData.Id,
|
||||
}
|
||||
if err := txDao.SaveExternalAuth(rel); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
if saveErr != nil {
|
||||
return nil, authData, saveErr
|
||||
}
|
||||
|
||||
return user, authData, nil
|
||||
}
|
@ -1,108 +0,0 @@
|
||||
package forms
|
||||
|
||||
import (
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/daos"
|
||||
"github.com/pocketbase/pocketbase/forms/validators"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
)
|
||||
|
||||
// UserPasswordResetConfirm specifies a user password reset confirmation form.
|
||||
type UserPasswordResetConfirm struct {
|
||||
config UserPasswordResetConfirmConfig
|
||||
|
||||
Token string `form:"token" json:"token"`
|
||||
Password string `form:"password" json:"password"`
|
||||
PasswordConfirm string `form:"passwordConfirm" json:"passwordConfirm"`
|
||||
}
|
||||
|
||||
// UserPasswordResetConfirmConfig is the [UserPasswordResetConfirm]
|
||||
// factory initializer config.
|
||||
//
|
||||
// NB! App is required struct member.
|
||||
type UserPasswordResetConfirmConfig struct {
|
||||
App core.App
|
||||
Dao *daos.Dao
|
||||
}
|
||||
|
||||
// NewUserPasswordResetConfirm creates a new [UserPasswordResetConfirm]
|
||||
// form with initializer config created from the provided [core.App] instance.
|
||||
//
|
||||
// If you want to submit the form as part of another transaction, use
|
||||
// [NewUserPasswordResetConfirmWithConfig] with explicitly set Dao.
|
||||
func NewUserPasswordResetConfirm(app core.App) *UserPasswordResetConfirm {
|
||||
return NewUserPasswordResetConfirmWithConfig(UserPasswordResetConfirmConfig{
|
||||
App: app,
|
||||
})
|
||||
}
|
||||
|
||||
// NewUserPasswordResetConfirmWithConfig creates a new [UserPasswordResetConfirm]
|
||||
// form with the provided config or panics on invalid configuration.
|
||||
func NewUserPasswordResetConfirmWithConfig(config UserPasswordResetConfirmConfig) *UserPasswordResetConfirm {
|
||||
form := &UserPasswordResetConfirm{config: config}
|
||||
|
||||
if form.config.App == nil {
|
||||
panic("Missing required config.App instance.")
|
||||
}
|
||||
|
||||
if form.config.Dao == nil {
|
||||
form.config.Dao = form.config.App.Dao()
|
||||
}
|
||||
|
||||
return form
|
||||
}
|
||||
|
||||
// Validate makes the form validatable by implementing [validation.Validatable] interface.
|
||||
func (form *UserPasswordResetConfirm) Validate() error {
|
||||
minPasswordLength := form.config.App.Settings().EmailAuth.MinPasswordLength
|
||||
|
||||
return validation.ValidateStruct(form,
|
||||
validation.Field(&form.Token, validation.Required, validation.By(form.checkToken)),
|
||||
validation.Field(&form.Password, validation.Required, validation.Length(minPasswordLength, 100)),
|
||||
validation.Field(&form.PasswordConfirm, validation.Required, validation.By(validators.Compare(form.Password))),
|
||||
)
|
||||
}
|
||||
|
||||
func (form *UserPasswordResetConfirm) checkToken(value any) error {
|
||||
v, _ := value.(string)
|
||||
if v == "" {
|
||||
return nil // nothing to check
|
||||
}
|
||||
|
||||
user, err := form.config.Dao.FindUserByToken(
|
||||
v,
|
||||
form.config.App.Settings().UserPasswordResetToken.Secret,
|
||||
)
|
||||
if err != nil || user == nil {
|
||||
return validation.NewError("validation_invalid_token", "Invalid or expired token.")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Submit validates and submits the form.
|
||||
// On success returns the updated user model associated to `form.Token`.
|
||||
func (form *UserPasswordResetConfirm) Submit() (*models.User, error) {
|
||||
if err := form.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
user, err := form.config.Dao.FindUserByToken(
|
||||
form.Token,
|
||||
form.config.App.Settings().UserPasswordResetToken.Secret,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := user.SetPassword(form.Password); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := form.config.Dao.SaveUser(user); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
@ -1,175 +0,0 @@
|
||||
package forms_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
"github.com/pocketbase/pocketbase/forms"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
)
|
||||
|
||||
func TestUserPasswordResetConfirmPanic(t *testing.T) {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("The form did not panic")
|
||||
}
|
||||
}()
|
||||
|
||||
forms.NewUserPasswordResetConfirm(nil)
|
||||
}
|
||||
|
||||
func TestUserPasswordResetConfirmValidate(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
scenarios := []struct {
|
||||
jsonData string
|
||||
expectedErrors []string
|
||||
}{
|
||||
// empty data
|
||||
{
|
||||
`{}`,
|
||||
[]string{"token", "password", "passwordConfirm"},
|
||||
},
|
||||
// empty fields
|
||||
{
|
||||
`{"token":"","password":"","passwordConfirm":""}`,
|
||||
[]string{"token", "password", "passwordConfirm"},
|
||||
},
|
||||
// invalid password length
|
||||
{
|
||||
`{"token":"invalid","password":"1234","passwordConfirm":"1234"}`,
|
||||
[]string{"token", "password"},
|
||||
},
|
||||
// mismatched passwords
|
||||
{
|
||||
`{"token":"invalid","password":"12345678","passwordConfirm":"87654321"}`,
|
||||
[]string{"token", "passwordConfirm"},
|
||||
},
|
||||
// invalid JWT token
|
||||
{
|
||||
`{"token":"invalid","password":"12345678","passwordConfirm":"12345678"}`,
|
||||
[]string{"token"},
|
||||
},
|
||||
// expired token
|
||||
{
|
||||
`{
|
||||
"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZW1haWwiOiJ0ZXN0QGV4YW1wbGUuY29tIiwiZXhwIjoxNjQwOTkxNjYxfQ.cSUFKWLAKEvulWV4fqPD6RRtkZYoyat_Tb8lrA2xqtw",
|
||||
"password":"12345678",
|
||||
"passwordConfirm":"12345678"
|
||||
}`,
|
||||
[]string{"token"},
|
||||
},
|
||||
// valid data
|
||||
{
|
||||
`{
|
||||
"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZW1haWwiOiJ0ZXN0QGV4YW1wbGUuY29tIiwiZXhwIjoxODkzNDUyNDYxfQ.YfpL4VOdsYh2gS30VIiPShgwwqPgt2CySD8TuuB1XD4",
|
||||
"password":"12345678",
|
||||
"passwordConfirm":"12345678"
|
||||
}`,
|
||||
[]string{},
|
||||
},
|
||||
}
|
||||
|
||||
for i, s := range scenarios {
|
||||
form := forms.NewUserPasswordResetConfirm(app)
|
||||
|
||||
// load data
|
||||
loadErr := json.Unmarshal([]byte(s.jsonData), form)
|
||||
if loadErr != nil {
|
||||
t.Errorf("(%d) Failed to load form data: %v", i, loadErr)
|
||||
continue
|
||||
}
|
||||
|
||||
// parse errors
|
||||
result := form.Validate()
|
||||
errs, ok := result.(validation.Errors)
|
||||
if !ok && result != nil {
|
||||
t.Errorf("(%d) Failed to parse errors %v", i, result)
|
||||
continue
|
||||
}
|
||||
|
||||
// check errors
|
||||
if len(errs) > len(s.expectedErrors) {
|
||||
t.Errorf("(%d) Expected error keys %v, got %v", i, s.expectedErrors, errs)
|
||||
}
|
||||
for _, k := range s.expectedErrors {
|
||||
if _, ok := errs[k]; !ok {
|
||||
t.Errorf("(%d) Missing expected error key %q in %v", i, k, errs)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserPasswordResetConfirmSubmit(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
scenarios := []struct {
|
||||
jsonData string
|
||||
expectError bool
|
||||
}{
|
||||
// empty data (Validate call check)
|
||||
{
|
||||
`{}`,
|
||||
true,
|
||||
},
|
||||
// expired token
|
||||
{
|
||||
`{
|
||||
"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZW1haWwiOiJ0ZXN0QGV4YW1wbGUuY29tIiwiZXhwIjoxNjQwOTkxNjYxfQ.cSUFKWLAKEvulWV4fqPD6RRtkZYoyat_Tb8lrA2xqtw",
|
||||
"password":"12345678",
|
||||
"passwordConfirm":"12345678"
|
||||
}`,
|
||||
true,
|
||||
},
|
||||
// valid data
|
||||
{
|
||||
`{
|
||||
"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZW1haWwiOiJ0ZXN0QGV4YW1wbGUuY29tIiwiZXhwIjoxODkzNDUyNDYxfQ.YfpL4VOdsYh2gS30VIiPShgwwqPgt2CySD8TuuB1XD4",
|
||||
"password":"12345678",
|
||||
"passwordConfirm":"12345678"
|
||||
}`,
|
||||
false,
|
||||
},
|
||||
}
|
||||
|
||||
for i, s := range scenarios {
|
||||
form := forms.NewUserPasswordResetConfirm(app)
|
||||
|
||||
// load data
|
||||
loadErr := json.Unmarshal([]byte(s.jsonData), form)
|
||||
if loadErr != nil {
|
||||
t.Errorf("(%d) Failed to load form data: %v", i, loadErr)
|
||||
continue
|
||||
}
|
||||
|
||||
user, err := form.Submit()
|
||||
|
||||
hasErr := err != nil
|
||||
if hasErr != s.expectError {
|
||||
t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, s.expectError, hasErr, err)
|
||||
}
|
||||
|
||||
if s.expectError {
|
||||
continue
|
||||
}
|
||||
|
||||
claims, _ := security.ParseUnverifiedJWT(form.Token)
|
||||
tokenUserId := claims["id"]
|
||||
|
||||
if user.Id != tokenUserId {
|
||||
t.Errorf("(%d) Expected user with id %s, got %v", i, tokenUserId, user)
|
||||
}
|
||||
|
||||
if !user.LastResetSentAt.IsZero() {
|
||||
t.Errorf("(%d) Expected user.LastResetSentAt to be empty, got %v", i, user.LastResetSentAt)
|
||||
}
|
||||
|
||||
if !user.ValidatePassword(form.Password) {
|
||||
t.Errorf("(%d) Expected the user password to have been updated to %q", i, form.Password)
|
||||
}
|
||||
}
|
||||
}
|
@ -1,100 +0,0 @@
|
||||
package forms
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
"github.com/go-ozzo/ozzo-validation/v4/is"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/daos"
|
||||
"github.com/pocketbase/pocketbase/mails"
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
)
|
||||
|
||||
// UserPasswordResetRequest specifies a user password reset request form.
|
||||
type UserPasswordResetRequest struct {
|
||||
config UserPasswordResetRequestConfig
|
||||
|
||||
Email string `form:"email" json:"email"`
|
||||
}
|
||||
|
||||
// UserPasswordResetRequestConfig is the [UserPasswordResetRequest]
|
||||
// factory initializer config.
|
||||
//
|
||||
// NB! App is required struct member.
|
||||
type UserPasswordResetRequestConfig struct {
|
||||
App core.App
|
||||
Dao *daos.Dao
|
||||
ResendThreshold float64 // in seconds
|
||||
}
|
||||
|
||||
// NewUserPasswordResetRequest creates a new [UserPasswordResetRequest]
|
||||
// form with initializer config created from the provided [core.App] instance.
|
||||
//
|
||||
// If you want to submit the form as part of another transaction, use
|
||||
// [NewUserPasswordResetRequestWithConfig] with explicitly set Dao.
|
||||
func NewUserPasswordResetRequest(app core.App) *UserPasswordResetRequest {
|
||||
return NewUserPasswordResetRequestWithConfig(UserPasswordResetRequestConfig{
|
||||
App: app,
|
||||
ResendThreshold: 120, // 2 min
|
||||
})
|
||||
}
|
||||
|
||||
// NewUserPasswordResetRequestWithConfig creates a new [UserPasswordResetRequest]
|
||||
// form with the provided config or panics on invalid configuration.
|
||||
func NewUserPasswordResetRequestWithConfig(config UserPasswordResetRequestConfig) *UserPasswordResetRequest {
|
||||
form := &UserPasswordResetRequest{config: config}
|
||||
|
||||
if form.config.App == nil {
|
||||
panic("Missing required config.App instance.")
|
||||
}
|
||||
|
||||
if form.config.Dao == nil {
|
||||
form.config.Dao = form.config.App.Dao()
|
||||
}
|
||||
|
||||
return form
|
||||
}
|
||||
|
||||
// Validate makes the form validatable by implementing [validation.Validatable] interface.
|
||||
//
|
||||
// This method doesn't checks whether user with `form.Email` exists (this is done on Submit).
|
||||
func (form *UserPasswordResetRequest) Validate() error {
|
||||
return validation.ValidateStruct(form,
|
||||
validation.Field(
|
||||
&form.Email,
|
||||
validation.Required,
|
||||
validation.Length(1, 255),
|
||||
is.EmailFormat,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// Submit validates and submits the form.
|
||||
// On success sends a password reset email to the `form.Email` user.
|
||||
func (form *UserPasswordResetRequest) Submit() error {
|
||||
if err := form.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
user, err := form.config.Dao.FindUserByEmail(form.Email)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
lastResetSentAt := user.LastResetSentAt.Time()
|
||||
if now.Sub(lastResetSentAt).Seconds() < form.config.ResendThreshold {
|
||||
return errors.New("You've already requested a password reset.")
|
||||
}
|
||||
|
||||
if err := mails.SendUserPasswordReset(form.config.App, user); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// update last sent timestamp
|
||||
user.LastResetSentAt = types.NowDateTime()
|
||||
|
||||
return form.config.Dao.SaveUser(user)
|
||||
}
|
@ -1,165 +0,0 @@
|
||||
package forms
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
"github.com/go-ozzo/ozzo-validation/v4/is"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/daos"
|
||||
"github.com/pocketbase/pocketbase/forms/validators"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/tools/list"
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
)
|
||||
|
||||
// UserUpsert specifies a [models.User] upsert (create/update) form.
|
||||
type UserUpsert struct {
|
||||
config UserUpsertConfig
|
||||
user *models.User
|
||||
|
||||
Id string `form:"id" json:"id"`
|
||||
Email string `form:"email" json:"email"`
|
||||
Password string `form:"password" json:"password"`
|
||||
PasswordConfirm string `form:"passwordConfirm" json:"passwordConfirm"`
|
||||
}
|
||||
|
||||
// UserUpsertConfig is the [UserUpsert] factory initializer config.
|
||||
//
|
||||
// NB! App is required struct member.
|
||||
type UserUpsertConfig struct {
|
||||
App core.App
|
||||
Dao *daos.Dao
|
||||
}
|
||||
|
||||
// NewUserUpsert creates a new [UserUpsert] form with initializer
|
||||
// config created from the provided [core.App] instance
|
||||
// (for create you could pass a pointer to an empty User - `&models.User{}`).
|
||||
//
|
||||
// If you want to submit the form as part of another transaction, use
|
||||
// [NewUserEmailChangeConfirmWithConfig] with explicitly set Dao.
|
||||
func NewUserUpsert(app core.App, user *models.User) *UserUpsert {
|
||||
return NewUserUpsertWithConfig(UserUpsertConfig{
|
||||
App: app,
|
||||
}, user)
|
||||
}
|
||||
|
||||
// NewUserUpsertWithConfig creates a new [UserUpsert] form with the provided
|
||||
// config and [models.User] instance or panics on invalid configuration
|
||||
// (for create you could pass a pointer to an empty User - `&models.User{}`).
|
||||
func NewUserUpsertWithConfig(config UserUpsertConfig, user *models.User) *UserUpsert {
|
||||
form := &UserUpsert{
|
||||
config: config,
|
||||
user: user,
|
||||
}
|
||||
|
||||
if form.config.App == nil || form.user == nil {
|
||||
panic("Invalid initializer config or nil upsert model.")
|
||||
}
|
||||
|
||||
if form.config.Dao == nil {
|
||||
form.config.Dao = form.config.App.Dao()
|
||||
}
|
||||
|
||||
// load defaults
|
||||
form.Id = user.Id
|
||||
form.Email = user.Email
|
||||
|
||||
return form
|
||||
}
|
||||
|
||||
// Validate makes the form validatable by implementing [validation.Validatable] interface.
|
||||
func (form *UserUpsert) Validate() error {
|
||||
return validation.ValidateStruct(form,
|
||||
validation.Field(
|
||||
&form.Id,
|
||||
validation.When(
|
||||
form.user.IsNew(),
|
||||
validation.Length(models.DefaultIdLength, models.DefaultIdLength),
|
||||
validation.Match(idRegex),
|
||||
).Else(validation.In(form.user.Id)),
|
||||
),
|
||||
validation.Field(
|
||||
&form.Email,
|
||||
validation.Required,
|
||||
validation.Length(1, 255),
|
||||
is.EmailFormat,
|
||||
validation.By(form.checkEmailDomain),
|
||||
validation.By(form.checkUniqueEmail),
|
||||
),
|
||||
validation.Field(
|
||||
&form.Password,
|
||||
validation.When(form.user.IsNew(), validation.Required),
|
||||
validation.Length(form.config.App.Settings().EmailAuth.MinPasswordLength, 100),
|
||||
),
|
||||
validation.Field(
|
||||
&form.PasswordConfirm,
|
||||
validation.When(form.user.IsNew() || form.Password != "", validation.Required),
|
||||
validation.By(validators.Compare(form.Password)),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
func (form *UserUpsert) checkUniqueEmail(value any) error {
|
||||
v, _ := value.(string)
|
||||
|
||||
if v == "" || form.config.Dao.IsUserEmailUnique(v, form.user.Id) {
|
||||
return nil
|
||||
}
|
||||
|
||||
return validation.NewError("validation_user_email_exists", "User email already exists.")
|
||||
}
|
||||
|
||||
func (form *UserUpsert) checkEmailDomain(value any) error {
|
||||
val, _ := value.(string)
|
||||
if val == "" {
|
||||
return nil // nothing to check
|
||||
}
|
||||
|
||||
domain := val[strings.LastIndex(val, "@")+1:]
|
||||
only := form.config.App.Settings().EmailAuth.OnlyDomains
|
||||
except := form.config.App.Settings().EmailAuth.ExceptDomains
|
||||
|
||||
// only domains check
|
||||
if len(only) > 0 && !list.ExistInSlice(domain, only) {
|
||||
return validation.NewError("validation_email_domain_not_allowed", "Email domain is not allowed.")
|
||||
}
|
||||
|
||||
// except domains check
|
||||
if len(except) > 0 && list.ExistInSlice(domain, except) {
|
||||
return validation.NewError("validation_email_domain_not_allowed", "Email domain is not allowed.")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Submit validates the form and upserts the form user model.
|
||||
//
|
||||
// You can optionally provide a list of InterceptorFunc to further
|
||||
// modify the form behavior before persisting it.
|
||||
func (form *UserUpsert) Submit(interceptors ...InterceptorFunc) error {
|
||||
if err := form.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if form.Password != "" {
|
||||
form.user.SetPassword(form.Password)
|
||||
}
|
||||
|
||||
// custom insertion id can be set only on create
|
||||
if form.user.IsNew() && form.Id != "" {
|
||||
form.user.MarkAsNew()
|
||||
form.user.SetId(form.Id)
|
||||
}
|
||||
|
||||
if !form.user.IsNew() && form.Email != form.user.Email {
|
||||
form.user.Verified = false
|
||||
form.user.LastVerificationSentAt = types.DateTime{} // reset
|
||||
}
|
||||
|
||||
form.user.Email = form.Email
|
||||
|
||||
return runInterceptors(func() error {
|
||||
return form.config.Dao.SaveUser(form.user)
|
||||
}, interceptors...)
|
||||
}
|
@ -1,432 +0,0 @@
|
||||
package forms_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
"github.com/pocketbase/pocketbase/forms"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
)
|
||||
|
||||
func TestUserUpsertPanic1(t *testing.T) {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("The form did not panic")
|
||||
}
|
||||
}()
|
||||
|
||||
forms.NewUserUpsert(nil, nil)
|
||||
}
|
||||
|
||||
func TestUserUpsertPanic2(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("The form did not panic")
|
||||
}
|
||||
}()
|
||||
|
||||
forms.NewUserUpsert(app, nil)
|
||||
}
|
||||
|
||||
func TestNewUserUpsert(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
user := &models.User{}
|
||||
user.Email = "new@example.com"
|
||||
|
||||
form := forms.NewUserUpsert(app, user)
|
||||
|
||||
// check defaults loading
|
||||
if form.Email != user.Email {
|
||||
t.Fatalf("Expected email %q, got %q", user.Email, form.Email)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserUpsertValidate(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
// mock app constraints
|
||||
app.Settings().EmailAuth.MinPasswordLength = 5
|
||||
app.Settings().EmailAuth.ExceptDomains = []string{"test.com"}
|
||||
app.Settings().EmailAuth.OnlyDomains = []string{"example.com", "test.com"}
|
||||
|
||||
scenarios := []struct {
|
||||
id string
|
||||
jsonData string
|
||||
expectedErrors []string
|
||||
}{
|
||||
// empty data - create
|
||||
{
|
||||
"",
|
||||
`{}`,
|
||||
[]string{"email", "password", "passwordConfirm"},
|
||||
},
|
||||
// empty data - update
|
||||
{
|
||||
"4d0197cc-2b4a-3f83-a26b-d77bc8423d3c",
|
||||
`{}`,
|
||||
[]string{},
|
||||
},
|
||||
// invalid email address
|
||||
{
|
||||
"4d0197cc-2b4a-3f83-a26b-d77bc8423d3c",
|
||||
`{"email":"invalid"}`,
|
||||
[]string{"email"},
|
||||
},
|
||||
// unique email constraint check (same email, aka. no changes)
|
||||
{
|
||||
"4d0197cc-2b4a-3f83-a26b-d77bc8423d3c",
|
||||
`{"email":"test@example.com"}`,
|
||||
[]string{},
|
||||
},
|
||||
// unique email constraint check (existing email)
|
||||
{
|
||||
"4d0197cc-2b4a-3f83-a26b-d77bc8423d3c",
|
||||
`{"email":"test2@something.com"}`,
|
||||
[]string{"email"},
|
||||
},
|
||||
// unique email constraint check (new email)
|
||||
{
|
||||
"4d0197cc-2b4a-3f83-a26b-d77bc8423d3c",
|
||||
`{"email":"new@example.com"}`,
|
||||
[]string{},
|
||||
},
|
||||
// EmailAuth.OnlyDomains constraints check
|
||||
{
|
||||
"4d0197cc-2b4a-3f83-a26b-d77bc8423d3c",
|
||||
`{"email":"test@something.com"}`,
|
||||
[]string{"email"},
|
||||
},
|
||||
// EmailAuth.ExceptDomains constraints check
|
||||
{
|
||||
"4d0197cc-2b4a-3f83-a26b-d77bc8423d3c",
|
||||
`{"email":"test@test.com"}`,
|
||||
[]string{"email"},
|
||||
},
|
||||
// password length constraint check
|
||||
{
|
||||
"4d0197cc-2b4a-3f83-a26b-d77bc8423d3c",
|
||||
`{"password":"1234", "passwordConfirm": "1234"}`,
|
||||
[]string{"password"},
|
||||
},
|
||||
// passwords mismatch
|
||||
{
|
||||
"4d0197cc-2b4a-3f83-a26b-d77bc8423d3c",
|
||||
`{"password":"12345", "passwordConfirm": "54321"}`,
|
||||
[]string{"passwordConfirm"},
|
||||
},
|
||||
// valid data - all fields
|
||||
{
|
||||
"",
|
||||
`{"email":"new@example.com","password":"12345","passwordConfirm":"12345"}`,
|
||||
[]string{},
|
||||
},
|
||||
}
|
||||
|
||||
for i, s := range scenarios {
|
||||
user := &models.User{}
|
||||
if s.id != "" {
|
||||
user, _ = app.Dao().FindUserById(s.id)
|
||||
}
|
||||
|
||||
form := forms.NewUserUpsert(app, user)
|
||||
|
||||
// load data
|
||||
loadErr := json.Unmarshal([]byte(s.jsonData), form)
|
||||
if loadErr != nil {
|
||||
t.Errorf("(%d) Failed to load form data: %v", i, loadErr)
|
||||
continue
|
||||
}
|
||||
|
||||
// parse errors
|
||||
result := form.Validate()
|
||||
errs, ok := result.(validation.Errors)
|
||||
if !ok && result != nil {
|
||||
t.Errorf("(%d) Failed to parse errors %v", i, result)
|
||||
continue
|
||||
}
|
||||
|
||||
// check errors
|
||||
if len(errs) > len(s.expectedErrors) {
|
||||
t.Errorf("(%d) Expected error keys %v, got %v", i, s.expectedErrors, errs)
|
||||
}
|
||||
for _, k := range s.expectedErrors {
|
||||
if _, ok := errs[k]; !ok {
|
||||
t.Errorf("(%d) Missing expected error key %q in %v", i, k, errs)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserUpsertSubmit(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
scenarios := []struct {
|
||||
id string
|
||||
jsonData string
|
||||
expectError bool
|
||||
}{
|
||||
// empty fields - create (Validate call check)
|
||||
{
|
||||
"",
|
||||
`{}`,
|
||||
true,
|
||||
},
|
||||
// empty fields - update (Validate call check)
|
||||
{
|
||||
"4d0197cc-2b4a-3f83-a26b-d77bc8423d3c",
|
||||
`{}`,
|
||||
false,
|
||||
},
|
||||
// updating with existing user email
|
||||
{
|
||||
"4d0197cc-2b4a-3f83-a26b-d77bc8423d3c",
|
||||
`{"email":"test2@example.com"}`,
|
||||
true,
|
||||
},
|
||||
// updating with nonexisting user email
|
||||
{
|
||||
"4d0197cc-2b4a-3f83-a26b-d77bc8423d3c",
|
||||
`{"email":"update_new@example.com"}`,
|
||||
false,
|
||||
},
|
||||
// changing password
|
||||
{
|
||||
"4d0197cc-2b4a-3f83-a26b-d77bc8423d3c",
|
||||
`{"password":"123456789","passwordConfirm":"123456789"}`,
|
||||
false,
|
||||
},
|
||||
// creating user (existing email)
|
||||
{
|
||||
"",
|
||||
`{"email":"test3@example.com","password":"123456789","passwordConfirm":"123456789"}`,
|
||||
true,
|
||||
},
|
||||
// creating user (new email)
|
||||
{
|
||||
"",
|
||||
`{"email":"create_new@example.com","password":"123456789","passwordConfirm":"123456789"}`,
|
||||
false,
|
||||
},
|
||||
}
|
||||
|
||||
for i, s := range scenarios {
|
||||
user := &models.User{}
|
||||
originalUser := &models.User{}
|
||||
if s.id != "" {
|
||||
user, _ = app.Dao().FindUserById(s.id)
|
||||
originalUser, _ = app.Dao().FindUserById(s.id)
|
||||
}
|
||||
|
||||
form := forms.NewUserUpsert(app, user)
|
||||
|
||||
// load data
|
||||
loadErr := json.Unmarshal([]byte(s.jsonData), form)
|
||||
if loadErr != nil {
|
||||
t.Errorf("(%d) Failed to load form data: %v", i, loadErr)
|
||||
continue
|
||||
}
|
||||
|
||||
interceptorCalls := 0
|
||||
|
||||
err := form.Submit(func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
|
||||
return func() error {
|
||||
interceptorCalls++
|
||||
return next()
|
||||
}
|
||||
})
|
||||
|
||||
hasErr := err != nil
|
||||
if hasErr != s.expectError {
|
||||
t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, s.expectError, hasErr, err)
|
||||
}
|
||||
|
||||
expectInterceptorCall := 1
|
||||
if s.expectError {
|
||||
expectInterceptorCall = 0
|
||||
}
|
||||
if interceptorCalls != expectInterceptorCall {
|
||||
t.Errorf("(%d) Expected interceptor to be called %d, got %d", i, expectInterceptorCall, interceptorCalls)
|
||||
}
|
||||
|
||||
if s.expectError {
|
||||
continue
|
||||
}
|
||||
|
||||
if user.Email != form.Email {
|
||||
t.Errorf("(%d) Expected email %q, got %q", i, form.Email, user.Email)
|
||||
}
|
||||
|
||||
// on email change Verified should reset
|
||||
if user.Email != originalUser.Email && user.Verified {
|
||||
t.Errorf("(%d) Expected Verified to be false, got true", i)
|
||||
}
|
||||
|
||||
if form.Password != "" && !user.ValidatePassword(form.Password) {
|
||||
t.Errorf("(%d) Expected password to be updated to %q", i, form.Password)
|
||||
}
|
||||
if form.Password != "" && originalUser.TokenKey == user.TokenKey {
|
||||
t.Errorf("(%d) Expected TokenKey to change, got %q", i, user.TokenKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserUpsertSubmitInterceptors(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
user := &models.User{}
|
||||
form := forms.NewUserUpsert(app, user)
|
||||
form.Email = "test_new@example.com"
|
||||
form.Password = "1234567890"
|
||||
form.PasswordConfirm = form.Password
|
||||
|
||||
testErr := errors.New("test_error")
|
||||
interceptorUserEmail := ""
|
||||
|
||||
interceptor1Called := false
|
||||
interceptor1 := func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
|
||||
return func() error {
|
||||
interceptor1Called = true
|
||||
return next()
|
||||
}
|
||||
}
|
||||
|
||||
interceptor2Called := false
|
||||
interceptor2 := func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
|
||||
return func() error {
|
||||
interceptorUserEmail = user.Email // to check if the record was filled
|
||||
interceptor2Called = true
|
||||
return testErr
|
||||
}
|
||||
}
|
||||
|
||||
err := form.Submit(interceptor1, interceptor2)
|
||||
if err != testErr {
|
||||
t.Fatalf("Expected error %v, got %v", testErr, err)
|
||||
}
|
||||
|
||||
if !interceptor1Called {
|
||||
t.Fatalf("Expected interceptor1 to be called")
|
||||
}
|
||||
|
||||
if !interceptor2Called {
|
||||
t.Fatalf("Expected interceptor2 to be called")
|
||||
}
|
||||
|
||||
if interceptorUserEmail != form.Email {
|
||||
t.Fatalf("Expected the form model to be filled before calling the interceptors")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserUpsertWithCustomId(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
existingUser, err := app.Dao().FindUserByEmail("test@example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
scenarios := []struct {
|
||||
name string
|
||||
jsonData string
|
||||
collection *models.User
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
"empty data",
|
||||
"{}",
|
||||
&models.User{},
|
||||
false,
|
||||
},
|
||||
{
|
||||
"empty id",
|
||||
`{"id":""}`,
|
||||
&models.User{},
|
||||
false,
|
||||
},
|
||||
{
|
||||
"id < 15 chars",
|
||||
`{"id":"a23"}`,
|
||||
&models.User{},
|
||||
true,
|
||||
},
|
||||
{
|
||||
"id > 15 chars",
|
||||
`{"id":"a234567890123456"}`,
|
||||
&models.User{},
|
||||
true,
|
||||
},
|
||||
{
|
||||
"id = 15 chars (invalid chars)",
|
||||
`{"id":"a@3456789012345"}`,
|
||||
&models.User{},
|
||||
true,
|
||||
},
|
||||
{
|
||||
"id = 15 chars (valid chars)",
|
||||
`{"id":"a23456789012345"}`,
|
||||
&models.User{},
|
||||
false,
|
||||
},
|
||||
{
|
||||
"changing the id of an existing item",
|
||||
`{"id":"b23456789012345"}`,
|
||||
existingUser,
|
||||
true,
|
||||
},
|
||||
{
|
||||
"using the same existing item id",
|
||||
`{"id":"` + existingUser.Id + `"}`,
|
||||
existingUser,
|
||||
false,
|
||||
},
|
||||
{
|
||||
"skipping the id for existing item",
|
||||
`{}`,
|
||||
existingUser,
|
||||
false,
|
||||
},
|
||||
}
|
||||
|
||||
for i, scenario := range scenarios {
|
||||
form := forms.NewUserUpsert(app, scenario.collection)
|
||||
if form.Email == "" {
|
||||
form.Email = fmt.Sprintf("test_id_%d@example.com", i)
|
||||
}
|
||||
form.Password = "1234567890"
|
||||
form.PasswordConfirm = form.Password
|
||||
|
||||
// load data
|
||||
loadErr := json.Unmarshal([]byte(scenario.jsonData), form)
|
||||
if loadErr != nil {
|
||||
t.Errorf("[%s] Failed to load form data: %v", scenario.name, loadErr)
|
||||
continue
|
||||
}
|
||||
|
||||
submitErr := form.Submit()
|
||||
hasErr := submitErr != nil
|
||||
|
||||
if hasErr != scenario.expectError {
|
||||
t.Errorf("[%s] Expected hasErr to be %v, got %v (%v)", scenario.name, scenario.expectError, hasErr, submitErr)
|
||||
}
|
||||
|
||||
if !hasErr && form.Id != "" {
|
||||
_, err := app.Dao().FindUserById(form.Id)
|
||||
if err != nil {
|
||||
t.Errorf("[%s] Expected to find record with id %s, got %v", scenario.name, form.Id, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -1,103 +0,0 @@
|
||||
package forms
|
||||
|
||||
import (
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/daos"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
)
|
||||
|
||||
// UserVerificationConfirm specifies a user email verification confirmation form.
|
||||
type UserVerificationConfirm struct {
|
||||
config UserVerificationConfirmConfig
|
||||
|
||||
Token string `form:"token" json:"token"`
|
||||
}
|
||||
|
||||
// UserVerificationConfirmConfig is the [UserVerificationConfirm]
|
||||
// factory initializer config.
|
||||
//
|
||||
// NB! App is required struct member.
|
||||
type UserVerificationConfirmConfig struct {
|
||||
App core.App
|
||||
Dao *daos.Dao
|
||||
}
|
||||
|
||||
// NewUserVerificationConfirm creates a new [UserVerificationConfirm]
|
||||
// form with initializer config created from the provided [core.App] instance.
|
||||
//
|
||||
// If you want to submit the form as part of another transaction, use
|
||||
// [NewUserVerificationConfirmWithConfig] with explicitly set Dao.
|
||||
func NewUserVerificationConfirm(app core.App) *UserVerificationConfirm {
|
||||
return NewUserVerificationConfirmWithConfig(UserVerificationConfirmConfig{
|
||||
App: app,
|
||||
})
|
||||
}
|
||||
|
||||
// NewUserVerificationConfirmWithConfig creates a new [UserVerificationConfirmConfig]
|
||||
// form with the provided config or panics on invalid configuration.
|
||||
func NewUserVerificationConfirmWithConfig(config UserVerificationConfirmConfig) *UserVerificationConfirm {
|
||||
form := &UserVerificationConfirm{config: config}
|
||||
|
||||
if form.config.App == nil {
|
||||
panic("Missing required config.App instance.")
|
||||
}
|
||||
|
||||
if form.config.Dao == nil {
|
||||
form.config.Dao = form.config.App.Dao()
|
||||
}
|
||||
|
||||
return form
|
||||
}
|
||||
|
||||
// Validate makes the form validatable by implementing [validation.Validatable] interface.
|
||||
func (form *UserVerificationConfirm) Validate() error {
|
||||
return validation.ValidateStruct(form,
|
||||
validation.Field(&form.Token, validation.Required, validation.By(form.checkToken)),
|
||||
)
|
||||
}
|
||||
|
||||
func (form *UserVerificationConfirm) checkToken(value any) error {
|
||||
v, _ := value.(string)
|
||||
if v == "" {
|
||||
return nil // nothing to check
|
||||
}
|
||||
|
||||
user, err := form.config.Dao.FindUserByToken(
|
||||
v,
|
||||
form.config.App.Settings().UserVerificationToken.Secret,
|
||||
)
|
||||
if err != nil || user == nil {
|
||||
return validation.NewError("validation_invalid_token", "Invalid or expired token.")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Submit validates and submits the form.
|
||||
// On success returns the verified user model associated to `form.Token`.
|
||||
func (form *UserVerificationConfirm) Submit() (*models.User, error) {
|
||||
if err := form.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
user, err := form.config.Dao.FindUserByToken(
|
||||
form.Token,
|
||||
form.config.App.Settings().UserVerificationToken.Secret,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if user.Verified {
|
||||
return user, nil // already verified
|
||||
}
|
||||
|
||||
user.Verified = true
|
||||
|
||||
if err := form.config.Dao.SaveUser(user); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
@ -1,150 +0,0 @@
|
||||
package forms_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
"github.com/pocketbase/pocketbase/forms"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
)
|
||||
|
||||
func TestUserVerificationConfirmPanic(t *testing.T) {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("The form did not panic")
|
||||
}
|
||||
}()
|
||||
|
||||
forms.NewUserVerificationConfirm(nil)
|
||||
}
|
||||
|
||||
func TestUserVerificationConfirmValidate(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
scenarios := []struct {
|
||||
jsonData string
|
||||
expectedErrors []string
|
||||
}{
|
||||
// empty data
|
||||
{
|
||||
`{}`,
|
||||
[]string{"token"},
|
||||
},
|
||||
// empty fields
|
||||
{
|
||||
`{"token":""}`,
|
||||
[]string{"token"},
|
||||
},
|
||||
// invalid JWT token
|
||||
{
|
||||
`{"token":"invalid"}`,
|
||||
[]string{"token"},
|
||||
},
|
||||
// expired token
|
||||
{
|
||||
`{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZW1haWwiOiJ0ZXN0QGV4YW1wbGUuY29tIiwiZXhwIjoxNjQwOTkxNjYxfQ.6KBn19eFa9aFAZ6hvuhQtK7Ovxb6QlBQ97vJtulb_P8"}`,
|
||||
[]string{"token"},
|
||||
},
|
||||
// valid token
|
||||
{
|
||||
`{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZW1haWwiOiJ0ZXN0QGV4YW1wbGUuY29tIiwiZXhwIjoxOTA2MTA2NDIxfQ.yvH96FwtPHGvzhFSKl8Tsi1FnGytKpMrvb7K9F2_zQA"}`,
|
||||
[]string{},
|
||||
},
|
||||
}
|
||||
|
||||
for i, s := range scenarios {
|
||||
form := forms.NewUserVerificationConfirm(app)
|
||||
|
||||
// load data
|
||||
loadErr := json.Unmarshal([]byte(s.jsonData), form)
|
||||
if loadErr != nil {
|
||||
t.Errorf("(%d) Failed to load form data: %v", i, loadErr)
|
||||
continue
|
||||
}
|
||||
|
||||
// parse errors
|
||||
result := form.Validate()
|
||||
errs, ok := result.(validation.Errors)
|
||||
if !ok && result != nil {
|
||||
t.Errorf("(%d) Failed to parse errors %v", i, result)
|
||||
continue
|
||||
}
|
||||
|
||||
// check errors
|
||||
if len(errs) > len(s.expectedErrors) {
|
||||
t.Errorf("(%d) Expected error keys %v, got %v", i, s.expectedErrors, errs)
|
||||
}
|
||||
for _, k := range s.expectedErrors {
|
||||
if _, ok := errs[k]; !ok {
|
||||
t.Errorf("(%d) Missing expected error key %q in %v", i, k, errs)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserVerificationConfirmSubmit(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
scenarios := []struct {
|
||||
jsonData string
|
||||
expectError bool
|
||||
}{
|
||||
// empty data (Validate call check)
|
||||
{
|
||||
`{}`,
|
||||
true,
|
||||
},
|
||||
// expired token (Validate call check)
|
||||
{
|
||||
`{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZW1haWwiOiJ0ZXN0QGV4YW1wbGUuY29tIiwiZXhwIjoxNjQwOTkxNjYxfQ.6KBn19eFa9aFAZ6hvuhQtK7Ovxb6QlBQ97vJtulb_P8"}`,
|
||||
true,
|
||||
},
|
||||
// valid token (already verified user)
|
||||
{
|
||||
`{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZW1haWwiOiJ0ZXN0QGV4YW1wbGUuY29tIiwiZXhwIjoxOTA2MTA2NDIxfQ.yvH96FwtPHGvzhFSKl8Tsi1FnGytKpMrvb7K9F2_zQA"}`,
|
||||
false,
|
||||
},
|
||||
// valid token (unverified user)
|
||||
{
|
||||
`{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjdiYzg0ZDI3LTZiYTItYjQyYS0zODNmLTQxOTdjYzNkM2QwYyIsInR5cGUiOiJ1c2VyIiwiZW1haWwiOiJ0ZXN0MkBleGFtcGxlLmNvbSIsImV4cCI6MTkwNjEwNjQyMX0.KbSucLGasQqTkGxUgqaaCjKNOHJ3ZVkL1WTzSApc6oM"}`,
|
||||
false,
|
||||
},
|
||||
}
|
||||
|
||||
for i, s := range scenarios {
|
||||
form := forms.NewUserVerificationConfirm(app)
|
||||
|
||||
// load data
|
||||
loadErr := json.Unmarshal([]byte(s.jsonData), form)
|
||||
if loadErr != nil {
|
||||
t.Errorf("(%d) Failed to load form data: %v", i, loadErr)
|
||||
continue
|
||||
}
|
||||
|
||||
user, err := form.Submit()
|
||||
|
||||
hasErr := err != nil
|
||||
if hasErr != s.expectError {
|
||||
t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, s.expectError, hasErr, err)
|
||||
}
|
||||
|
||||
if s.expectError {
|
||||
continue
|
||||
}
|
||||
|
||||
claims, _ := security.ParseUnverifiedJWT(form.Token)
|
||||
tokenUserId := claims["id"]
|
||||
|
||||
if user.Id != tokenUserId {
|
||||
t.Errorf("(%d) Expected user.Id %q, got %q", i, tokenUserId, user.Id)
|
||||
}
|
||||
|
||||
if !user.Verified {
|
||||
t.Errorf("(%d) Expected user.Verified to be true, got false", i)
|
||||
}
|
||||
}
|
||||
}
|
@ -1,104 +0,0 @@
|
||||
package forms
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
"github.com/go-ozzo/ozzo-validation/v4/is"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/daos"
|
||||
"github.com/pocketbase/pocketbase/mails"
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
)
|
||||
|
||||
// UserVerificationRequest defines a user email verification request form.
|
||||
type UserVerificationRequest struct {
|
||||
config UserVerificationRequestConfig
|
||||
|
||||
Email string `form:"email" json:"email"`
|
||||
}
|
||||
|
||||
// UserVerificationRequestConfig is the [UserVerificationRequest]
|
||||
// factory initializer config.
|
||||
//
|
||||
// NB! App is required struct member.
|
||||
type UserVerificationRequestConfig struct {
|
||||
App core.App
|
||||
Dao *daos.Dao
|
||||
ResendThreshold float64 // in seconds
|
||||
}
|
||||
|
||||
// NewUserVerificationRequest creates a new [UserVerificationRequest]
|
||||
// form with initializer config created from the provided [core.App] instance.
|
||||
//
|
||||
// If you want to submit the form as part of another transaction, use
|
||||
// [NewUserVerificationRequestWithConfig] with explicitly set Dao.
|
||||
func NewUserVerificationRequest(app core.App) *UserVerificationRequest {
|
||||
return NewUserVerificationRequestWithConfig(UserVerificationRequestConfig{
|
||||
App: app,
|
||||
ResendThreshold: 120, // 2 min
|
||||
})
|
||||
}
|
||||
|
||||
// NewUserVerificationRequestWithConfig creates a new [UserVerificationRequest]
|
||||
// form with the provided config or panics on invalid configuration.
|
||||
func NewUserVerificationRequestWithConfig(config UserVerificationRequestConfig) *UserVerificationRequest {
|
||||
form := &UserVerificationRequest{config: config}
|
||||
|
||||
if form.config.App == nil {
|
||||
panic("Missing required config.App instance.")
|
||||
}
|
||||
|
||||
if form.config.Dao == nil {
|
||||
form.config.Dao = form.config.App.Dao()
|
||||
}
|
||||
|
||||
return form
|
||||
}
|
||||
|
||||
// Validate makes the form validatable by implementing [validation.Validatable] interface.
|
||||
//
|
||||
// // This method doesn't verify that user with `form.Email` exists (this is done on Submit).
|
||||
func (form *UserVerificationRequest) Validate() error {
|
||||
return validation.ValidateStruct(form,
|
||||
validation.Field(
|
||||
&form.Email,
|
||||
validation.Required,
|
||||
validation.Length(1, 255),
|
||||
is.EmailFormat,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// Submit validates and sends a verification request email
|
||||
// to the `form.Email` user.
|
||||
func (form *UserVerificationRequest) Submit() error {
|
||||
if err := form.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
user, err := form.config.Dao.FindUserByEmail(form.Email)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if user.Verified {
|
||||
return nil // already verified
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
lastVerificationSentAt := user.LastVerificationSentAt.Time()
|
||||
if (now.Sub(lastVerificationSentAt)).Seconds() < form.config.ResendThreshold {
|
||||
return errors.New("A verification email was already sent.")
|
||||
}
|
||||
|
||||
if err := mails.SendUserVerification(form.config.App, user); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// update last sent timestamp
|
||||
user.LastVerificationSentAt = types.NowDateTime()
|
||||
|
||||
return form.config.Dao.SaveUser(user)
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user