1
0
mirror of https://github.com/pocketbase/pocketbase.git synced 2025-11-06 17:39:57 +02:00

initial v0.8 pre-release

This commit is contained in:
Gani Georgiev
2022-10-30 10:28:14 +02:00
parent 9cbb2e750e
commit 90dba45d7c
388 changed files with 21580 additions and 13603 deletions

View File

@@ -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
}

View File

@@ -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()

View File

@@ -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
}

View File

@@ -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 {

View File

@@ -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)
}

View File

@@ -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()

View File

@@ -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...)
}

View File

@@ -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"

View File

@@ -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]+$`)

View File

@@ -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...)
}

View File

@@ -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)
}
}
}

View File

@@ -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)

View File

@@ -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,
},
},
}

View 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
}

View 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)
}
}
}

View 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)
}

View File

@@ -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)

View 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
}

View File

@@ -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)
}
}
}

View 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
}

View 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)
}
}
}

View 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
}

View 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)
}
}
}

View 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)
}

View File

@@ -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())
}
}
}

View File

@@ -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
}

View File

@@ -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)
}
}
}

View 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
}

View 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)
}
}
}

View 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)
}

View File

@@ -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())
}
}
}

View File

@@ -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...)
}

View File

@@ -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,
},

View File

@@ -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

View File

@@ -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"

View File

@@ -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
}

View File

@@ -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)
}
}
}

View File

@@ -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)
}

View File

@@ -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
}

View File

@@ -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)
}
}
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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)
}
}
}

View File

@@ -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)
}

View File

@@ -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...)
}

View File

@@ -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)
}
}
}
}

View File

@@ -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
}

View File

@@ -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)
}
}
}

View File

@@ -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)
}

View File

@@ -1,7 +1,6 @@
package validators
import (
"encoding/binary"
"fmt"
"strings"
@@ -22,7 +21,7 @@ func UploadedFileSize(maxBytes int) validation.RuleFunc {
return nil // nothing to validate
}
if binary.Size(v.Bytes()) > maxBytes {
if int(v.Header().Size) > maxBytes {
return validation.NewError("validation_file_size_limit", fmt.Sprintf("Maximum allowed file size is %v bytes.", maxBytes))
}
@@ -47,7 +46,16 @@ func UploadedFileMimeType(validTypes []string) validation.RuleFunc {
return validation.NewError("validation_invalid_mime_type", "Unsupported file type.")
}
filetype := mimetype.Detect(v.Bytes())
f, err := v.Header().Open()
if err != nil {
return validation.NewError("validation_invalid_mime_type", "Unsupported file type.")
}
defer f.Close()
filetype, err := mimetype.DetectReader(f)
if err != nil {
return validation.NewError("validation_invalid_mime_type", "Unsupported file type.")
}
for _, t := range validTypes {
if filetype.Is(t) {

View File

@@ -28,7 +28,7 @@ var requiredErr = validation.NewError("validation_required", "Missing required v
func NewRecordDataValidator(
dao *daos.Dao,
record *models.Record,
uploadedFiles []*rest.UploadedFile,
uploadedFiles map[string][]*rest.UploadedFile,
) *RecordDataValidator {
return &RecordDataValidator{
dao: dao,
@@ -42,7 +42,7 @@ func NewRecordDataValidator(
type RecordDataValidator struct {
dao *daos.Dao
record *models.Record
uploadedFiles []*rest.UploadedFile
uploadedFiles map[string][]*rest.UploadedFile
}
// Validate validates the provided `data` by checking it against
@@ -88,7 +88,7 @@ func (validator *RecordDataValidator) Validate(data map[string]any) error {
// check unique constraint
if field.Unique && !validator.dao.IsRecordValueUnique(
validator.record.Collection(),
validator.record.Collection().Id,
key,
value,
validator.record.GetId(),
@@ -127,8 +127,6 @@ func (validator *RecordDataValidator) checkFieldValue(field *schema.SchemaField,
return validator.checkFileValue(field, value)
case schema.FieldTypeRelation:
return validator.checkRelationValue(field, value)
case schema.FieldTypeUser:
return validator.checkUserValue(field, value)
}
return nil
@@ -316,8 +314,8 @@ func (validator *RecordDataValidator) checkFileValue(field *schema.SchemaField,
}
// extract the uploaded files
files := make([]*rest.UploadedFile, 0, len(validator.uploadedFiles))
for _, file := range validator.uploadedFiles {
files := make([]*rest.UploadedFile, 0, len(validator.uploadedFiles[field.Name]))
for _, file := range validator.uploadedFiles[field.Name] {
if list.ExistInSlice(file.Name(), names) {
files = append(files, file)
}
@@ -351,8 +349,8 @@ func (validator *RecordDataValidator) checkRelationValue(field *schema.SchemaFie
options, _ := field.Options.(*schema.RelationOptions)
if len(ids) > options.MaxSelect {
return validation.NewError("validation_too_many_values", fmt.Sprintf("Select no more than %d", options.MaxSelect))
if options.MaxSelect != nil && len(ids) > *options.MaxSelect {
return validation.NewError("validation_too_many_values", fmt.Sprintf("Select no more than %d", *options.MaxSelect))
}
// check if the related records exist
@@ -374,31 +372,3 @@ func (validator *RecordDataValidator) checkRelationValue(field *schema.SchemaFie
return nil
}
func (validator *RecordDataValidator) checkUserValue(field *schema.SchemaField, value any) error {
ids := list.ToUniqueStringSlice(value)
if len(ids) == 0 {
if field.Required {
return requiredErr
}
return nil // nothing to check
}
options, _ := field.Options.(*schema.UserOptions)
if len(ids) > options.MaxSelect {
return validation.NewError("validation_too_many_values", fmt.Sprintf("Select no more than %d", options.MaxSelect))
}
// check if the related users exist
var total int
validator.dao.UserQuery().
Select("count(*)").
AndWhere(dbx.In("id", list.ToInterfaceSlice(ids)...)).
Row(&total)
if total != len(ids) {
return validation.NewError("validation_missing_users", "Failed to fetch all users with the provided ids")
}
return nil
}

View File

@@ -20,7 +20,7 @@ import (
type testDataFieldScenario struct {
name string
data map[string]any
files []*rest.UploadedFile
files map[string][]*rest.UploadedFile
expectedErrors []string
}
@@ -28,7 +28,7 @@ func TestRecordDataValidatorEmptyAndUnknown(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
collection, _ := app.Dao().FindCollectionByNameOrId("demo")
collection, _ := app.Dao().FindCollectionByNameOrId("demo2")
record := models.NewRecord(collection)
validator := validators.NewRecordDataValidator(app.Dao(), record, nil)
@@ -80,9 +80,9 @@ func TestRecordDataValidatorValidateText(t *testing.T) {
// create dummy record (used for the unique check)
dummy := models.NewRecord(collection)
dummy.SetDataValue("field1", "test")
dummy.SetDataValue("field2", "test")
dummy.SetDataValue("field3", "test")
dummy.Set("field1", "test")
dummy.Set("field2", "test")
dummy.Set("field3", "test")
if err := app.Dao().SaveRecord(dummy); err != nil {
t.Fatal(err)
}
@@ -196,9 +196,9 @@ func TestRecordDataValidatorValidateNumber(t *testing.T) {
// create dummy record (used for the unique check)
dummy := models.NewRecord(collection)
dummy.SetDataValue("field1", 123)
dummy.SetDataValue("field2", 123)
dummy.SetDataValue("field3", 123)
dummy.Set("field1", 123)
dummy.Set("field2", 123)
dummy.Set("field3", 123)
if err := app.Dao().SaveRecord(dummy); err != nil {
t.Fatal(err)
}
@@ -307,9 +307,9 @@ func TestRecordDataValidatorValidateBool(t *testing.T) {
// create dummy record (used for the unique check)
dummy := models.NewRecord(collection)
dummy.SetDataValue("field1", false)
dummy.SetDataValue("field2", true)
dummy.SetDataValue("field3", true)
dummy.Set("field1", false)
dummy.Set("field2", true)
dummy.Set("field3", true)
if err := app.Dao().SaveRecord(dummy); err != nil {
t.Fatal(err)
}
@@ -403,9 +403,9 @@ func TestRecordDataValidatorValidateEmail(t *testing.T) {
// create dummy record (used for the unique check)
dummy := models.NewRecord(collection)
dummy.SetDataValue("field1", "test@demo.com")
dummy.SetDataValue("field2", "test@test.com")
dummy.SetDataValue("field3", "test@example.com")
dummy.Set("field1", "test@demo.com")
dummy.Set("field2", "test@test.com")
dummy.Set("field3", "test@example.com")
if err := app.Dao().SaveRecord(dummy); err != nil {
t.Fatal(err)
}
@@ -519,9 +519,9 @@ func TestRecordDataValidatorValidateUrl(t *testing.T) {
// create dummy record (used for the unique check)
dummy := models.NewRecord(collection)
dummy.SetDataValue("field1", "http://demo.com")
dummy.SetDataValue("field2", "http://test.com")
dummy.SetDataValue("field3", "http://example.com")
dummy.Set("field1", "http://demo.com")
dummy.Set("field2", "http://test.com")
dummy.Set("field3", "http://example.com")
if err := app.Dao().SaveRecord(dummy); err != nil {
t.Fatal(err)
}
@@ -647,9 +647,9 @@ func TestRecordDataValidatorValidateDate(t *testing.T) {
// create dummy record (used for the unique check)
dummy := models.NewRecord(collection)
dummy.SetDataValue("field1", "2022-01-01 01:01:01")
dummy.SetDataValue("field2", "2029-01-01 01:01:01.123")
dummy.SetDataValue("field3", "2029-01-01 01:01:01.123")
dummy.Set("field1", "2022-01-01 01:01:01")
dummy.Set("field2", "2029-01-01 01:01:01.123")
dummy.Set("field3", "2029-01-01 01:01:01.123")
if err := app.Dao().SaveRecord(dummy); err != nil {
t.Fatal(err)
}
@@ -779,9 +779,9 @@ func TestRecordDataValidatorValidateSelect(t *testing.T) {
// create dummy record (used for the unique check)
dummy := models.NewRecord(collection)
dummy.SetDataValue("field1", "a")
dummy.SetDataValue("field2", []string{"a", "b"})
dummy.SetDataValue("field3", []string{"a", "b", "c"})
dummy.Set("field1", "a")
dummy.Set("field2", []string{"a", "b"})
dummy.Set("field3", []string{"a", "b", "c"})
if err := app.Dao().SaveRecord(dummy); err != nil {
t.Fatal(err)
}
@@ -909,9 +909,9 @@ func TestRecordDataValidatorValidateJson(t *testing.T) {
// create dummy record (used for the unique check)
dummy := models.NewRecord(collection)
dummy.SetDataValue("field1", `{"test":123}`)
dummy.SetDataValue("field2", `{"test":123}`)
dummy.SetDataValue("field3", `{"test":123}`)
dummy.Set("field1", `{"test":123}`)
dummy.Set("field2", `{"test":123}`)
dummy.Set("field3", `{"test":123}`)
if err := app.Dao().SaveRecord(dummy); err != nil {
t.Fatal(err)
}
@@ -1080,7 +1080,9 @@ func TestRecordDataValidatorValidateFile(t *testing.T) {
"field2": []string{"test1", testFiles[0].Name(), testFiles[3].Name()},
"field3": []string{"test1", "test2", "test3", "test4"},
},
[]*rest.UploadedFile{testFiles[0], testFiles[1], testFiles[2]},
map[string][]*rest.UploadedFile{
"field2": {testFiles[0], testFiles[3]},
},
[]string{"field2", "field3"},
},
{
@@ -1090,7 +1092,10 @@ func TestRecordDataValidatorValidateFile(t *testing.T) {
"field2": []string{"test1", testFiles[0].Name()},
"field3": []string{"test1", "test2", "test3"},
},
[]*rest.UploadedFile{testFiles[0], testFiles[1], testFiles[2]},
map[string][]*rest.UploadedFile{
"field1": {testFiles[0]},
"field2": {testFiles[0]},
},
[]string{"field1"},
},
{
@@ -1100,7 +1105,10 @@ func TestRecordDataValidatorValidateFile(t *testing.T) {
"field2": []string{"test1", testFiles[0].Name()},
"field3": []string{testFiles[1].Name(), testFiles[2].Name()},
},
[]*rest.UploadedFile{testFiles[0], testFiles[1], testFiles[2]},
map[string][]*rest.UploadedFile{
"field2": {testFiles[0], testFiles[1], testFiles[2]},
"field3": {testFiles[1], testFiles[2]},
},
[]string{"field3"},
},
{
@@ -1120,7 +1128,9 @@ func TestRecordDataValidatorValidateFile(t *testing.T) {
"field2": []string{testFiles[0].Name(), testFiles[1].Name()},
"field3": nil,
},
[]*rest.UploadedFile{testFiles[0], testFiles[1], testFiles[2]},
map[string][]*rest.UploadedFile{
"field2": {testFiles[0], testFiles[1]},
},
[]string{},
},
{
@@ -1130,7 +1140,9 @@ func TestRecordDataValidatorValidateFile(t *testing.T) {
"field2": []string{"test1", testFiles[0].Name()},
"field3": "test1", // will be casted
},
[]*rest.UploadedFile{testFiles[0], testFiles[1], testFiles[2]},
map[string][]*rest.UploadedFile{
"field2": {testFiles[0], testFiles[1], testFiles[2]},
},
[]string{},
},
}
@@ -1142,17 +1154,17 @@ func TestRecordDataValidatorValidateRelation(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
demo, _ := app.Dao().FindCollectionByNameOrId("demo4")
demo, _ := app.Dao().FindCollectionByNameOrId("demo3")
// demo4 rel ids
relId1 := "b8ba58f9-e2d7-42a0-b0e7-a11efd98236b"
relId2 := "df55c8ff-45ef-4c82-8aed-6e2183fe1125"
relId3 := "b84cd893-7119-43c9-8505-3c4e22da28a9"
relId4 := "054f9f24-0a0a-4e09-87b1-bc7ff2b336a2"
// demo3 rel ids
relId1 := "mk5fmymtx4wsprk"
relId2 := "7nwo8tuiatetxdm"
relId3 := "lcl9d87w22ml6jy"
relId4 := "1tmknxy2868d869"
// record rel ids from different collections
diffRelId1 := "63c2ab80-84ab-4057-a592-4604a731f78f"
diffRelId2 := "2c542824-9de1-42fe-8924-e57c86267760"
diffRelId1 := "0yxhwia2amd8gec"
diffRelId2 := "llvuca81nly1qls"
// create new test collection
collection := &models.Collection{}
@@ -1162,7 +1174,7 @@ func TestRecordDataValidatorValidateRelation(t *testing.T) {
Name: "field1",
Type: schema.FieldTypeRelation,
Options: &schema.RelationOptions{
MaxSelect: 1,
MaxSelect: types.Pointer(1),
CollectionId: demo.Id,
},
},
@@ -1171,7 +1183,7 @@ func TestRecordDataValidatorValidateRelation(t *testing.T) {
Required: true,
Type: schema.FieldTypeRelation,
Options: &schema.RelationOptions{
MaxSelect: 2,
MaxSelect: types.Pointer(2),
CollectionId: demo.Id,
},
},
@@ -1180,7 +1192,6 @@ func TestRecordDataValidatorValidateRelation(t *testing.T) {
Unique: true,
Type: schema.FieldTypeRelation,
Options: &schema.RelationOptions{
MaxSelect: 3,
CollectionId: demo.Id,
},
},
@@ -1188,7 +1199,7 @@ func TestRecordDataValidatorValidateRelation(t *testing.T) {
Name: "field4",
Type: schema.FieldTypeRelation,
Options: &schema.RelationOptions{
MaxSelect: 3,
MaxSelect: types.Pointer(3),
CollectionId: "", // missing or non-existing collection id
},
},
@@ -1199,9 +1210,9 @@ func TestRecordDataValidatorValidateRelation(t *testing.T) {
// create dummy record (used for the unique check)
dummy := models.NewRecord(collection)
dummy.SetDataValue("field1", relId1)
dummy.SetDataValue("field2", []string{relId1, relId2})
dummy.SetDataValue("field3", []string{relId1, relId2, relId3})
dummy.Set("field1", relId1)
dummy.Set("field2", []string{relId1, relId2})
dummy.Set("field3", []string{relId1, relId2, relId3})
if err := app.Dao().SaveRecord(dummy); err != nil {
t.Fatal(err)
}
@@ -1254,7 +1265,7 @@ func TestRecordDataValidatorValidateRelation(t *testing.T) {
"field3": []string{relId1, relId2, relId3, relId4},
},
nil,
[]string{"field2", "field3"},
[]string{"field2"},
},
{
"check with ids from different collections",
@@ -1289,130 +1300,6 @@ func TestRecordDataValidatorValidateRelation(t *testing.T) {
checkValidatorErrors(t, app.Dao(), models.NewRecord(collection), scenarios)
}
func TestRecordDataValidatorValidateUser(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
userId1 := "97cc3d3d-6ba2-383f-b42a-7bc84d27410c"
userId2 := "7bc84d27-6ba2-b42a-383f-4197cc3d3d0c"
userId3 := "4d0197cc-2b4a-3f83-a26b-d77bc8423d3c"
missingUserId := "00000000-84ab-4057-a592-4604a731f78f"
// create new test collection
collection := &models.Collection{}
collection.Name = "validate_test"
collection.Schema = schema.NewSchema(
&schema.SchemaField{
Name: "field1",
Type: schema.FieldTypeUser,
Options: &schema.UserOptions{
MaxSelect: 1,
},
},
&schema.SchemaField{
Name: "field2",
Required: true,
Type: schema.FieldTypeUser,
Options: &schema.UserOptions{
MaxSelect: 2,
},
},
&schema.SchemaField{
Name: "field3",
Unique: true,
Type: schema.FieldTypeUser,
Options: &schema.UserOptions{
MaxSelect: 3,
},
},
)
if err := app.Dao().SaveCollection(collection); err != nil {
t.Fatal(err)
}
// create dummy record (used for the unique check)
dummy := models.NewRecord(collection)
dummy.SetDataValue("field1", userId1)
dummy.SetDataValue("field2", []string{userId1, userId2})
dummy.SetDataValue("field3", []string{userId1, userId2, userId3})
if err := app.Dao().SaveRecord(dummy); err != nil {
t.Fatal(err)
}
scenarios := []testDataFieldScenario{
{
"check required constraint - nil",
map[string]any{
"field1": nil,
"field2": nil,
"field3": nil,
},
nil,
[]string{"field2"},
},
{
"check required constraint - zero id",
map[string]any{
"field1": "",
"field2": "",
"field3": "",
},
nil,
[]string{"field2"},
},
{
"check unique constraint",
map[string]any{
"field1": nil,
"field2": userId1,
"field3": []string{userId1, userId2, userId3, userId3}, // repeating values are collapsed
},
nil,
[]string{"field3"},
},
{
"check MaxSelect constraint",
map[string]any{
"field1": []string{userId1, userId2}, // maxSelect is 1 and will be normalized to userId1 only
"field2": []string{userId1, userId2, userId3},
"field3": []string{userId1, userId3, userId2},
},
nil,
[]string{"field2"},
},
{
"check with mixed existing and nonexisting user ids",
map[string]any{
"field1": missingUserId,
"field2": []string{missingUserId, userId1},
"field3": []string{userId1, missingUserId},
},
nil,
[]string{"field1", "field2", "field3"},
},
{
"valid data - only required fields",
map[string]any{
"field2": []string{userId1, userId2},
},
nil,
[]string{},
},
{
"valid data - all fields with normalization",
map[string]any{
"field1": []string{userId1, userId2},
"field2": userId2,
"field3": []string{userId3, userId2, userId1}, // unique is not triggered because the order is different
},
nil,
[]string{},
},
}
checkValidatorErrors(t, app.Dao(), models.NewRecord(collection), scenarios)
}
func checkValidatorErrors(t *testing.T, dao *daos.Dao, record *models.Record, scenarios []testDataFieldScenario) {
for i, s := range scenarios {
validator := validators.NewRecordDataValidator(dao, record, s.files)