feat: use keying for task secrets (#9923)

- Follow up of forgejo/forgejo!5041, forgejo/forgejo!6074, forgejo/forgejo!8692
- The `task` table contains three secrets: clone address (with credentials), auth password and auth token. These secrets are stored for migrating repositories (also the only usage of this table, although it allows for more usages).
- Use `keying` to safely store these secrets and bound them to the table, column, row id and JSON field name.
- The migration isn't spectacular but does closely follow what we learned in the previous two migrations: use a transaction and delete records when you can't decrypt them. We also learned about `db.Iterate` not being happy when updating records but it has since been fixed.

Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/9923
Reviewed-by: Mathieu Fenniak <mfenniak@noreply.codeberg.org>
Reviewed-by: Earl Warren <earl-warren@noreply.codeberg.org>
Co-authored-by: Gusted <postmaster@gusted.xyz>
Co-committed-by: Gusted <postmaster@gusted.xyz>
This commit is contained in:
Gusted 2025-11-03 13:42:32 +01:00 committed by Gusted
parent d6f7e154a1
commit 0c11e9a43a
9 changed files with 392 additions and 32 deletions

View file

@ -5,15 +5,15 @@ package admin
import (
"context"
"encoding/base64"
"fmt"
"forgejo.org/models/db"
repo_model "forgejo.org/models/repo"
user_model "forgejo.org/models/user"
"forgejo.org/modules/json"
"forgejo.org/modules/keying"
"forgejo.org/modules/migration"
"forgejo.org/modules/secret"
"forgejo.org/modules/setting"
"forgejo.org/modules/structs"
"forgejo.org/modules/timeutil"
"forgejo.org/modules/util"
@ -120,21 +120,47 @@ func (task *Task) MigrateConfig() (*migration.MigrateOptions, error) {
return nil, err
}
key := keying.DeriveKey(keying.ContextMigrateTask)
// decrypt credentials
if opts.CloneAddrEncrypted != "" {
if opts.CloneAddr, err = secret.DecryptSecret(setting.SecretKey, opts.CloneAddrEncrypted); err != nil {
encryptedCloneAddr, err := base64.RawStdEncoding.DecodeString(opts.CloneAddrEncrypted)
if err != nil {
return nil, err
}
cloneAddr, err := key.Decrypt(encryptedCloneAddr, keying.ColumnAndJSONSelectorAndID("payload_content", "clone_addr_encrypted", task.ID))
if err != nil {
return nil, err
}
opts.CloneAddr = string(cloneAddr)
}
if opts.AuthPasswordEncrypted != "" {
if opts.AuthPassword, err = secret.DecryptSecret(setting.SecretKey, opts.AuthPasswordEncrypted); err != nil {
encryptedAuthPassword, err := base64.RawStdEncoding.DecodeString(opts.AuthPasswordEncrypted)
if err != nil {
return nil, err
}
authPassword, err := key.Decrypt(encryptedAuthPassword, keying.ColumnAndJSONSelectorAndID("payload_content", "auth_password_encrypted", task.ID))
if err != nil {
return nil, err
}
opts.AuthPassword = string(authPassword)
}
if opts.AuthTokenEncrypted != "" {
if opts.AuthToken, err = secret.DecryptSecret(setting.SecretKey, opts.AuthTokenEncrypted); err != nil {
encryptedAuthToken, err := base64.RawStdEncoding.DecodeString(opts.AuthTokenEncrypted)
if err != nil {
return nil, err
}
authToken, err := key.Decrypt(encryptedAuthToken, keying.ColumnAndJSONSelectorAndID("payload_content", "auth_token_encrypted", task.ID))
if err != nil {
return nil, err
}
opts.AuthToken = string(authToken)
}
return &opts, nil

View file

@ -0,0 +1,114 @@
// Copyright 2025 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: GPL-3.0-or-later
package forgejo_migrations
import (
"context"
"encoding/base64"
"fmt"
admin_model "forgejo.org/models/admin"
"forgejo.org/models/db"
"forgejo.org/modules/json"
"forgejo.org/modules/keying"
"forgejo.org/modules/log"
"forgejo.org/modules/migration"
"forgejo.org/modules/secret"
"forgejo.org/modules/setting"
"forgejo.org/modules/structs"
"xorm.io/builder"
"xorm.io/xorm"
)
func init() {
registerMigration(&Migration{
Description: "migrate columns of `task` table to store keying material",
Upgrade: migrateTaskSecrets,
})
}
func migrateTaskSecrets(x *xorm.Engine) error {
return db.WithTx(db.DefaultContext, func(ctx context.Context) error {
sess := db.GetEngine(ctx)
key := keying.DeriveKey(keying.ContextMigrateTask)
oldEncryptionKey := setting.SecretKey
messages := make([]string, 0, 100)
ids := make([]int64, 0, 100)
err := db.Iterate(ctx, builder.Eq{"type": structs.TaskTypeMigrateRepo}, func(ctx context.Context, bean *admin_model.Task) error {
var opts migration.MigrateOptions
err := json.Unmarshal([]byte(bean.PayloadContent), &opts)
if err != nil {
messages = append(messages, fmt.Sprintf("task.id=%d, task.doer_id=%d, task.repo_id=%d, task.owner_id=%d: json.Unmarshal(): %v", bean.ID, bean.DoerID, bean.RepoID, bean.OwnerID, err))
ids = append(ids, bean.ID)
return nil
}
decryptionError := false
if opts.CloneAddrEncrypted != "" {
if opts.CloneAddr, err = secret.DecryptSecret(oldEncryptionKey, opts.CloneAddrEncrypted); err != nil {
messages = append(messages, fmt.Sprintf("task.id=%d, task.doer_id=%d, task.repo_id=%d, task.owner_id=%d: secret.DecryptSecret(CloneAddrEncrypted): %v", bean.ID, bean.DoerID, bean.RepoID, bean.OwnerID, err))
ids = append(ids, bean.ID)
decryptionError = true
}
}
if opts.AuthPasswordEncrypted != "" {
if opts.AuthPassword, err = secret.DecryptSecret(oldEncryptionKey, opts.AuthPasswordEncrypted); err != nil {
messages = append(messages, fmt.Sprintf("task.id=%d, task.doer_id=%d, task.repo_id=%d, task.owner_id=%d: secret.DecryptSecret(AuthPasswordEncrypted): %v", bean.ID, bean.DoerID, bean.RepoID, bean.OwnerID, err))
ids = append(ids, bean.ID)
decryptionError = true
}
}
if opts.AuthTokenEncrypted != "" {
if opts.AuthToken, err = secret.DecryptSecret(oldEncryptionKey, opts.AuthTokenEncrypted); err != nil {
messages = append(messages, fmt.Sprintf("task.id=%d, task.doer_id=%d, task.repo_id=%d, task.owner_id=%d: secret.DecryptSecret(AuthTokenEncrypted): %v", bean.ID, bean.DoerID, bean.RepoID, bean.OwnerID, err))
ids = append(ids, bean.ID)
decryptionError = true
}
}
// Don't migrate a task that has a decryption error.
if decryptionError {
return nil
}
if opts.CloneAddrEncrypted != "" {
opts.CloneAddrEncrypted = base64.RawStdEncoding.EncodeToString(key.Encrypt([]byte(opts.CloneAddr), keying.ColumnAndJSONSelectorAndID("payload_content", "clone_addr_encrypted", bean.ID)))
}
if opts.AuthPasswordEncrypted != "" {
opts.AuthPasswordEncrypted = base64.RawStdEncoding.EncodeToString(key.Encrypt([]byte(opts.AuthPassword), keying.ColumnAndJSONSelectorAndID("payload_content", "auth_password_encrypted", bean.ID)))
}
if opts.AuthTokenEncrypted != "" {
opts.AuthTokenEncrypted = base64.RawStdEncoding.EncodeToString(key.Encrypt([]byte(opts.AuthToken), keying.ColumnAndJSONSelectorAndID("payload_content", "auth_token_encrypted", bean.ID)))
}
bs, err := json.Marshal(&opts)
if err != nil {
return err
}
bean.PayloadContent = string(bs)
return bean.UpdateCols(ctx, "payload_content")
})
if err == nil {
if len(ids) > 0 {
log.Error("v14a_migrate_task_secrets: The following tasks were found to be corrupted and removed from the database.")
for _, message := range messages {
log.Error("v14a_migrate_task_secrets: %s", message)
}
_, err = sess.In("id", ids).NoAutoCondition().NoAutoTime().Delete(&admin_model.Task{})
}
}
return err
})
}

View file

@ -0,0 +1,78 @@
// Copyright 2025 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: GPL-3.0-or-later
package forgejo_migrations
import (
"encoding/base64"
"testing"
migration_tests "forgejo.org/models/gitea_migrations/test"
"forgejo.org/modules/json"
"forgejo.org/modules/keying"
"forgejo.org/modules/migration"
"forgejo.org/modules/structs"
"forgejo.org/modules/timeutil"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func Test_MigrateTaskSecretsToKeying(t *testing.T) {
type Task struct {
ID int64
DoerID int64 `xorm:"index"`
OwnerID int64 `xorm:"index"`
RepoID int64 `xorm:"index"`
Type structs.TaskType
Status structs.TaskStatus `xorm:"index"`
StartTime timeutil.TimeStamp
EndTime timeutil.TimeStamp
PayloadContent string `xorm:"TEXT"`
Message string `xorm:"TEXT"`
Created timeutil.TimeStamp `xorm:"created"`
}
// Prepare and load the testing database
x, deferable := migration_tests.PrepareTestEnv(t, 0, new(Task))
defer deferable()
if x == nil || t.Failed() {
return
}
cnt, err := x.Table("task").Count()
require.NoError(t, err)
assert.EqualValues(t, 3, cnt)
require.NoError(t, migrateTaskSecrets(x))
cnt, err = x.Table("task").Count()
require.NoError(t, err)
assert.EqualValues(t, 1, cnt)
var task Task
_, err = x.Table("task").ID(1).Get(&task)
require.NoError(t, err)
var opts migration.MigrateOptions
require.NoError(t, json.Unmarshal([]byte(task.PayloadContent), &opts))
key := keying.DeriveKey(keying.ContextMigrateTask)
encryptedCloneAddr, err := base64.RawStdEncoding.DecodeString(opts.CloneAddrEncrypted)
require.NoError(t, err)
cloneAddr, err := key.Decrypt(encryptedCloneAddr, keying.ColumnAndJSONSelectorAndID("payload_content", "clone_addr_encrypted", task.ID))
require.NoError(t, err)
assert.Equal(t, "https://admin:password@example.com", string(cloneAddr))
encryptedAuthPassword, err := base64.RawStdEncoding.DecodeString(opts.AuthPasswordEncrypted)
require.NoError(t, err)
authPassword, err := key.Decrypt(encryptedAuthPassword, keying.ColumnAndJSONSelectorAndID("payload_content", "auth_password_encrypted", task.ID))
require.NoError(t, err)
assert.Equal(t, "password", string(authPassword))
encryptedAuthToken, err := base64.RawStdEncoding.DecodeString(opts.AuthTokenEncrypted)
require.NoError(t, err)
authToken, err := key.Decrypt(encryptedAuthToken, keying.ColumnAndJSONSelectorAndID("payload_content", "auth_token_encrypted", task.ID))
require.NoError(t, err)
assert.Equal(t, "token", string(authToken))
}

View file

@ -0,0 +1,38 @@
-
id: 1
doer_id: 5
owner_id: 5
repo_id: 5
type: 0
status: 4
start_time: 1761951636
end_time: 1761951636
payload_content: '{"auth_token_encrypted": "2142ac70cf41885b4a3a74f2d36a64662bdbc70f70c7f5b2", "clone_addr_encrypted": "79b9eb793d5af95af61d483566474454b880c4aa80bf3028f561ca227fccfc518b18fb9823c2fa79fa9cf0efae1eb13080e0c51f26c40622ee9d649bff0ef64b", "auth_password_encrypted": "986717ee9de0b9b1fda8afe5f64d53245d8ec4131221085f59ac7e13"}'
message: 'working'
created: 176195163
-
id: 2
doer_id: 5
owner_id: 5
repo_id: 5
type: 0
status: 4
start_time: 1761951636
end_time: 1761951636
payload_content: '{"auth_token_encrypted": "badbad", "clone_addr_encrypted": "badbad", "auth_password_encrypted": "badbad"}'
message: 'working'
created: 176195163
-
id: 3
doer_id: 5
owner_id: 5
repo_id: 5
type: 0
status: 4
start_time: 1761951636
end_time: 1761951636
payload_content: '{ badjson'
message: 'working'
created: 176195163

View file

@ -60,6 +60,8 @@ var (
ContextTOTP Context = "totp"
// Used for the `secret` table.
ContextActionSecret Context = "action_secret"
// Used for the `task` table where type == TaskTypeMigrateRepo.
ContextMigrateTask Context = "migrate_repo_task"
)
// Derive *the* key for a given context, this is a deterministic function.
@ -131,3 +133,16 @@ func (k *Key) Decrypt(ciphertext, additionalData []byte) ([]byte, error) {
func ColumnAndID(column string, id int64) []byte {
return binary.BigEndian.AppendUint64(append([]byte(column), ':'), uint64(id))
}
// ColumnAndJSONSelectorAndID generates a context that can be used as additional context
// for encrypting and decrypting data. It requires the column name, JSON
// selector and the row ID (this requires to be known beforehand). Be careful
// when using this, as the table name isn't part of this context. This means
// it's not bound to a particular table. The table should be part of the context
// that the key was derived for, in which case it binds through that. Use this
// over `ColumnAndID` if you're encrypting data that's stored inside JSON.
// jsonSelector must be a unambigous selector to the JSON field that stores the
// encrypted data.
func ColumnAndJSONSelectorAndID(column, jsonSelector string, id int64) []byte {
return binary.BigEndian.AppendUint64(append(append([]byte(column), ':'), append([]byte(jsonSelector), ':')...), uint64(id))
}

View file

@ -109,3 +109,23 @@ func TestKeyingColumnAndID(t *testing.T) {
assert.Equal(t, []byte{0x74, 0x61, 0x62, 0x6c, 0x65, 0x32, 0x3a, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01}, keying.ColumnAndID("table2", 1))
assert.Equal(t, []byte{0x74, 0x61, 0x62, 0x6c, 0x65, 0x32, 0x3a, 0x7f, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff}, keying.ColumnAndID("table2", math.MaxInt64))
}
func TestColumnAndJSONSelectorAndID(t *testing.T) {
assert.Equal(t, []byte{0x74, 0x61, 0x62, 0x6c, 0x65, 0x3a, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x31, 0x3a, 0x80, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0}, keying.ColumnAndJSONSelectorAndID("table", "field1", math.MinInt64))
assert.Equal(t, []byte{0x74, 0x61, 0x62, 0x6c, 0x65, 0x3a, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x31, 0x3a, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff}, keying.ColumnAndJSONSelectorAndID("table", "field1", -1))
assert.Equal(t, []byte{0x74, 0x61, 0x62, 0x6c, 0x65, 0x3a, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x31, 0x3a, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0}, keying.ColumnAndJSONSelectorAndID("table", "field1", 0))
assert.Equal(t, []byte{0x74, 0x61, 0x62, 0x6c, 0x65, 0x3a, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x31, 0x3a, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x1}, keying.ColumnAndJSONSelectorAndID("table", "field1", 1))
assert.Equal(t, []byte{0x74, 0x61, 0x62, 0x6c, 0x65, 0x3a, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x31, 0x3a, 0x7f, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff}, keying.ColumnAndJSONSelectorAndID("table", "field1", math.MaxInt64))
assert.Equal(t, []byte{0x74, 0x61, 0x62, 0x6c, 0x65, 0x3a, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x32, 0x3a, 0x80, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0}, keying.ColumnAndJSONSelectorAndID("table", "field2", math.MinInt64))
assert.Equal(t, []byte{0x74, 0x61, 0x62, 0x6c, 0x65, 0x3a, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x32, 0x3a, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff}, keying.ColumnAndJSONSelectorAndID("table", "field2", -1))
assert.Equal(t, []byte{0x74, 0x61, 0x62, 0x6c, 0x65, 0x3a, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x32, 0x3a, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0}, keying.ColumnAndJSONSelectorAndID("table", "field2", 0))
assert.Equal(t, []byte{0x74, 0x61, 0x62, 0x6c, 0x65, 0x3a, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x32, 0x3a, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x1}, keying.ColumnAndJSONSelectorAndID("table", "field2", 1))
assert.Equal(t, []byte{0x74, 0x61, 0x62, 0x6c, 0x65, 0x3a, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x32, 0x3a, 0x7f, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff}, keying.ColumnAndJSONSelectorAndID("table", "field2", math.MaxInt64))
assert.Equal(t, []byte{0x74, 0x61, 0x62, 0x6c, 0x65, 0x32, 0x3a, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x31, 0x3a, 0x80, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0}, keying.ColumnAndJSONSelectorAndID("table2", "field1", math.MinInt64))
assert.Equal(t, []byte{0x74, 0x61, 0x62, 0x6c, 0x65, 0x32, 0x3a, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x31, 0x3a, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff}, keying.ColumnAndJSONSelectorAndID("table2", "field1", -1))
assert.Equal(t, []byte{0x74, 0x61, 0x62, 0x6c, 0x65, 0x32, 0x3a, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x31, 0x3a, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0}, keying.ColumnAndJSONSelectorAndID("table2", "field1", 0))
assert.Equal(t, []byte{0x74, 0x61, 0x62, 0x6c, 0x65, 0x32, 0x3a, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x31, 0x3a, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x1}, keying.ColumnAndJSONSelectorAndID("table2", "field1", 1))
assert.Equal(t, []byte{0x74, 0x61, 0x62, 0x6c, 0x65, 0x32, 0x3a, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x31, 0x3a, 0x7f, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff}, keying.ColumnAndJSONSelectorAndID("table2", "field1", math.MaxInt64))
}

View file

@ -0,0 +1,14 @@
// Copyright 2025 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: GPL-3.0-or-later
package task
import (
"testing"
"forgejo.org/models/unittest"
)
func TestMain(m *testing.M) {
unittest.MainTest(m)
}

View file

@ -5,6 +5,7 @@ package task
import (
"context"
"encoding/base64"
"errors"
"fmt"
@ -14,10 +15,10 @@ import (
user_model "forgejo.org/models/user"
"forgejo.org/modules/graceful"
"forgejo.org/modules/json"
"forgejo.org/modules/keying"
"forgejo.org/modules/log"
base "forgejo.org/modules/migration"
"forgejo.org/modules/queue"
"forgejo.org/modules/secret"
"forgejo.org/modules/setting"
"forgejo.org/modules/structs"
"forgejo.org/modules/timeutil"
@ -70,36 +71,38 @@ func MigrateRepository(ctx context.Context, doer, u *user_model.User, opts base.
// CreateMigrateTask creates a migrate task
func CreateMigrateTask(ctx context.Context, doer, u *user_model.User, opts base.MigrateOptions) (*admin_model.Task, error) {
// encrypt credentials for persistence
var err error
opts.CloneAddrEncrypted, err = secret.EncryptSecret(setting.SecretKey, opts.CloneAddr)
if err != nil {
return nil, err
}
opts.CloneAddr = util.SanitizeCredentialURLs(opts.CloneAddr)
opts.AuthPasswordEncrypted, err = secret.EncryptSecret(setting.SecretKey, opts.AuthPassword)
if err != nil {
return nil, err
}
opts.AuthPassword = ""
opts.AuthTokenEncrypted, err = secret.EncryptSecret(setting.SecretKey, opts.AuthToken)
if err != nil {
return nil, err
}
opts.AuthToken = ""
bs, err := json.Marshal(&opts)
if err != nil {
return nil, err
}
task := &admin_model.Task{
DoerID: doer.ID,
OwnerID: u.ID,
Type: structs.TaskTypeMigrateRepo,
Status: structs.TaskStatusQueued,
PayloadContent: string(bs),
DoerID: doer.ID,
OwnerID: u.ID,
Type: structs.TaskTypeMigrateRepo,
Status: structs.TaskStatusQueued,
}
if err := admin_model.CreateTask(ctx, task); err != nil {
if err := db.WithTx(ctx, func(ctx context.Context) error {
if err := admin_model.CreateTask(ctx, task); err != nil {
return err
}
key := keying.DeriveKey(keying.ContextMigrateTask)
opts.CloneAddrEncrypted = base64.RawStdEncoding.EncodeToString(key.Encrypt([]byte(opts.CloneAddr), keying.ColumnAndJSONSelectorAndID("payload_content", "clone_addr_encrypted", task.ID)))
opts.CloneAddr = util.SanitizeCredentialURLs(opts.CloneAddr)
opts.AuthPasswordEncrypted = base64.RawStdEncoding.EncodeToString(key.Encrypt([]byte(opts.AuthPassword), keying.ColumnAndJSONSelectorAndID("payload_content", "auth_password_encrypted", task.ID)))
opts.AuthPassword = ""
opts.AuthTokenEncrypted = base64.RawStdEncoding.EncodeToString(key.Encrypt([]byte(opts.AuthToken), keying.ColumnAndJSONSelectorAndID("payload_content", "auth_token_encrypted", task.ID)))
opts.AuthToken = ""
bs, err := json.Marshal(&opts)
if err != nil {
return err
}
task.PayloadContent = string(bs)
return task.UpdateCols(ctx, "payload_content")
}); err != nil {
return nil, err
}

View file

@ -0,0 +1,52 @@
package task
import (
"testing"
admin_model "forgejo.org/models/admin"
"forgejo.org/models/unittest"
user_model "forgejo.org/models/user"
"forgejo.org/modules/migration"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestCreateMigrateTask(t *testing.T) {
require.NoError(t, unittest.PrepareTestDatabase())
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
t.Run("Transaction failure", func(t *testing.T) {
defer unittest.SetFaultInjector(2)()
task, err := CreateMigrateTask(t.Context(), user, user, migration.MigrateOptions{
CloneAddr: "https://admin:password2@example.com",
AuthPassword: "password",
AuthToken: "token",
RepoName: "migrate-test-2",
})
require.ErrorIs(t, err, unittest.ErrFaultInjected)
require.Nil(t, task)
unittest.AssertExistsIf(t, false, &admin_model.Task{})
})
t.Run("Normal", func(t *testing.T) {
task, err := CreateMigrateTask(t.Context(), user, user, migration.MigrateOptions{
CloneAddr: "https://admin:password@example.com",
AuthPassword: "password",
AuthToken: "token",
RepoName: "migrate-test",
})
require.NoError(t, err)
require.NotNil(t, task)
config, err := task.MigrateConfig()
require.NoError(t, err)
require.NotNil(t, config)
assert.Equal(t, "token", config.AuthToken)
assert.Equal(t, "password", config.AuthPassword)
assert.Equal(t, "https://admin:password@example.com", config.CloneAddr)
})
}