1
0
mirror of https://github.com/pocketbase/pocketbase.git synced 2025-03-21 06:36:27 +02:00
pocketbase/plugins/migratecmd/migratecmd_test.go

1338 lines
40 KiB
Go

package migratecmd_test
import (
"os"
"path/filepath"
"regexp"
"strings"
"testing"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/plugins/migratecmd"
"github.com/pocketbase/pocketbase/tests"
"github.com/pocketbase/pocketbase/tools/list"
"github.com/pocketbase/pocketbase/tools/types"
)
func TestAutomigrateCollectionCreate(t *testing.T) {
t.Parallel()
scenarios := []struct {
lang string
expectedTemplate string
}{
{
migratecmd.TemplateLangJS,
`
/// <reference path="../pb_data/types.d.ts" />
migrate((app) => {
const collection = new Collection({
"authAlert": {
"emailTemplate": {
"body": "<p>Hello,</p>\n<p>We noticed a login to your {APP_NAME} account from a new location.</p>\n<p>If this was you, you may disregard this email.</p>\n<p><strong>If this wasn't you, you should immediately change your {APP_NAME} account password to revoke access from all other locations.</strong></p>\n<p>\n Thanks,<br/>\n {APP_NAME} team\n</p>",
"subject": "Login from a new location"
},
"enabled": true
},
"authRule": "",
"authToken": {
"duration": 604800
},
"confirmEmailChangeTemplate": {
"body": "<p>Hello,</p>\n<p>Click on the button below to confirm your new email address.</p>\n<p>\n <a class=\"btn\" href=\"{APP_URL}/_/#/auth/confirm-email-change/{TOKEN}\" target=\"_blank\" rel=\"noopener\">Confirm new email</a>\n</p>\n<p><i>If you didn't ask to change your email address, you can ignore this email.</i></p>\n<p>\n Thanks,<br/>\n {APP_NAME} team\n</p>",
"subject": "Confirm your {APP_NAME} new email address"
},
"createRule": null,
"deleteRule": null,
"emailChangeToken": {
"duration": 1800
},
"fields": [
{
"autogeneratePattern": "[a-z0-9]{15}",
"hidden": false,
"id": "text@TEST_RANDOM",
"max": 15,
"min": 15,
"name": "id",
"pattern": "^[a-z0-9]+$",
"presentable": false,
"primaryKey": true,
"required": true,
"system": true,
"type": "text"
},
{
"cost": 0,
"hidden": true,
"id": "password@TEST_RANDOM",
"max": 0,
"min": 8,
"name": "password",
"pattern": "",
"presentable": false,
"required": true,
"system": true,
"type": "password"
},
{
"autogeneratePattern": "[a-zA-Z0-9]{50}",
"hidden": true,
"id": "text@TEST_RANDOM",
"max": 60,
"min": 30,
"name": "tokenKey",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": true,
"system": true,
"type": "text"
},
{
"exceptDomains": null,
"hidden": false,
"id": "email@TEST_RANDOM",
"name": "email",
"onlyDomains": null,
"presentable": false,
"required": true,
"system": true,
"type": "email"
},
{
"hidden": false,
"id": "bool@TEST_RANDOM",
"name": "emailVisibility",
"presentable": false,
"required": false,
"system": true,
"type": "bool"
},
{
"hidden": false,
"id": "bool@TEST_RANDOM",
"name": "verified",
"presentable": false,
"required": false,
"system": true,
"type": "bool"
}
],
"fileToken": {
"duration": 180
},
"id": "@TEST_RANDOM",
"indexes": [
"create index test on new_name (id)",
"CREATE UNIQUE INDEX ` + "`" + `idx_tokenKey_@TEST_RANDOM` + "`" + ` ON ` + "`" + `new_name` + "`" + ` (` + "`" + `tokenKey` + "`" + `)",
"CREATE UNIQUE INDEX ` + "`" + `idx_email_@TEST_RANDOM` + "`" + ` ON ` + "`" + `new_name` + "`" + ` (` + "`" + `email` + "`" + `) WHERE ` + "`" + `email` + "`" + ` != ''"
],
"listRule": "@request.auth.id != '' && 1 > 0 || 'backtick` + "`" + `test' = 0",
"manageRule": "1 != 2",
"mfa": {
"duration": 1800,
"enabled": false,
"rule": ""
},
"name": "new_name",
"oauth2": {
"enabled": false,
"mappedFields": {
"avatarURL": "",
"id": "",
"name": "",
"username": ""
}
},
"otp": {
"duration": 180,
"emailTemplate": {
"body": "<p>Hello,</p>\n<p>Your one-time password is: <strong>{OTP}</strong></p>\n<p><i>If you didn't ask for the one-time password, you can ignore this email.</i></p>\n<p>\n Thanks,<br/>\n {APP_NAME} team\n</p>",
"subject": "OTP for {APP_NAME}"
},
"enabled": false,
"length": 8
},
"passwordAuth": {
"enabled": true,
"identityFields": [
"email"
]
},
"passwordResetToken": {
"duration": 1800
},
"resetPasswordTemplate": {
"body": "<p>Hello,</p>\n<p>Click on the button below to reset your password.</p>\n<p>\n <a class=\"btn\" href=\"{APP_URL}/_/#/auth/confirm-password-reset/{TOKEN}\" target=\"_blank\" rel=\"noopener\">Reset password</a>\n</p>\n<p><i>If you didn't ask to reset your password, you can ignore this email.</i></p>\n<p>\n Thanks,<br/>\n {APP_NAME} team\n</p>",
"subject": "Reset your {APP_NAME} password"
},
"system": true,
"type": "auth",
"updateRule": null,
"verificationTemplate": {
"body": "<p>Hello,</p>\n<p>Thank you for joining us at {APP_NAME}.</p>\n<p>Click on the button below to verify your email address.</p>\n<p>\n <a class=\"btn\" href=\"{APP_URL}/_/#/auth/confirm-verification/{TOKEN}\" target=\"_blank\" rel=\"noopener\">Verify</a>\n</p>\n<p>\n Thanks,<br/>\n {APP_NAME} team\n</p>",
"subject": "Verify your {APP_NAME} email"
},
"verificationToken": {
"duration": 259200
},
"viewRule": "id = \"1\""
});
return app.save(collection);
}, (app) => {
const collection = app.findCollectionByNameOrId("@TEST_RANDOM");
return app.delete(collection);
})
`,
},
{
migratecmd.TemplateLangGo,
`
package _test_migrations
import (
"encoding/json"
"github.com/pocketbase/pocketbase/core"
m "github.com/pocketbase/pocketbase/migrations"
)
func init() {
m.Register(func(app core.App) error {
jsonData := ` + "`" + `{
"authAlert": {
"emailTemplate": {
"body": "<p>Hello,</p>\n<p>We noticed a login to your {APP_NAME} account from a new location.</p>\n<p>If this was you, you may disregard this email.</p>\n<p><strong>If this wasn't you, you should immediately change your {APP_NAME} account password to revoke access from all other locations.</strong></p>\n<p>\n Thanks,<br/>\n {APP_NAME} team\n</p>",
"subject": "Login from a new location"
},
"enabled": true
},
"authRule": "",
"authToken": {
"duration": 604800
},
"confirmEmailChangeTemplate": {
"body": "<p>Hello,</p>\n<p>Click on the button below to confirm your new email address.</p>\n<p>\n <a class=\"btn\" href=\"{APP_URL}/_/#/auth/confirm-email-change/{TOKEN}\" target=\"_blank\" rel=\"noopener\">Confirm new email</a>\n</p>\n<p><i>If you didn't ask to change your email address, you can ignore this email.</i></p>\n<p>\n Thanks,<br/>\n {APP_NAME} team\n</p>",
"subject": "Confirm your {APP_NAME} new email address"
},
"createRule": null,
"deleteRule": null,
"emailChangeToken": {
"duration": 1800
},
"fields": [
{
"autogeneratePattern": "[a-z0-9]{15}",
"hidden": false,
"id": "text@TEST_RANDOM",
"max": 15,
"min": 15,
"name": "id",
"pattern": "^[a-z0-9]+$",
"presentable": false,
"primaryKey": true,
"required": true,
"system": true,
"type": "text"
},
{
"cost": 0,
"hidden": true,
"id": "password@TEST_RANDOM",
"max": 0,
"min": 8,
"name": "password",
"pattern": "",
"presentable": false,
"required": true,
"system": true,
"type": "password"
},
{
"autogeneratePattern": "[a-zA-Z0-9]{50}",
"hidden": true,
"id": "text@TEST_RANDOM",
"max": 60,
"min": 30,
"name": "tokenKey",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": true,
"system": true,
"type": "text"
},
{
"exceptDomains": null,
"hidden": false,
"id": "email@TEST_RANDOM",
"name": "email",
"onlyDomains": null,
"presentable": false,
"required": true,
"system": true,
"type": "email"
},
{
"hidden": false,
"id": "bool@TEST_RANDOM",
"name": "emailVisibility",
"presentable": false,
"required": false,
"system": true,
"type": "bool"
},
{
"hidden": false,
"id": "bool@TEST_RANDOM",
"name": "verified",
"presentable": false,
"required": false,
"system": true,
"type": "bool"
}
],
"fileToken": {
"duration": 180
},
"id": "@TEST_RANDOM",
"indexes": [
"create index test on new_name (id)",
"CREATE UNIQUE INDEX ` + "` + \"`\" + `" + `idx_tokenKey_@TEST_RANDOM` + "` + \"`\" + `" + ` ON ` + "` + \"`\" + `" + `new_name` + "` + \"`\" + `" + ` (` + "` + \"`\" + `" + `tokenKey` + "` + \"`\" + `" + `)",
"CREATE UNIQUE INDEX ` + "` + \"`\" + `" + `idx_email_@TEST_RANDOM` + "` + \"`\" + `" + ` ON ` + "` + \"`\" + `" + `new_name` + "` + \"`\" + `" + ` (` + "` + \"`\" + `" + `email` + "` + \"`\" + `" + `) WHERE ` + "` + \"`\" + `" + `email` + "` + \"`\" + `" + ` != ''"
],
"listRule": "@request.auth.id != '' && 1 > 0 || 'backtick` + "` + \"`\" + `" + `test' = 0",
"manageRule": "1 != 2",
"mfa": {
"duration": 1800,
"enabled": false,
"rule": ""
},
"name": "new_name",
"oauth2": {
"enabled": false,
"mappedFields": {
"avatarURL": "",
"id": "",
"name": "",
"username": ""
}
},
"otp": {
"duration": 180,
"emailTemplate": {
"body": "<p>Hello,</p>\n<p>Your one-time password is: <strong>{OTP}</strong></p>\n<p><i>If you didn't ask for the one-time password, you can ignore this email.</i></p>\n<p>\n Thanks,<br/>\n {APP_NAME} team\n</p>",
"subject": "OTP for {APP_NAME}"
},
"enabled": false,
"length": 8
},
"passwordAuth": {
"enabled": true,
"identityFields": [
"email"
]
},
"passwordResetToken": {
"duration": 1800
},
"resetPasswordTemplate": {
"body": "<p>Hello,</p>\n<p>Click on the button below to reset your password.</p>\n<p>\n <a class=\"btn\" href=\"{APP_URL}/_/#/auth/confirm-password-reset/{TOKEN}\" target=\"_blank\" rel=\"noopener\">Reset password</a>\n</p>\n<p><i>If you didn't ask to reset your password, you can ignore this email.</i></p>\n<p>\n Thanks,<br/>\n {APP_NAME} team\n</p>",
"subject": "Reset your {APP_NAME} password"
},
"system": true,
"type": "auth",
"updateRule": null,
"verificationTemplate": {
"body": "<p>Hello,</p>\n<p>Thank you for joining us at {APP_NAME}.</p>\n<p>Click on the button below to verify your email address.</p>\n<p>\n <a class=\"btn\" href=\"{APP_URL}/_/#/auth/confirm-verification/{TOKEN}\" target=\"_blank\" rel=\"noopener\">Verify</a>\n</p>\n<p>\n Thanks,<br/>\n {APP_NAME} team\n</p>",
"subject": "Verify your {APP_NAME} email"
},
"verificationToken": {
"duration": 259200
},
"viewRule": "id = \"1\""
}` + "`" + `
collection := &core.Collection{}
if err := json.Unmarshal([]byte(jsonData), &collection); err != nil {
return err
}
return app.Save(collection)
}, func(app core.App) error {
collection, err := app.FindCollectionByNameOrId("@TEST_RANDOM")
if err != nil {
return err
}
return app.Delete(collection)
})
}
`,
},
}
for _, s := range scenarios {
t.Run(s.lang, func(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
migrationsDir := filepath.Join(app.DataDir(), "_test_migrations")
migratecmd.MustRegister(app, nil, migratecmd.Config{
TemplateLang: s.lang,
Automigrate: true,
Dir: migrationsDir,
})
app.Bootstrap()
collection := core.NewAuthCollection("new_name")
collection.System = true
collection.ListRule = types.Pointer("@request.auth.id != '' && 1 > 0 || 'backtick`test' = 0")
collection.ViewRule = types.Pointer(`id = "1"`)
collection.Indexes = types.JSONArray[string]{"create index test on new_name (id)"}
collection.ManageRule = types.Pointer("1 != 2")
// should be ignored
collection.OAuth2.Providers = []core.OAuth2ProviderConfig{{Name: "gitlab", ClientId: "abc", ClientSecret: "123"}}
testSecret := strings.Repeat("a", 30)
collection.AuthToken.Secret = testSecret
collection.FileToken.Secret = testSecret
collection.EmailChangeToken.Secret = testSecret
collection.PasswordResetToken.Secret = testSecret
collection.VerificationToken.Secret = testSecret
// save the newly created dummy collection (with mock request event)
event := new(core.CollectionRequestEvent)
event.RequestEvent = &core.RequestEvent{}
event.App = app
event.Collection = collection
err := app.OnCollectionCreateRequest().Trigger(event, func(e *core.CollectionRequestEvent) error {
return e.App.Save(e.Collection)
})
if err != nil {
t.Fatalf("Failed to save the created dummy collection, got: %v", err)
}
files, err := os.ReadDir(migrationsDir)
if err != nil {
t.Fatalf("Expected migrationsDir to be created, got %v", err)
}
if total := len(files); total != 1 {
t.Fatalf("Expected 1 file to be generated, got %d: %v", total, files)
}
expectedName := "_created_new_name." + s.lang
if !strings.Contains(files[0].Name(), expectedName) {
t.Fatalf("Expected filename to contains %q, got %q", expectedName, files[0].Name())
}
fullPath := filepath.Join(migrationsDir, files[0].Name())
content, err := os.ReadFile(fullPath)
if err != nil {
t.Fatalf("Failed to read the generated migration file: %v", err)
}
contentStr := strings.TrimSpace(string(content))
// replace @TEST_RANDOM placeholder with a regex pattern
expectedTemplate := strings.ReplaceAll(
"^"+regexp.QuoteMeta(strings.TrimSpace(s.expectedTemplate))+"$",
"@TEST_RANDOM",
`\w+`,
)
if !list.ExistInSliceWithRegex(contentStr, []string{expectedTemplate}) {
t.Fatalf("Expected template \n%v \ngot \n%v", s.expectedTemplate, contentStr)
}
})
}
}
func TestAutomigrateCollectionDelete(t *testing.T) {
t.Parallel()
scenarios := []struct {
lang string
expectedTemplate string
}{
{
migratecmd.TemplateLangJS,
`
/// <reference path="../pb_data/types.d.ts" />
migrate((app) => {
const collection = app.findCollectionByNameOrId("@TEST_RANDOM");
return app.delete(collection);
}, (app) => {
const collection = new Collection({
"authAlert": {
"emailTemplate": {
"body": "<p>Hello,</p>\n<p>We noticed a login to your {APP_NAME} account from a new location.</p>\n<p>If this was you, you may disregard this email.</p>\n<p><strong>If this wasn't you, you should immediately change your {APP_NAME} account password to revoke access from all other locations.</strong></p>\n<p>\n Thanks,<br/>\n {APP_NAME} team\n</p>",
"subject": "Login from a new location"
},
"enabled": true
},
"authRule": "",
"authToken": {
"duration": 604800
},
"confirmEmailChangeTemplate": {
"body": "<p>Hello,</p>\n<p>Click on the button below to confirm your new email address.</p>\n<p>\n <a class=\"btn\" href=\"{APP_URL}/_/#/auth/confirm-email-change/{TOKEN}\" target=\"_blank\" rel=\"noopener\">Confirm new email</a>\n</p>\n<p><i>If you didn't ask to change your email address, you can ignore this email.</i></p>\n<p>\n Thanks,<br/>\n {APP_NAME} team\n</p>",
"subject": "Confirm your {APP_NAME} new email address"
},
"createRule": null,
"deleteRule": null,
"emailChangeToken": {
"duration": 1800
},
"fields": [
{
"autogeneratePattern": "[a-z0-9]{15}",
"hidden": false,
"id": "text@TEST_RANDOM",
"max": 15,
"min": 15,
"name": "id",
"pattern": "^[a-z0-9]+$",
"presentable": false,
"primaryKey": true,
"required": true,
"system": true,
"type": "text"
},
{
"cost": 0,
"hidden": true,
"id": "password@TEST_RANDOM",
"max": 0,
"min": 8,
"name": "password",
"pattern": "",
"presentable": false,
"required": true,
"system": true,
"type": "password"
},
{
"autogeneratePattern": "[a-zA-Z0-9]{50}",
"hidden": true,
"id": "text@TEST_RANDOM",
"max": 60,
"min": 30,
"name": "tokenKey",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": true,
"system": true,
"type": "text"
},
{
"exceptDomains": null,
"hidden": false,
"id": "email3885137012",
"name": "email",
"onlyDomains": null,
"presentable": false,
"required": true,
"system": true,
"type": "email"
},
{
"hidden": false,
"id": "bool@TEST_RANDOM",
"name": "emailVisibility",
"presentable": false,
"required": false,
"system": true,
"type": "bool"
},
{
"hidden": false,
"id": "bool256245529",
"name": "verified",
"presentable": false,
"required": false,
"system": true,
"type": "bool"
}
],
"fileToken": {
"duration": 180
},
"id": "@TEST_RANDOM",
"indexes": [
"create index test on test123 (id)",
"CREATE UNIQUE INDEX ` + "`" + `idx_tokenKey_@TEST_RANDOM` + "`" + ` ON ` + "`" + `test123` + "`" + ` (` + "`" + `tokenKey` + "`" + `)",
"CREATE UNIQUE INDEX ` + "`" + `idx_email_@TEST_RANDOM` + "`" + ` ON ` + "`" + `test123` + "`" + ` (` + "`" + `email` + "`" + `) WHERE ` + "`" + `email` + "`" + ` != ''"
],
"listRule": "@request.auth.id != '' && 1 > 0 || 'backtick` + "`" + `test' = 0",
"manageRule": "1 != 2",
"mfa": {
"duration": 1800,
"enabled": false,
"rule": ""
},
"name": "test123",
"oauth2": {
"enabled": false,
"mappedFields": {
"avatarURL": "",
"id": "",
"name": "",
"username": ""
}
},
"otp": {
"duration": 180,
"emailTemplate": {
"body": "<p>Hello,</p>\n<p>Your one-time password is: <strong>{OTP}</strong></p>\n<p><i>If you didn't ask for the one-time password, you can ignore this email.</i></p>\n<p>\n Thanks,<br/>\n {APP_NAME} team\n</p>",
"subject": "OTP for {APP_NAME}"
},
"enabled": false,
"length": 8
},
"passwordAuth": {
"enabled": true,
"identityFields": [
"email"
]
},
"passwordResetToken": {
"duration": 1800
},
"resetPasswordTemplate": {
"body": "<p>Hello,</p>\n<p>Click on the button below to reset your password.</p>\n<p>\n <a class=\"btn\" href=\"{APP_URL}/_/#/auth/confirm-password-reset/{TOKEN}\" target=\"_blank\" rel=\"noopener\">Reset password</a>\n</p>\n<p><i>If you didn't ask to reset your password, you can ignore this email.</i></p>\n<p>\n Thanks,<br/>\n {APP_NAME} team\n</p>",
"subject": "Reset your {APP_NAME} password"
},
"system": false,
"type": "auth",
"updateRule": null,
"verificationTemplate": {
"body": "<p>Hello,</p>\n<p>Thank you for joining us at {APP_NAME}.</p>\n<p>Click on the button below to verify your email address.</p>\n<p>\n <a class=\"btn\" href=\"{APP_URL}/_/#/auth/confirm-verification/{TOKEN}\" target=\"_blank\" rel=\"noopener\">Verify</a>\n</p>\n<p>\n Thanks,<br/>\n {APP_NAME} team\n</p>",
"subject": "Verify your {APP_NAME} email"
},
"verificationToken": {
"duration": 259200
},
"viewRule": "id = \"1\""
});
return app.save(collection);
})
`,
},
{
migratecmd.TemplateLangGo,
`
package _test_migrations
import (
"encoding/json"
"github.com/pocketbase/pocketbase/core"
m "github.com/pocketbase/pocketbase/migrations"
)
func init() {
m.Register(func(app core.App) error {
collection, err := app.FindCollectionByNameOrId("@TEST_RANDOM")
if err != nil {
return err
}
return app.Delete(collection)
}, func(app core.App) error {
jsonData := ` + "`" + `{
"authAlert": {
"emailTemplate": {
"body": "<p>Hello,</p>\n<p>We noticed a login to your {APP_NAME} account from a new location.</p>\n<p>If this was you, you may disregard this email.</p>\n<p><strong>If this wasn't you, you should immediately change your {APP_NAME} account password to revoke access from all other locations.</strong></p>\n<p>\n Thanks,<br/>\n {APP_NAME} team\n</p>",
"subject": "Login from a new location"
},
"enabled": true
},
"authRule": "",
"authToken": {
"duration": 604800
},
"confirmEmailChangeTemplate": {
"body": "<p>Hello,</p>\n<p>Click on the button below to confirm your new email address.</p>\n<p>\n <a class=\"btn\" href=\"{APP_URL}/_/#/auth/confirm-email-change/{TOKEN}\" target=\"_blank\" rel=\"noopener\">Confirm new email</a>\n</p>\n<p><i>If you didn't ask to change your email address, you can ignore this email.</i></p>\n<p>\n Thanks,<br/>\n {APP_NAME} team\n</p>",
"subject": "Confirm your {APP_NAME} new email address"
},
"createRule": null,
"deleteRule": null,
"emailChangeToken": {
"duration": 1800
},
"fields": [
{
"autogeneratePattern": "[a-z0-9]{15}",
"hidden": false,
"id": "text@TEST_RANDOM",
"max": 15,
"min": 15,
"name": "id",
"pattern": "^[a-z0-9]+$",
"presentable": false,
"primaryKey": true,
"required": true,
"system": true,
"type": "text"
},
{
"cost": 0,
"hidden": true,
"id": "password@TEST_RANDOM",
"max": 0,
"min": 8,
"name": "password",
"pattern": "",
"presentable": false,
"required": true,
"system": true,
"type": "password"
},
{
"autogeneratePattern": "[a-zA-Z0-9]{50}",
"hidden": true,
"id": "text@TEST_RANDOM",
"max": 60,
"min": 30,
"name": "tokenKey",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": true,
"system": true,
"type": "text"
},
{
"exceptDomains": null,
"hidden": false,
"id": "email3885137012",
"name": "email",
"onlyDomains": null,
"presentable": false,
"required": true,
"system": true,
"type": "email"
},
{
"hidden": false,
"id": "bool@TEST_RANDOM",
"name": "emailVisibility",
"presentable": false,
"required": false,
"system": true,
"type": "bool"
},
{
"hidden": false,
"id": "bool256245529",
"name": "verified",
"presentable": false,
"required": false,
"system": true,
"type": "bool"
}
],
"fileToken": {
"duration": 180
},
"id": "@TEST_RANDOM",
"indexes": [
"create index test on test123 (id)",
"CREATE UNIQUE INDEX ` + "` + \"`\" + `" + `idx_tokenKey_@TEST_RANDOM` + "` + \"`\" + `" + ` ON ` + "` + \"`\" + `" + `test123` + "` + \"`\" + `" + ` (` + "` + \"`\" + `" + `tokenKey` + "` + \"`\" + `" + `)",
"CREATE UNIQUE INDEX ` + "` + \"`\" + `" + `idx_email_@TEST_RANDOM` + "` + \"`\" + `" + ` ON ` + "` + \"`\" + `" + `test123` + "` + \"`\" + `" + ` (` + "` + \"`\" + `" + `email` + "` + \"`\" + `" + `) WHERE ` + "` + \"`\" + `" + `email` + "` + \"`\" + `" + ` != ''"
],
"listRule": "@request.auth.id != '' && 1 > 0 || 'backtick` + "` + \"`\" + `" + `test' = 0",
"manageRule": "1 != 2",
"mfa": {
"duration": 1800,
"enabled": false,
"rule": ""
},
"name": "test123",
"oauth2": {
"enabled": false,
"mappedFields": {
"avatarURL": "",
"id": "",
"name": "",
"username": ""
}
},
"otp": {
"duration": 180,
"emailTemplate": {
"body": "<p>Hello,</p>\n<p>Your one-time password is: <strong>{OTP}</strong></p>\n<p><i>If you didn't ask for the one-time password, you can ignore this email.</i></p>\n<p>\n Thanks,<br/>\n {APP_NAME} team\n</p>",
"subject": "OTP for {APP_NAME}"
},
"enabled": false,
"length": 8
},
"passwordAuth": {
"enabled": true,
"identityFields": [
"email"
]
},
"passwordResetToken": {
"duration": 1800
},
"resetPasswordTemplate": {
"body": "<p>Hello,</p>\n<p>Click on the button below to reset your password.</p>\n<p>\n <a class=\"btn\" href=\"{APP_URL}/_/#/auth/confirm-password-reset/{TOKEN}\" target=\"_blank\" rel=\"noopener\">Reset password</a>\n</p>\n<p><i>If you didn't ask to reset your password, you can ignore this email.</i></p>\n<p>\n Thanks,<br/>\n {APP_NAME} team\n</p>",
"subject": "Reset your {APP_NAME} password"
},
"system": false,
"type": "auth",
"updateRule": null,
"verificationTemplate": {
"body": "<p>Hello,</p>\n<p>Thank you for joining us at {APP_NAME}.</p>\n<p>Click on the button below to verify your email address.</p>\n<p>\n <a class=\"btn\" href=\"{APP_URL}/_/#/auth/confirm-verification/{TOKEN}\" target=\"_blank\" rel=\"noopener\">Verify</a>\n</p>\n<p>\n Thanks,<br/>\n {APP_NAME} team\n</p>",
"subject": "Verify your {APP_NAME} email"
},
"verificationToken": {
"duration": 259200
},
"viewRule": "id = \"1\""
}` + "`" + `
collection := &core.Collection{}
if err := json.Unmarshal([]byte(jsonData), &collection); err != nil {
return err
}
return app.Save(collection)
})
}
`,
},
}
for _, s := range scenarios {
t.Run(s.lang, func(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
migrationsDir := filepath.Join(app.DataDir(), "_test_migrations")
// create dummy collection
collection := core.NewAuthCollection("test123")
collection.ListRule = types.Pointer("@request.auth.id != '' && 1 > 0 || 'backtick`test' = 0")
collection.ViewRule = types.Pointer(`id = "1"`)
collection.Indexes = types.JSONArray[string]{"create index test on test123 (id)"}
collection.ManageRule = types.Pointer("1 != 2")
if err := app.Save(collection); err != nil {
t.Fatalf("Failed to save dummy collection, got: %v", err)
}
migratecmd.MustRegister(app, nil, migratecmd.Config{
TemplateLang: s.lang,
Automigrate: true,
Dir: migrationsDir,
})
app.Bootstrap()
// delete the newly created dummy collection (with mock request event)
event := new(core.CollectionRequestEvent)
event.RequestEvent = &core.RequestEvent{}
event.App = app
event.Collection = collection
err := app.OnCollectionDeleteRequest().Trigger(event, func(e *core.CollectionRequestEvent) error {
return e.App.Delete(e.Collection)
})
if err != nil {
t.Fatalf("Failed to delete dummy collection, got: %v", err)
}
files, err := os.ReadDir(migrationsDir)
if err != nil {
t.Fatalf("Expected migrationsDir to be created, got: %v", err)
}
if total := len(files); total != 1 {
t.Fatalf("Expected 1 file to be generated, got %d", total)
}
expectedName := "_deleted_test123." + s.lang
if !strings.Contains(files[0].Name(), expectedName) {
t.Fatalf("Expected filename to contains %q, got %q", expectedName, files[0].Name())
}
fullPath := filepath.Join(migrationsDir, files[0].Name())
content, err := os.ReadFile(fullPath)
if err != nil {
t.Fatalf("Failed to read the generated migration file: %v", err)
}
contentStr := strings.TrimSpace(string(content))
// replace @TEST_RANDOM placeholder with a regex pattern
expectedTemplate := strings.ReplaceAll(
"^"+regexp.QuoteMeta(strings.TrimSpace(s.expectedTemplate))+"$",
"@TEST_RANDOM",
`\w+`,
)
if !list.ExistInSliceWithRegex(contentStr, []string{expectedTemplate}) {
t.Fatalf("Expected template \n%v \ngot \n%v", s.expectedTemplate, contentStr)
}
})
}
}
func TestAutomigrateCollectionUpdate(t *testing.T) {
t.Parallel()
scenarios := []struct {
lang string
expectedTemplate string
}{
{
migratecmd.TemplateLangJS,
`
/// <reference path="../pb_data/types.d.ts" />
migrate((app) => {
const collection = app.findCollectionByNameOrId("@TEST_RANDOM")
// update collection data
unmarshal({
"createRule": "id = \"nil_update\"",
"deleteRule": null,
"fileToken": {
"duration": 10
},
"indexes": [
"create index test1 on test123_update (f1_name)",
"CREATE UNIQUE INDEX ` + "`" + `idx_tokenKey_@TEST_RANDOM` + "`" + ` ON ` + "`" + `test123_update` + "`" + ` (` + "`" + `tokenKey` + "`" + `)",
"CREATE UNIQUE INDEX ` + "`" + `idx_email_@TEST_RANDOM` + "`" + ` ON ` + "`" + `test123_update` + "`" + ` (` + "`" + `email` + "`" + `) WHERE ` + "`" + `email` + "`" + ` != ''"
],
"listRule": "@request.auth.id != ''",
"name": "test123_update",
"oauth2": {
"enabled": true
},
"updateRule": "id = \"2_update\""
}, collection)
// remove field
collection.fields.removeById("f3_id")
// add field
collection.fields.addAt(8, new Field({
"autogeneratePattern": "",
"hidden": false,
"id": "f4_id",
"max": 0,
"min": 0,
"name": "f4_name",
"pattern": "` + "`" + `test backtick` + "`" + `123",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
}))
// update field
collection.fields.addAt(7, new Field({
"hidden": false,
"id": "f2_id",
"max": null,
"min": 10,
"name": "f2_name_new",
"onlyInt": false,
"presentable": false,
"required": false,
"system": false,
"type": "number"
}))
return app.save(collection)
}, (app) => {
const collection = app.findCollectionByNameOrId("@TEST_RANDOM")
// update collection data
unmarshal({
"createRule": null,
"deleteRule": "id = \"3\"",
"fileToken": {
"duration": 180
},
"indexes": [
"create index test1 on test123 (f1_name)",
"CREATE UNIQUE INDEX ` + "`" + `idx_tokenKey_@TEST_RANDOM` + "`" + ` ON ` + "`" + `test123` + "`" + ` (` + "`" + `tokenKey` + "`" + `)",
"CREATE UNIQUE INDEX ` + "`" + `idx_email_@TEST_RANDOM` + "`" + ` ON ` + "`" + `test123` + "`" + ` (` + "`" + `email` + "`" + `) WHERE ` + "`" + `email` + "`" + ` != ''"
],
"listRule": "@request.auth.id != '' && 1 != 2",
"name": "test123",
"oauth2": {
"enabled": false
},
"updateRule": "id = \"2\""
}, collection)
// add field
collection.fields.addAt(8, new Field({
"hidden": false,
"id": "f3_id",
"name": "f3_name",
"presentable": false,
"required": false,
"system": false,
"type": "bool"
}))
// remove field
collection.fields.removeById("f4_id")
// update field
collection.fields.addAt(7, new Field({
"hidden": false,
"id": "f2_id",
"max": null,
"min": 10,
"name": "f2_name",
"onlyInt": false,
"presentable": false,
"required": false,
"system": false,
"type": "number"
}))
return app.save(collection)
})
`,
},
{
migratecmd.TemplateLangGo,
`
package _test_migrations
import (
"encoding/json"
"github.com/pocketbase/pocketbase/core"
m "github.com/pocketbase/pocketbase/migrations"
)
func init() {
m.Register(func(app core.App) error {
collection, err := app.FindCollectionByNameOrId("@TEST_RANDOM")
if err != nil {
return err
}
// update collection data
if err := json.Unmarshal([]byte(` + "`" + `{
"createRule": "id = \"nil_update\"",
"deleteRule": null,
"fileToken": {
"duration": 10
},
"indexes": [
"create index test1 on test123_update (f1_name)",
"CREATE UNIQUE INDEX ` + "` + \"`\" + `" + `idx_tokenKey_@TEST_RANDOM` + "` + \"`\" + `" + ` ON ` + "` + \"`\" + `" + `test123_update` + "` + \"`\" + `" + ` (` + "` + \"`\" + `" + `tokenKey` + "` + \"`\" + `" + `)",
"CREATE UNIQUE INDEX ` + "` + \"`\" + `" + `idx_email_@TEST_RANDOM` + "` + \"`\" + `" + ` ON ` + "` + \"`\" + `" + `test123_update` + "` + \"`\" + `" + ` (` + "` + \"`\" + `" + `email` + "` + \"`\" + `" + `) WHERE ` + "` + \"`\" + `" + `email` + "` + \"`\" + `" + ` != ''"
],
"listRule": "@request.auth.id != ''",
"name": "test123_update",
"oauth2": {
"enabled": true
},
"updateRule": "id = \"2_update\""
}` + "`" + `), &collection); err != nil {
return err
}
// remove field
collection.Fields.RemoveById("f3_id")
// add field
if err := collection.Fields.AddMarshaledJSONAt(8, []byte(` + "`" + `{
"autogeneratePattern": "",
"hidden": false,
"id": "f4_id",
"max": 0,
"min": 0,
"name": "f4_name",
"pattern": "` + "` + \"`\" + `" + `test backtick` + "` + \"`\" + `" + `123",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
}` + "`" + `)); err != nil {
return err
}
// update field
if err := collection.Fields.AddMarshaledJSONAt(7, []byte(` + "`" + `{
"hidden": false,
"id": "f2_id",
"max": null,
"min": 10,
"name": "f2_name_new",
"onlyInt": false,
"presentable": false,
"required": false,
"system": false,
"type": "number"
}` + "`" + `)); err != nil {
return err
}
return app.Save(collection)
}, func(app core.App) error {
collection, err := app.FindCollectionByNameOrId("@TEST_RANDOM")
if err != nil {
return err
}
// update collection data
if err := json.Unmarshal([]byte(` + "`" + `{
"createRule": null,
"deleteRule": "id = \"3\"",
"fileToken": {
"duration": 180
},
"indexes": [
"create index test1 on test123 (f1_name)",
"CREATE UNIQUE INDEX ` + "` + \"`\" + `" + `idx_tokenKey_@TEST_RANDOM` + "` + \"`\" + `" + ` ON ` + "` + \"`\" + `" + `test123` + "` + \"`\" + `" + ` (` + "` + \"`\" + `" + `tokenKey` + "` + \"`\" + `" + `)",
"CREATE UNIQUE INDEX ` + "` + \"`\" + `" + `idx_email_@TEST_RANDOM` + "` + \"`\" + `" + ` ON ` + "` + \"`\" + `" + `test123` + "` + \"`\" + `" + ` (` + "` + \"`\" + `" + `email` + "` + \"`\" + `" + `) WHERE ` + "` + \"`\" + `" + `email` + "` + \"`\" + `" + ` != ''"
],
"listRule": "@request.auth.id != '' && 1 != 2",
"name": "test123",
"oauth2": {
"enabled": false
},
"updateRule": "id = \"2\""
}` + "`" + `), &collection); err != nil {
return err
}
// add field
if err := collection.Fields.AddMarshaledJSONAt(8, []byte(` + "`" + `{
"hidden": false,
"id": "f3_id",
"name": "f3_name",
"presentable": false,
"required": false,
"system": false,
"type": "bool"
}` + "`" + `)); err != nil {
return err
}
// remove field
collection.Fields.RemoveById("f4_id")
// update field
if err := collection.Fields.AddMarshaledJSONAt(7, []byte(` + "`" + `{
"hidden": false,
"id": "f2_id",
"max": null,
"min": 10,
"name": "f2_name",
"onlyInt": false,
"presentable": false,
"required": false,
"system": false,
"type": "number"
}` + "`" + `)); err != nil {
return err
}
return app.Save(collection)
})
}
`,
},
}
for _, s := range scenarios {
t.Run(s.lang, func(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
migrationsDir := filepath.Join(app.DataDir(), "_test_migrations")
// create dummy collection
collection := core.NewAuthCollection("test123")
collection.ListRule = types.Pointer("@request.auth.id != '' && 1 != 2")
collection.ViewRule = types.Pointer(`id = "1"`)
collection.UpdateRule = types.Pointer(`id = "2"`)
collection.CreateRule = nil
collection.DeleteRule = types.Pointer(`id = "3"`)
collection.Indexes = types.JSONArray[string]{"create index test1 on test123 (f1_name)"}
collection.ManageRule = types.Pointer("1 != 2")
collection.Fields.Add(&core.TextField{
Id: "f1_id",
Name: "f1_name",
Required: true,
})
collection.Fields.Add(&core.NumberField{
Id: "f2_id",
Name: "f2_name",
Min: types.Pointer(10.0),
})
collection.Fields.Add(&core.BoolField{
Id: "f3_id",
Name: "f3_name",
})
if err := app.Save(collection); err != nil {
t.Fatalf("Failed to save dummy collection, got %v", err)
}
// init plugin
migratecmd.MustRegister(app, nil, migratecmd.Config{
TemplateLang: s.lang,
Automigrate: true,
Dir: migrationsDir,
})
app.Bootstrap()
// update the dummy collection
collection.Name = "test123_update"
collection.ListRule = types.Pointer("@request.auth.id != ''")
collection.ViewRule = types.Pointer(`id = "1"`) // no change
collection.UpdateRule = types.Pointer(`id = "2_update"`)
collection.CreateRule = types.Pointer(`id = "nil_update"`)
collection.DeleteRule = nil
collection.Indexes = types.JSONArray[string]{
"create index test1 on test123_update (f1_name)",
}
collection.Fields.RemoveById("f3_id")
collection.Fields.Add(&core.TextField{
Id: "f4_id",
Name: "f4_name",
Pattern: "`test backtick`123",
})
f := collection.Fields.GetById("f2_id")
f.SetName("f2_name_new")
collection.OAuth2.Enabled = true
collection.FileToken.Duration = 10
// should be ignored
collection.OAuth2.Providers = []core.OAuth2ProviderConfig{{Name: "gitlab", ClientId: "abc", ClientSecret: "123"}}
testSecret := strings.Repeat("b", 30)
collection.AuthToken.Secret = testSecret
collection.FileToken.Secret = testSecret
collection.EmailChangeToken.Secret = testSecret
collection.PasswordResetToken.Secret = testSecret
collection.VerificationToken.Secret = testSecret
// save the changes and trigger automigrate (with mock request event)
event := new(core.CollectionRequestEvent)
event.RequestEvent = &core.RequestEvent{}
event.App = app
event.Collection = collection
err := app.OnCollectionUpdateRequest().Trigger(event, func(e *core.CollectionRequestEvent) error {
return e.App.Save(e.Collection)
})
if err != nil {
t.Fatalf("Failed to save dummy collection changes, got %v", err)
}
files, err := os.ReadDir(migrationsDir)
if err != nil {
t.Fatalf("Expected migrationsDir to be created, got: %v", err)
}
if total := len(files); total != 1 {
t.Fatalf("Expected 1 file to be generated, got %d", total)
}
expectedName := "_updated_test123." + s.lang
if !strings.Contains(files[0].Name(), expectedName) {
t.Fatalf("Expected filename to contains %q, got %q", expectedName, files[0].Name())
}
fullPath := filepath.Join(migrationsDir, files[0].Name())
content, err := os.ReadFile(fullPath)
if err != nil {
t.Fatalf("Failed to read the generated migration file: %v", err)
}
contentStr := strings.TrimSpace(string(content))
// replace @TEST_RANDOM placeholder with a regex pattern
expectedTemplate := strings.ReplaceAll(
"^"+regexp.QuoteMeta(strings.TrimSpace(s.expectedTemplate))+"$",
"@TEST_RANDOM",
`\w+`,
)
if !list.ExistInSliceWithRegex(contentStr, []string{expectedTemplate}) {
t.Fatalf("Expected template \n%v \ngot \n%v", s.expectedTemplate, contentStr)
}
})
}
}
func TestAutomigrateCollectionNoChanges(t *testing.T) {
t.Parallel()
scenarios := []struct {
lang string
}{
{
migratecmd.TemplateLangJS,
},
{
migratecmd.TemplateLangGo,
},
}
for _, s := range scenarios {
t.Run(s.lang, func(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
migrationsDir := filepath.Join(app.DataDir(), "_test_migrations")
// create dummy collection
collection := core.NewAuthCollection("test123")
if err := app.Save(collection); err != nil {
t.Fatalf("Failed to save dummy collection, got %v", err)
}
// init plugin
migratecmd.MustRegister(app, nil, migratecmd.Config{
TemplateLang: s.lang,
Automigrate: true,
Dir: migrationsDir,
})
app.Bootstrap()
// should be ignored
collection.OAuth2.Providers = []core.OAuth2ProviderConfig{{Name: "gitlab", ClientId: "abc", ClientSecret: "123"}}
testSecret := strings.Repeat("b", 30)
collection.AuthToken.Secret = testSecret
collection.FileToken.Secret = testSecret
collection.EmailChangeToken.Secret = testSecret
collection.PasswordResetToken.Secret = testSecret
collection.VerificationToken.Secret = testSecret
// resave without other changes and trigger automigrate (with mock request event)
event := new(core.CollectionRequestEvent)
event.RequestEvent = &core.RequestEvent{}
event.App = app
event.Collection = collection
err := app.OnCollectionUpdateRequest().Trigger(event, func(e *core.CollectionRequestEvent) error {
return e.App.Save(e.Collection)
})
if err != nil {
t.Fatalf("Failed to save dummy collection update, got %v", err)
}
files, _ := os.ReadDir(migrationsDir)
if total := len(files); total != 0 {
t.Fatalf("Expected 0 files to be generated, got %d", total)
}
})
}
}