mirror of
https://github.com/netbirdio/netbird.git
synced 2026-10-08 14:39:09 +02:00
226 lines
7.7 KiB
Go
226 lines
7.7 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
dexstorage "github.com/dexidp/dex/storage"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
"github.com/rs/xid"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"gorm.io/driver/mysql"
|
|
"gorm.io/gorm"
|
|
|
|
"github.com/netbirdio/netbird/idp/dex"
|
|
"github.com/netbirdio/netbird/management/internals/shared/db"
|
|
"github.com/netbirdio/netbird/management/server/activity"
|
|
activitystore "github.com/netbirdio/netbird/management/server/activity/store"
|
|
"github.com/netbirdio/netbird/management/server/store"
|
|
"github.com/netbirdio/netbird/management/server/testutil"
|
|
"github.com/netbirdio/netbird/management/server/types"
|
|
"github.com/netbirdio/netbird/util/crypt"
|
|
)
|
|
|
|
const testAccountID = "bf1c8084-ba50-4ce7-9439-34653001fc3b"
|
|
|
|
func TestRun(t *testing.T) {
|
|
if os.Getenv("CI") == "true" && runtime.GOOS != "linux" {
|
|
t.Skip("needs Docker for the MySQL and Postgres containers")
|
|
}
|
|
// Seed in UTC to match the --mysql-timezone default.
|
|
time.Local = time.UTC
|
|
ctx := context.Background()
|
|
dir := t.TempDir()
|
|
key, err := crypt.GenerateKey()
|
|
require.NoError(t, err)
|
|
|
|
mysqlDSN := newMySQLDatabase(t, "run_src")
|
|
pgDSN := newPostgresDatabase(t, ctx, "run_dst")
|
|
mysqlStore := seedMySQL(t, ctx, mysqlDSN)
|
|
eventID := seedEvents(t, ctx, dir, key)
|
|
seedIdP(t, ctx, filepath.Join(dir, "idp.db"))
|
|
|
|
cfg, err := parseFlags([]string{
|
|
"--mysql-dsn", mysqlDSN,
|
|
"--postgres-dsn", pgDSN,
|
|
"--events-db", filepath.Join(dir, "events.db"),
|
|
"--auth-db", filepath.Join(dir, "idp.db"),
|
|
})
|
|
require.NoError(t, err)
|
|
require.NoError(t, run(ctx, cfg))
|
|
|
|
t.Run("management", func(t *testing.T) {
|
|
pgStore, err := store.NewPostgresqlStore(ctx, pgDSN, nil, true)
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() { _ = pgStore.Close(ctx) })
|
|
|
|
assert.JSONEq(t, accountJSON(t, ctx, mysqlStore), accountJSON(t, ctx, pgStore),
|
|
"the account read from Postgres must match the one in MySQL")
|
|
})
|
|
|
|
t.Run("activity", func(t *testing.T) {
|
|
t.Setenv("NB_ACTIVITY_EVENT_STORE_ENGINE", "postgres")
|
|
t.Setenv("NB_ACTIVITY_EVENT_POSTGRES_DSN", pgDSN)
|
|
events, err := activitystore.NewSqlStore(ctx, dir, key)
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() { _ = events.Close(ctx) })
|
|
|
|
got, err := events.Get(ctx, testAccountID, 0, 10, false)
|
|
require.NoError(t, err)
|
|
require.Len(t, got, 1, "the migrated event must be readable")
|
|
assert.Equal(t, eventID, got[0].ID, "the event must keep its id")
|
|
|
|
next, err := events.Save(ctx, &activity.Event{Timestamp: time.Now(), AccountID: testAccountID, InitiatorID: "u", TargetID: "p"})
|
|
require.NoError(t, err)
|
|
assert.Greater(t, next.ID, eventID, "new events must continue after the migrated ids")
|
|
})
|
|
|
|
t.Run("auth", func(t *testing.T) {
|
|
idp := dex.Storage{Type: "postgres", Config: map[string]any{"dsn": pgDSN}}
|
|
s, err := idp.OpenStorage(slog.New(slog.DiscardHandler))
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() { _ = s.Close() })
|
|
|
|
pw, err := s.GetPassword(ctx, "admin@example.com")
|
|
require.NoError(t, err)
|
|
assert.Equal(t, []byte("hash"), pw.Hash, "the IdP password must survive the migration")
|
|
})
|
|
|
|
t.Run("refuses a target that holds data", func(t *testing.T) {
|
|
assert.ErrorContains(t, run(ctx, cfg), "target already holds data")
|
|
})
|
|
}
|
|
|
|
func seedMySQL(t *testing.T, ctx context.Context, dsn string) *store.SqlStore {
|
|
t.Helper()
|
|
t.Setenv("NETBIRD_STORE_ENGINE", string(types.SqliteStoreEngine))
|
|
seed, cleanup, err := store.NewTestStoreFromSQL(ctx, "../../management/server/testdata/extended-store.sql", t.TempDir())
|
|
require.NoError(t, err)
|
|
t.Cleanup(cleanup)
|
|
|
|
mysqlStore, err := store.NewMysqlStoreFromSqlStore(ctx, seed.(*store.SqlStore), dsn, nil)
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() { _ = mysqlStore.Close(ctx) })
|
|
return mysqlStore
|
|
}
|
|
|
|
func seedEvents(t *testing.T, ctx context.Context, dir, key string) uint64 {
|
|
t.Helper()
|
|
events, err := activitystore.NewSqlStore(ctx, dir, key)
|
|
require.NoError(t, err)
|
|
defer events.Close(ctx)
|
|
|
|
event, err := events.Save(ctx, &activity.Event{
|
|
Timestamp: time.Now(), Activity: activity.PeerAddedByUser,
|
|
AccountID: testAccountID, InitiatorID: "user", TargetID: "peer",
|
|
Meta: map[string]any{"name": "peer"},
|
|
})
|
|
require.NoError(t, err)
|
|
return event.ID
|
|
}
|
|
|
|
func seedIdP(t *testing.T, ctx context.Context, file string) {
|
|
t.Helper()
|
|
idp := dex.Storage{Type: "sqlite3", Config: map[string]any{"file": file}}
|
|
s, err := idp.OpenStorage(slog.New(slog.DiscardHandler))
|
|
require.NoError(t, err)
|
|
defer s.Close()
|
|
|
|
require.NoError(t, s.CreatePassword(ctx, dexstorage.Password{
|
|
Email: "admin@example.com", Hash: []byte("hash"), Username: "admin", UserID: "admin-id",
|
|
}))
|
|
}
|
|
|
|
func accountJSON(t *testing.T, ctx context.Context, s store.Store) string {
|
|
t.Helper()
|
|
var account *types.Account
|
|
require.NoError(t, s.ExecuteInTransaction(ctx, func(tx store.Store) error {
|
|
var err error
|
|
account, err = tx.GetAccount(ctx, testAccountID)
|
|
return err
|
|
}))
|
|
out, err := json.Marshal(account)
|
|
require.NoError(t, err)
|
|
return string(out)
|
|
}
|
|
|
|
func TestMySQLTimezone(t *testing.T) {
|
|
if os.Getenv("CI") == "true" && runtime.GOOS != "linux" {
|
|
t.Skip("needs Docker for the MySQL and Postgres containers")
|
|
}
|
|
ctx := context.Background()
|
|
mysqlDSN := newMySQLDatabase(t, "tz_src")
|
|
pgDSN := newPostgresDatabase(t, ctx, "tz_dst")
|
|
ms := seedMySQL(t, ctx, mysqlDSN)
|
|
require.NoError(t, ms.GetDB().Exec("UPDATE accounts SET created_at = '2026-01-02 03:04:05' WHERE id = ?", testAccountID).Error)
|
|
|
|
cfg, err := parseFlags([]string{
|
|
"--mysql-dsn", mysqlDSN,
|
|
"--postgres-dsn", pgDSN,
|
|
"--mysql-timezone", "Asia/Tokyo",
|
|
"--events-db", filepath.Join(t.TempDir(), "none.db"),
|
|
"--auth-db", filepath.Join(t.TempDir(), "none.db"),
|
|
})
|
|
require.NoError(t, err)
|
|
require.NoError(t, run(ctx, cfg))
|
|
|
|
pool, err := pgxpool.New(ctx, pgDSN)
|
|
require.NoError(t, err)
|
|
t.Cleanup(pool.Close)
|
|
var got time.Time
|
|
require.NoError(t, pool.QueryRow(ctx, "SELECT created_at FROM accounts WHERE id = $1", testAccountID).Scan(&got))
|
|
want := time.Date(2026, 1, 1, 18, 4, 5, 0, time.UTC)
|
|
assert.True(t, want.Equal(got), "created_at must keep the instant the server wrote, got %s", got.UTC())
|
|
}
|
|
|
|
func TestParseFlagsRejectsUnknownTimezone(t *testing.T) {
|
|
_, err := parseFlags([]string{"--mysql-dsn", "m", "--postgres-dsn", "p", "--mysql-timezone", "Mars/Base"})
|
|
assert.ErrorContains(t, err, "--mysql-timezone")
|
|
}
|
|
|
|
// newMySQLDatabase creates a database unique to this run in the shared
|
|
// container, so repeated runs (-count) don't collide, and drops it afterwards.
|
|
func newMySQLDatabase(t *testing.T, prefix string) string {
|
|
t.Helper()
|
|
_, base, err := testutil.CreateMysqlTestContainer()
|
|
require.NoError(t, err)
|
|
admin, err := gorm.Open(mysql.Open(db.MysqlDSN(base)), &gorm.Config{})
|
|
require.NoError(t, err)
|
|
name := prefix + "_" + xid.New().String()
|
|
require.NoError(t, admin.Exec("CREATE DATABASE "+name).Error)
|
|
t.Cleanup(func() {
|
|
_ = admin.Exec("DROP DATABASE IF EXISTS " + name).Error
|
|
if sqlDB, err := admin.DB(); err == nil {
|
|
_ = sqlDB.Close()
|
|
}
|
|
})
|
|
return base[:strings.LastIndex(base, "/")] + "/" + name
|
|
}
|
|
|
|
// newPostgresDatabase is the Postgres counterpart of newMySQLDatabase.
|
|
func newPostgresDatabase(t *testing.T, ctx context.Context, prefix string) string {
|
|
t.Helper()
|
|
_, base, err := testutil.CreatePostgresTestContainer()
|
|
require.NoError(t, err)
|
|
// Dex connects through lib/pq, which requires TLS unless told otherwise.
|
|
base = strings.TrimSuffix(base, "?")
|
|
admin, err := pgxpool.New(ctx, base+"?sslmode=disable")
|
|
require.NoError(t, err)
|
|
name := prefix + "_" + xid.New().String()
|
|
_, err = admin.Exec(ctx, "CREATE DATABASE "+name)
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() {
|
|
_, _ = admin.Exec(context.Background(), "DROP DATABASE IF EXISTS "+name+" WITH (FORCE)")
|
|
admin.Close()
|
|
})
|
|
return base[:strings.LastIndex(base, "/")] + "/" + name + "?sslmode=disable"
|
|
}
|