chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,87 @@
|
||||
package forms
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
)
|
||||
|
||||
var privateKeyRegex = regexp.MustCompile(`(?m)-----BEGIN PRIVATE KEY----[\s\S]+-----END PRIVATE KEY-----`)
|
||||
|
||||
// AppleClientSecretCreate is a form struct to generate a new Apple Client Secret.
|
||||
//
|
||||
// Reference: https://developer.apple.com/documentation/sign_in_with_apple/generate_and_validate_tokens
|
||||
type AppleClientSecretCreate struct {
|
||||
app core.App
|
||||
|
||||
// ClientId is the identifier of your app (aka. Service ID).
|
||||
ClientId string `form:"clientId" json:"clientId"`
|
||||
|
||||
// TeamId is a 10-character string associated with your developer account
|
||||
// (usually could be found next to your name in the Apple Developer site).
|
||||
TeamId string `form:"teamId" json:"teamId"`
|
||||
|
||||
// KeyId is a 10-character key identifier generated for the "Sign in with Apple"
|
||||
// private key associated with your developer account.
|
||||
KeyId string `form:"keyId" json:"keyId"`
|
||||
|
||||
// PrivateKey is the private key associated to your app.
|
||||
// Usually wrapped within -----BEGIN PRIVATE KEY----- X -----END PRIVATE KEY-----.
|
||||
PrivateKey string `form:"privateKey" json:"privateKey"`
|
||||
|
||||
// Duration specifies how long the generated JWT should be considered valid.
|
||||
// The specified value must be in seconds and max 15777000 (~6months).
|
||||
Duration int `form:"duration" json:"duration"`
|
||||
}
|
||||
|
||||
// NewAppleClientSecretCreate creates a new [AppleClientSecretCreate] form with initializer
|
||||
// config created from the provided [core.App] instances.
|
||||
func NewAppleClientSecretCreate(app core.App) *AppleClientSecretCreate {
|
||||
form := &AppleClientSecretCreate{
|
||||
app: app,
|
||||
}
|
||||
|
||||
return form
|
||||
}
|
||||
|
||||
// Validate makes the form validatable by implementing [validation.Validatable] interface.
|
||||
func (form *AppleClientSecretCreate) Validate() error {
|
||||
return validation.ValidateStruct(form,
|
||||
validation.Field(&form.ClientId, validation.Required),
|
||||
validation.Field(&form.TeamId, validation.Required, validation.Length(10, 10)),
|
||||
validation.Field(&form.KeyId, validation.Required, validation.Length(10, 10)),
|
||||
validation.Field(&form.PrivateKey, validation.Required, validation.Match(privateKeyRegex)),
|
||||
validation.Field(&form.Duration, validation.Required, validation.Min(1), validation.Max(15777000)),
|
||||
)
|
||||
}
|
||||
|
||||
// Submit validates the form and returns a new Apple Client Secret JWT.
|
||||
func (form *AppleClientSecretCreate) Submit() (string, error) {
|
||||
if err := form.Validate(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
signKey, err := jwt.ParseECPrivateKeyFromPEM([]byte(strings.TrimSpace(form.PrivateKey)))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
|
||||
claims := &jwt.RegisteredClaims{
|
||||
Audience: jwt.ClaimStrings{"https://appleid.apple.com"},
|
||||
Subject: form.ClientId,
|
||||
Issuer: form.TeamId,
|
||||
IssuedAt: jwt.NewNumericDate(now),
|
||||
ExpiresAt: jwt.NewNumericDate(now.Add(time.Duration(form.Duration) * time.Second)),
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodES256, claims)
|
||||
token.Header["kid"] = form.KeyId
|
||||
|
||||
return token.SignedString(signKey)
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package forms_test
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"testing"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/pocketbase/pocketbase/forms"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
)
|
||||
|
||||
func TestAppleClientSecretCreateValidateAndSubmit(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
encodedKey, err := x509.MarshalPKCS8PrivateKey(key)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
privatePem := pem.EncodeToMemory(
|
||||
&pem.Block{
|
||||
Type: "PRIVATE KEY",
|
||||
Bytes: encodedKey,
|
||||
},
|
||||
)
|
||||
|
||||
scenarios := []struct {
|
||||
name string
|
||||
formData map[string]any
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
"empty data",
|
||||
map[string]any{},
|
||||
true,
|
||||
},
|
||||
{
|
||||
"invalid data",
|
||||
map[string]any{
|
||||
"clientId": "",
|
||||
"teamId": "123456789",
|
||||
"keyId": "123456789",
|
||||
"privateKey": "-----BEGIN PRIVATE KEY----- invalid -----END PRIVATE KEY-----",
|
||||
"duration": -1,
|
||||
},
|
||||
true,
|
||||
},
|
||||
{
|
||||
"valid data",
|
||||
map[string]any{
|
||||
"clientId": "123",
|
||||
"teamId": "1234567890",
|
||||
"keyId": "1234567891",
|
||||
"privateKey": string(privatePem),
|
||||
"duration": 1,
|
||||
},
|
||||
false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
form := forms.NewAppleClientSecretCreate(app)
|
||||
|
||||
rawData, marshalErr := json.Marshal(s.formData)
|
||||
if marshalErr != nil {
|
||||
t.Errorf("[%s] Failed to marshalize the scenario data: %v", s.name, marshalErr)
|
||||
continue
|
||||
}
|
||||
|
||||
// load data
|
||||
loadErr := json.Unmarshal(rawData, form)
|
||||
if loadErr != nil {
|
||||
t.Errorf("[%s] Failed to load form data: %v", s.name, loadErr)
|
||||
continue
|
||||
}
|
||||
|
||||
secret, err := form.Submit()
|
||||
|
||||
hasErr := err != nil
|
||||
if hasErr != s.expectError {
|
||||
t.Errorf("[%s] Expected hasErr %v, got %v (%v)", s.name, s.expectError, hasErr, err)
|
||||
}
|
||||
|
||||
if hasErr {
|
||||
continue
|
||||
}
|
||||
|
||||
if secret == "" {
|
||||
t.Errorf("[%s] Expected non-empty secret", s.name)
|
||||
}
|
||||
|
||||
claims := jwt.MapClaims{}
|
||||
token, _, err := jwt.NewParser().ParseUnverified(secret, claims)
|
||||
if err != nil {
|
||||
t.Errorf("[%s] Failed to parse token: %v", s.name, err)
|
||||
}
|
||||
|
||||
if alg := token.Header["alg"]; alg != "ES256" {
|
||||
t.Errorf("[%s] Expected %q alg header, got %q", s.name, "ES256", alg)
|
||||
}
|
||||
|
||||
if kid := token.Header["kid"]; kid != form.KeyId {
|
||||
t.Errorf("[%s] Expected %q kid header, got %q", s.name, form.KeyId, kid)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
package forms
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/core/validators"
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
"github.com/spf13/cast"
|
||||
)
|
||||
|
||||
const (
|
||||
accessLevelDefault = iota
|
||||
accessLevelManager
|
||||
accessLevelSuperuser
|
||||
)
|
||||
|
||||
type RecordUpsert struct {
|
||||
ctx context.Context
|
||||
app core.App
|
||||
record *core.Record
|
||||
accessLevel int
|
||||
|
||||
// extra password fields
|
||||
disablePasswordValidations bool
|
||||
password string
|
||||
passwordConfirm string
|
||||
oldPassword string
|
||||
}
|
||||
|
||||
// NewRecordUpsert creates a new [RecordUpsert] form from the provided [core.App] and [core.Record] instances
|
||||
// (for create you could pass a pointer to an empty Record - core.NewRecord(collection)).
|
||||
func NewRecordUpsert(app core.App, record *core.Record) *RecordUpsert {
|
||||
form := &RecordUpsert{
|
||||
ctx: context.Background(),
|
||||
app: app,
|
||||
record: record,
|
||||
}
|
||||
|
||||
return form
|
||||
}
|
||||
|
||||
// SetContext assigns ctx as context of the current form.
|
||||
func (form *RecordUpsert) SetContext(ctx context.Context) {
|
||||
form.ctx = ctx
|
||||
}
|
||||
|
||||
// SetApp replaces the current form app instance.
|
||||
//
|
||||
// This could be used for example if you want to change at later stage
|
||||
// before submission to change from regular -> transactional app instance.
|
||||
func (form *RecordUpsert) SetApp(app core.App) {
|
||||
form.app = app
|
||||
}
|
||||
|
||||
// SetRecord replaces the current form record instance.
|
||||
func (form *RecordUpsert) SetRecord(record *core.Record) {
|
||||
form.record = record
|
||||
}
|
||||
|
||||
// ResetAccess resets the form access level to the accessLevelDefault.
|
||||
func (form *RecordUpsert) ResetAccess() {
|
||||
form.accessLevel = accessLevelDefault
|
||||
}
|
||||
|
||||
// GrantManagerAccess updates the form access level to "manager" allowing
|
||||
// directly changing some system record fields (often used with auth collection records).
|
||||
func (form *RecordUpsert) GrantManagerAccess() {
|
||||
form.accessLevel = accessLevelManager
|
||||
}
|
||||
|
||||
// GrantSuperuserAccess updates the form access level to "superuser" allowing
|
||||
// directly changing all system record fields, including those marked as "Hidden".
|
||||
func (form *RecordUpsert) GrantSuperuserAccess() {
|
||||
form.accessLevel = accessLevelSuperuser
|
||||
}
|
||||
|
||||
// HasManageAccess reports whether the form has "manager" or "superuser" level access.
|
||||
func (form *RecordUpsert) HasManageAccess() bool {
|
||||
return form.accessLevel == accessLevelManager || form.accessLevel == accessLevelSuperuser
|
||||
}
|
||||
|
||||
// Load loads the provided data into the form and the related record.
|
||||
func (form *RecordUpsert) Load(data map[string]any) {
|
||||
excludeFields := []string{core.FieldNameExpand}
|
||||
|
||||
isAuth := form.record.Collection().IsAuth()
|
||||
|
||||
// load the special auth form fields
|
||||
if isAuth {
|
||||
if v, ok := data["password"]; ok {
|
||||
form.password = cast.ToString(v)
|
||||
}
|
||||
if v, ok := data["passwordConfirm"]; ok {
|
||||
form.passwordConfirm = cast.ToString(v)
|
||||
}
|
||||
if v, ok := data["oldPassword"]; ok {
|
||||
form.oldPassword = cast.ToString(v)
|
||||
}
|
||||
|
||||
excludeFields = append(excludeFields, "passwordConfirm", "oldPassword") // skip non-schema password fields
|
||||
}
|
||||
|
||||
for k, v := range data {
|
||||
if slices.Contains(excludeFields, k) {
|
||||
continue
|
||||
}
|
||||
|
||||
// set only known collection fields
|
||||
field := form.record.SetIfFieldExists(k, v)
|
||||
|
||||
// restore original value if hidden field (with exception of the auth "password")
|
||||
//
|
||||
// note: this is an extra measure against loading hidden fields
|
||||
// but usually is not used by the default route handlers since
|
||||
// they filter additionally the data before calling Load
|
||||
if form.accessLevel != accessLevelSuperuser && field != nil && field.GetHidden() && (!isAuth || field.GetName() != core.FieldNamePassword) {
|
||||
form.record.SetRaw(field.GetName(), form.record.Original().GetRaw(field.GetName()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (form *RecordUpsert) validateFormFields() error {
|
||||
isAuth := form.record.Collection().IsAuth()
|
||||
if !isAuth {
|
||||
return nil
|
||||
}
|
||||
|
||||
form.syncPasswordFields()
|
||||
|
||||
isNew := form.record.IsNew()
|
||||
|
||||
original := form.record.Original()
|
||||
|
||||
validateData := map[string]any{
|
||||
"email": form.record.Email(),
|
||||
"verified": form.record.Verified(),
|
||||
"password": form.password,
|
||||
"passwordConfirm": form.passwordConfirm,
|
||||
"oldPassword": form.oldPassword,
|
||||
}
|
||||
|
||||
return validation.Validate(validateData,
|
||||
validation.Map(
|
||||
validation.Key(
|
||||
"email",
|
||||
// don't allow direct email updates if the form doesn't have manage access permissions
|
||||
// (aka. allow only admin or authorized auth models to directly update the field)
|
||||
validation.When(
|
||||
!isNew && !form.HasManageAccess(),
|
||||
validation.By(validators.Equal(original.Email())),
|
||||
),
|
||||
),
|
||||
validation.Key(
|
||||
"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.HasManageAccess(),
|
||||
validation.By(validators.Equal(original.Verified())),
|
||||
),
|
||||
),
|
||||
validation.Key(
|
||||
"password",
|
||||
validation.When(
|
||||
!form.disablePasswordValidations && (isNew || form.passwordConfirm != "" || form.oldPassword != ""),
|
||||
validation.Required,
|
||||
),
|
||||
),
|
||||
validation.Key(
|
||||
"passwordConfirm",
|
||||
validation.When(
|
||||
!form.disablePasswordValidations && (isNew || form.password != "" || form.oldPassword != ""),
|
||||
validation.Required,
|
||||
),
|
||||
validation.When(!form.disablePasswordValidations, validation.By(validators.Equal(form.password))),
|
||||
),
|
||||
validation.Key(
|
||||
"oldPassword",
|
||||
// require old password only on update when:
|
||||
// - form.HasManageAccess() is not satisfied
|
||||
// - changing the existing password
|
||||
validation.When(
|
||||
!form.disablePasswordValidations && !isNew && !form.HasManageAccess() && (form.password != "" || form.passwordConfirm != ""),
|
||||
validation.Required,
|
||||
validation.By(form.checkOldPassword),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
func (form *RecordUpsert) checkOldPassword(value any) error {
|
||||
v, ok := value.(string)
|
||||
if !ok {
|
||||
return validators.ErrUnsupportedValueType
|
||||
}
|
||||
|
||||
if !form.record.Original().ValidatePassword(v) {
|
||||
return validation.NewError("validation_invalid_old_password", "Missing or invalid old password.")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Deprecated: It was previously used as part of the record create action but it is not needed anymore and will be removed in the future.
|
||||
//
|
||||
// DrySubmit performs a temp form submit within a transaction and reverts it at the end.
|
||||
// For actual record persistence, check the [RecordUpsert.Submit()] method.
|
||||
//
|
||||
// This method doesn't perform validations, handle file uploads/deletes or trigger app save events!
|
||||
func (form *RecordUpsert) DrySubmit(callback func(txApp core.App, drySavedRecord *core.Record) error) error {
|
||||
isNew := form.record.IsNew()
|
||||
|
||||
clone := form.record.Clone()
|
||||
|
||||
// set an id if it doesn't have already
|
||||
// (the value doesn't matter; it is used only during the manual delete/update rollback)
|
||||
if clone.IsNew() && clone.Id == "" {
|
||||
clone.Id = "_temp_" + security.PseudorandomString(15)
|
||||
}
|
||||
|
||||
app := form.app.UnsafeWithoutHooks()
|
||||
|
||||
isTransactional := app.IsTransactional()
|
||||
if !isTransactional {
|
||||
return app.RunInTransaction(func(txApp core.App) error {
|
||||
tx, ok := txApp.DB().(*dbx.Tx)
|
||||
if !ok {
|
||||
return errors.New("failed to get transaction db")
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
if err := txApp.SaveNoValidateWithContext(form.ctx, clone); err != nil {
|
||||
return validators.NormalizeUniqueIndexError(err, clone.Collection().Name, clone.Collection().Fields.FieldNames())
|
||||
}
|
||||
|
||||
if callback != nil {
|
||||
return callback(txApp, clone)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// already in a transaction
|
||||
// (manual rollback to avoid starting another transaction)
|
||||
// ---------------------------------------------------------------
|
||||
err := app.SaveNoValidateWithContext(form.ctx, clone)
|
||||
if err != nil {
|
||||
return validators.NormalizeUniqueIndexError(err, clone.Collection().Name, clone.Collection().Fields.FieldNames())
|
||||
}
|
||||
|
||||
manualRollback := func() error {
|
||||
if isNew {
|
||||
err = app.DeleteWithContext(form.ctx, clone)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to rollback dry submit created record: %w", err)
|
||||
}
|
||||
} else {
|
||||
clone.Load(clone.Original().FieldsData())
|
||||
err = app.SaveNoValidateWithContext(form.ctx, clone)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to rollback dry submit updated record: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
if callback != nil {
|
||||
return errors.Join(callback(app, clone), manualRollback())
|
||||
}
|
||||
|
||||
return manualRollback()
|
||||
}
|
||||
|
||||
// Submit validates the form specific validations and attempts to save the form record.
|
||||
func (form *RecordUpsert) Submit() error {
|
||||
err := form.validateFormFields()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// run record validations and persist in db
|
||||
return form.app.SaveWithContext(form.ctx, form.record)
|
||||
}
|
||||
|
||||
// syncPasswordFields syncs the form's auth password fields with their
|
||||
// corresponding record field values.
|
||||
//
|
||||
// This could be useful in case the password fields were programmatically set
|
||||
// directly by modifying the related record model.
|
||||
func (form *RecordUpsert) syncPasswordFields() {
|
||||
if !form.record.Collection().IsAuth() {
|
||||
return // not an auth collection
|
||||
}
|
||||
|
||||
form.disablePasswordValidations = false
|
||||
|
||||
rawPassword := form.record.GetRaw(core.FieldNamePassword)
|
||||
if v, ok := rawPassword.(*core.PasswordFieldValue); ok && v != nil {
|
||||
if
|
||||
// programmatically set custom plain password value
|
||||
(v.Plain != "" && v.Plain != form.password) ||
|
||||
// random generated password for new record
|
||||
(v.Plain == "" && v.Hash != "" && form.record.IsNew()) {
|
||||
form.disablePasswordValidations = true
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,118 @@
|
||||
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/mails"
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
)
|
||||
|
||||
const (
|
||||
TestTemplateVerification = "verification"
|
||||
TestTemplatePasswordReset = "password-reset"
|
||||
TestTemplateEmailChange = "email-change"
|
||||
TestTemplateOTP = "otp"
|
||||
TestTemplateAuthAlert = "login-alert"
|
||||
)
|
||||
|
||||
// TestEmailSend is a email template test request form.
|
||||
type TestEmailSend struct {
|
||||
app core.App
|
||||
|
||||
Email string `form:"email" json:"email"`
|
||||
Template string `form:"template" json:"template"`
|
||||
Collection string `form:"collection" json:"collection"` // optional, fallbacks to _superusers
|
||||
}
|
||||
|
||||
// NewTestEmailSend creates and initializes new TestEmailSend form.
|
||||
func NewTestEmailSend(app core.App) *TestEmailSend {
|
||||
return &TestEmailSend{app: app}
|
||||
}
|
||||
|
||||
// Validate makes the form validatable by implementing [validation.Validatable] interface.
|
||||
func (form *TestEmailSend) Validate() error {
|
||||
return validation.ValidateStruct(form,
|
||||
validation.Field(
|
||||
&form.Collection,
|
||||
validation.Length(1, 255),
|
||||
validation.By(form.checkAuthCollection),
|
||||
),
|
||||
validation.Field(
|
||||
&form.Email,
|
||||
validation.Required,
|
||||
validation.Length(1, 255),
|
||||
is.EmailFormat,
|
||||
),
|
||||
validation.Field(
|
||||
&form.Template,
|
||||
validation.Required,
|
||||
validation.In(
|
||||
TestTemplateVerification,
|
||||
TestTemplatePasswordReset,
|
||||
TestTemplateEmailChange,
|
||||
TestTemplateOTP,
|
||||
TestTemplateAuthAlert,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
func (form *TestEmailSend) checkAuthCollection(value any) error {
|
||||
v, _ := value.(string)
|
||||
if v == "" {
|
||||
return nil // nothing to check
|
||||
}
|
||||
|
||||
c, _ := form.app.FindCollectionByNameOrId(v)
|
||||
if c == nil || !c.IsAuth() {
|
||||
return validation.NewError("validation_invalid_auth_collection", "Must be a valid auth collection id or name.")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Submit validates and sends a test email to the form.Email address.
|
||||
func (form *TestEmailSend) Submit() error {
|
||||
if err := form.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
collectionIdOrName := form.Collection
|
||||
if collectionIdOrName == "" {
|
||||
collectionIdOrName = core.CollectionNameSuperusers
|
||||
}
|
||||
|
||||
collection, err := form.app.FindCollectionByNameOrId(collectionIdOrName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
record := core.NewRecord(collection)
|
||||
for _, field := range collection.Fields {
|
||||
if field.GetHidden() {
|
||||
continue
|
||||
}
|
||||
record.Set(field.GetName(), "__pb_test_"+field.GetName()+"__")
|
||||
}
|
||||
record.RefreshTokenKey()
|
||||
record.SetEmail(form.Email)
|
||||
|
||||
switch form.Template {
|
||||
case TestTemplateVerification:
|
||||
return mails.SendRecordVerification(form.app, record)
|
||||
case TestTemplatePasswordReset:
|
||||
return mails.SendRecordPasswordReset(form.app, record)
|
||||
case TestTemplateEmailChange:
|
||||
return mails.SendRecordChangeEmail(form.app, record, form.Email)
|
||||
case TestTemplateOTP:
|
||||
return mails.SendRecordOTP(form.app, record, "_PB_TEST_OTP_ID_", "123456")
|
||||
case TestTemplateAuthAlert:
|
||||
testEvent := types.NowDateTime().String() + " - TEST_IP TEST_USER_AGENT"
|
||||
return mails.SendRecordAuthAlert(form.app, record, testEvent)
|
||||
default:
|
||||
return errors.New("unknown template " + form.Template)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package forms_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
"github.com/pocketbase/pocketbase/forms"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
)
|
||||
|
||||
func TestEmailSendValidateAndSubmit(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scenarios := []struct {
|
||||
template string
|
||||
email string
|
||||
collection string
|
||||
expectedErrors []string
|
||||
}{
|
||||
{"", "", "", []string{"template", "email"}},
|
||||
{"invalid", "test@example.com", "", []string{"template"}},
|
||||
{forms.TestTemplateVerification, "invalid", "", []string{"email"}},
|
||||
{forms.TestTemplateVerification, "test@example.com", "invalid", []string{"collection"}},
|
||||
{forms.TestTemplateVerification, "test@example.com", "demo1", []string{"collection"}},
|
||||
{forms.TestTemplateVerification, "test@example.com", "users", nil},
|
||||
{forms.TestTemplatePasswordReset, "test@example.com", "", nil},
|
||||
{forms.TestTemplateEmailChange, "test@example.com", "", nil},
|
||||
{forms.TestTemplateOTP, "test@example.com", "", nil},
|
||||
{forms.TestTemplateAuthAlert, "test@example.com", "", nil},
|
||||
}
|
||||
|
||||
for i, s := range scenarios {
|
||||
t.Run(fmt.Sprintf("%d_%s", i, s.template), func(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
form := forms.NewTestEmailSend(app)
|
||||
form.Email = s.email
|
||||
form.Template = s.template
|
||||
form.Collection = s.collection
|
||||
|
||||
result := form.Submit()
|
||||
|
||||
// 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(s.expectedErrors) {
|
||||
t.Fatalf("Expected error keys %v, got %v", s.expectedErrors, errs)
|
||||
}
|
||||
for _, k := range s.expectedErrors {
|
||||
if _, ok := errs[k]; !ok {
|
||||
t.Fatalf("Missing expected error key %q in %v", k, errs)
|
||||
}
|
||||
}
|
||||
|
||||
expectedEmails := 1
|
||||
if len(s.expectedErrors) > 0 {
|
||||
expectedEmails = 0
|
||||
}
|
||||
|
||||
if app.TestMailer.TotalSend() != expectedEmails {
|
||||
t.Fatalf("Expected %d email(s) to be sent, got %d", expectedEmails, app.TestMailer.TotalSend())
|
||||
}
|
||||
|
||||
if len(s.expectedErrors) > 0 {
|
||||
return
|
||||
}
|
||||
|
||||
var expectedContent string
|
||||
switch s.template {
|
||||
case forms.TestTemplatePasswordReset:
|
||||
expectedContent = "Reset password"
|
||||
case forms.TestTemplateEmailChange:
|
||||
expectedContent = "Confirm new email"
|
||||
case forms.TestTemplateVerification:
|
||||
expectedContent = "Verify"
|
||||
case forms.TestTemplateOTP:
|
||||
expectedContent = "one-time password"
|
||||
case forms.TestTemplateAuthAlert:
|
||||
expectedContent = "from a new location"
|
||||
default:
|
||||
expectedContent = "__UNKNOWN_TEMPLATE__"
|
||||
}
|
||||
|
||||
if !strings.Contains(app.TestMailer.LastMessage().HTML, expectedContent) {
|
||||
t.Errorf("Expected the email to contains %q, got\n%v", expectedContent, app.TestMailer.LastMessage().HTML)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package forms
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tools/filesystem"
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
)
|
||||
|
||||
const (
|
||||
s3FilesystemStorage = "storage"
|
||||
s3FilesystemBackups = "backups"
|
||||
)
|
||||
|
||||
// TestS3Filesystem defines a S3 filesystem connection test.
|
||||
type TestS3Filesystem struct {
|
||||
app core.App
|
||||
|
||||
// The name of the filesystem - storage or backups
|
||||
Filesystem string `form:"filesystem" json:"filesystem"`
|
||||
|
||||
// Optional S3 config fields to test unsaved settings
|
||||
// If provided, these override the saved settings for testing
|
||||
Bucket string `form:"bucket" json:"bucket"`
|
||||
Region string `form:"region" json:"region"`
|
||||
Endpoint string `form:"endpoint" json:"endpoint"`
|
||||
AccessKey string `form:"accessKey" json:"accessKey"`
|
||||
Secret string `form:"secret" json:"secret"`
|
||||
ForcePathStyle bool `form:"forcePathStyle" json:"forcePathStyle"`
|
||||
}
|
||||
|
||||
// NewTestS3Filesystem creates and initializes new TestS3Filesystem form.
|
||||
func NewTestS3Filesystem(app core.App) *TestS3Filesystem {
|
||||
return &TestS3Filesystem{app: app}
|
||||
}
|
||||
|
||||
// Validate makes the form validatable by implementing [validation.Validatable] interface.
|
||||
func (form *TestS3Filesystem) Validate() error {
|
||||
return validation.ValidateStruct(form,
|
||||
validation.Field(
|
||||
&form.Filesystem,
|
||||
validation.Required,
|
||||
validation.In(s3FilesystemStorage, s3FilesystemBackups),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// Submit validates and performs a S3 filesystem connection test.
|
||||
func (form *TestS3Filesystem) Submit() error {
|
||||
if err := form.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var s3Config core.S3Config
|
||||
|
||||
// Check if form fields are provided (for testing unsaved settings)
|
||||
if form.Endpoint != "" {
|
||||
// Use the form fields directly
|
||||
s3Config = core.S3Config{
|
||||
Enabled: true,
|
||||
Bucket: form.Bucket,
|
||||
Region: form.Region,
|
||||
Endpoint: form.Endpoint,
|
||||
AccessKey: form.AccessKey,
|
||||
Secret: form.Secret,
|
||||
ForcePathStyle: form.ForcePathStyle,
|
||||
}
|
||||
|
||||
// If secret is not provided, use the saved secret from settings
|
||||
if s3Config.Secret == "" {
|
||||
if form.Filesystem == s3FilesystemBackups {
|
||||
s3Config.Secret = form.app.Settings().Backups.S3.Secret
|
||||
} else {
|
||||
s3Config.Secret = form.app.Settings().S3.Secret
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Fall back to saved settings
|
||||
if form.Filesystem == s3FilesystemBackups {
|
||||
s3Config = form.app.Settings().Backups.S3
|
||||
} else {
|
||||
s3Config = form.app.Settings().S3
|
||||
}
|
||||
}
|
||||
|
||||
if !s3Config.Enabled {
|
||||
return errors.New("S3 storage filesystem is not enabled")
|
||||
}
|
||||
|
||||
fsys, err := filesystem.NewS3(
|
||||
s3Config.Bucket,
|
||||
s3Config.Region,
|
||||
s3Config.Endpoint,
|
||||
s3Config.AccessKey,
|
||||
s3Config.Secret,
|
||||
s3Config.ForcePathStyle,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initialize the S3 filesystem: %w", err)
|
||||
}
|
||||
defer fsys.Close()
|
||||
|
||||
testPrefix := "pb_settings_test_" + security.PseudorandomString(5)
|
||||
testFileKey := testPrefix + "/test.txt"
|
||||
|
||||
// try to upload a test file
|
||||
if err := fsys.Upload([]byte("test"), testFileKey); err != nil {
|
||||
return fmt.Errorf("failed to upload a test file: %w", err)
|
||||
}
|
||||
|
||||
// test prefix deletion (ensures that both bucket list and delete works)
|
||||
if errs := fsys.DeletePrefix(testPrefix); len(errs) > 0 {
|
||||
return fmt.Errorf("failed to delete a test file: %w", errs[0])
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package forms_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||
"github.com/pocketbase/pocketbase/forms"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
)
|
||||
|
||||
func TestS3FilesystemValidate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scenarios := []struct {
|
||||
name string
|
||||
filesystem string
|
||||
expectedErrors []string
|
||||
}{
|
||||
{
|
||||
"empty filesystem",
|
||||
"",
|
||||
[]string{"filesystem"},
|
||||
},
|
||||
{
|
||||
"invalid filesystem",
|
||||
"something",
|
||||
[]string{"filesystem"},
|
||||
},
|
||||
{
|
||||
"backups filesystem",
|
||||
"backups",
|
||||
[]string{},
|
||||
},
|
||||
{
|
||||
"storage filesystem",
|
||||
"storage",
|
||||
[]string{},
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
t.Run(s.name, func(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
form := forms.NewTestS3Filesystem(app)
|
||||
form.Filesystem = s.filesystem
|
||||
|
||||
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(s.expectedErrors) {
|
||||
t.Fatalf("Expected error keys %v, got %v", s.expectedErrors, errs)
|
||||
}
|
||||
for _, k := range s.expectedErrors {
|
||||
if _, ok := errs[k]; !ok {
|
||||
t.Fatalf("Missing expected error key %q in %v", k, errs)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestS3FilesystemSubmitFailure(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
// check if validate was called
|
||||
{
|
||||
form := forms.NewTestS3Filesystem(app)
|
||||
form.Filesystem = ""
|
||||
|
||||
result := form.Submit()
|
||||
|
||||
if result == nil {
|
||||
t.Fatal("Expected error, got nil")
|
||||
}
|
||||
|
||||
if _, ok := result.(validation.Errors); !ok {
|
||||
t.Fatalf("Expected validation.Error, got %v", result)
|
||||
}
|
||||
}
|
||||
|
||||
// check with valid storage and disabled s3
|
||||
{
|
||||
form := forms.NewTestS3Filesystem(app)
|
||||
form.Filesystem = "storage"
|
||||
|
||||
result := form.Submit()
|
||||
|
||||
if result == nil {
|
||||
t.Fatal("Expected error, got nil")
|
||||
}
|
||||
|
||||
if _, ok := result.(validation.Error); ok {
|
||||
t.Fatalf("Didn't expect validation.Error, got %v", result)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user