mirror of
https://github.com/netbirdio/netbird.git
synced 2026-07-29 01:12:36 -04:00
739 lines
26 KiB
Go
739 lines
26 KiB
Go
package migration_test
|
|
|
|
import (
|
|
"context"
|
|
"encoding/gob"
|
|
"net"
|
|
"os"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"gorm.io/driver/mysql"
|
|
"gorm.io/driver/postgres"
|
|
"gorm.io/driver/sqlite"
|
|
"gorm.io/gorm"
|
|
|
|
agentNetworkTypes "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types"
|
|
"github.com/netbirdio/netbird/management/server/migration"
|
|
nbpeer "github.com/netbirdio/netbird/management/server/peer"
|
|
"github.com/netbirdio/netbird/management/server/testutil"
|
|
"github.com/netbirdio/netbird/management/server/types"
|
|
"github.com/netbirdio/netbird/route"
|
|
)
|
|
|
|
func setupDatabase(t *testing.T) *gorm.DB {
|
|
t.Helper()
|
|
|
|
var db *gorm.DB
|
|
var err error
|
|
var dsn string
|
|
var cleanup func()
|
|
switch os.Getenv("NETBIRD_STORE_ENGINE") {
|
|
case "mysql":
|
|
cleanup, dsn, err = testutil.CreateMysqlTestContainer()
|
|
if err != nil {
|
|
t.Fatalf("Failed to create MySQL test container: %v", err)
|
|
}
|
|
|
|
if dsn == "" {
|
|
t.Fatal("MySQL connection string is empty, ensure the test container is running")
|
|
}
|
|
|
|
db, err = gorm.Open(mysql.Open(dsn+"?charset=utf8&parseTime=True&loc=Local"), &gorm.Config{})
|
|
case "postgres":
|
|
cleanup, dsn, err = testutil.CreatePostgresTestContainer()
|
|
if err != nil {
|
|
t.Fatalf("Failed to create PostgreSQL test container: %v", err)
|
|
}
|
|
|
|
if dsn == "" {
|
|
t.Fatalf("PostgreSQL connection string is empty, ensure the test container is running")
|
|
}
|
|
|
|
db, err = gorm.Open(postgres.Open(dsn), &gorm.Config{})
|
|
case "sqlite":
|
|
db, err = gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
|
|
default:
|
|
db, err = gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
|
|
}
|
|
if cleanup != nil {
|
|
t.Cleanup(cleanup)
|
|
}
|
|
|
|
require.NoError(t, err, "Failed to open database")
|
|
return db
|
|
}
|
|
|
|
func TestMigrateFieldFromGobToJSON_EmptyDB(t *testing.T) {
|
|
db := setupDatabase(t)
|
|
err := migration.MigrateFieldFromGobToJSON[types.Account, net.IPNet](context.Background(), db, "network_net")
|
|
require.NoError(t, err, "Migration should not fail for an empty database")
|
|
}
|
|
|
|
func TestMigrateFieldFromGobToJSON_WithGobData(t *testing.T) {
|
|
t.Setenv("NETBIRD_STORE_ENGINE", "sqlite")
|
|
db := setupDatabase(t)
|
|
|
|
err := db.AutoMigrate(&types.Account{}, &route.Route{})
|
|
require.NoError(t, err, "Failed to auto-migrate tables")
|
|
|
|
_, ipnet, err := net.ParseCIDR("10.0.0.0/24")
|
|
require.NoError(t, err, "Failed to parse CIDR")
|
|
|
|
type network struct {
|
|
types.Network
|
|
Net net.IPNet `gorm:"serializer:gob"`
|
|
}
|
|
|
|
type account struct {
|
|
types.Account
|
|
Network *network `gorm:"embedded;embeddedPrefix:network_"`
|
|
}
|
|
|
|
err = db.Save(&account{Account: types.Account{Id: "123"}, Network: &network{Net: *ipnet}}).Error
|
|
require.NoError(t, err, "Failed to insert Gob data")
|
|
|
|
var gobStr string
|
|
err = db.Model(&types.Account{}).Select("network_net").First(&gobStr).Error
|
|
assert.NoError(t, err, "Failed to fetch Gob data")
|
|
|
|
err = gob.NewDecoder(strings.NewReader(gobStr)).Decode(&ipnet)
|
|
require.NoError(t, err, "Failed to decode Gob data")
|
|
|
|
err = migration.MigrateFieldFromGobToJSON[types.Account, net.IPNet](context.Background(), db, "network_net")
|
|
require.NoError(t, err, "Migration should not fail with Gob data")
|
|
|
|
var jsonStr string
|
|
db.Model(&types.Account{}).Select("network_net").First(&jsonStr)
|
|
assert.JSONEq(t, `{"IP":"10.0.0.0","Mask":"////AA=="}`, jsonStr, "Data should be migrated")
|
|
}
|
|
|
|
func TestMigrateFieldFromGobToJSON_WithJSONData(t *testing.T) {
|
|
db := setupDatabase(t)
|
|
|
|
err := db.AutoMigrate(&types.Account{}, &route.Route{})
|
|
require.NoError(t, err, "Failed to auto-migrate tables")
|
|
|
|
_, ipnet, err := net.ParseCIDR("10.0.0.0/24")
|
|
require.NoError(t, err, "Failed to parse CIDR")
|
|
|
|
err = db.Save(&types.Account{Network: &types.Network{Net: *ipnet}}).Error
|
|
require.NoError(t, err, "Failed to insert JSON data")
|
|
|
|
err = migration.MigrateFieldFromGobToJSON[types.Account, net.IPNet](context.Background(), db, "network_net")
|
|
require.NoError(t, err, "Migration should not fail with JSON data")
|
|
|
|
var jsonStr string
|
|
db.Model(&types.Account{}).Select("network_net").First(&jsonStr)
|
|
assert.JSONEq(t, `{"IP":"10.0.0.0","Mask":"////AA=="}`, jsonStr, "Data should be unchanged")
|
|
}
|
|
|
|
func TestMigrateNetIPFieldFromBlobToJSON_EmptyDB(t *testing.T) {
|
|
db := setupDatabase(t)
|
|
err := migration.MigrateNetIPFieldFromBlobToJSON[nbpeer.Peer](context.Background(), db, "ip", "idx_peers_account_id_ip")
|
|
require.NoError(t, err, "Migration should not fail for an empty database")
|
|
}
|
|
|
|
func TestMigrateNetIPFieldFromBlobToJSON_WithBlobData(t *testing.T) {
|
|
t.Setenv("NETBIRD_STORE_ENGINE", "sqlite")
|
|
db := setupDatabase(t)
|
|
|
|
err := db.AutoMigrate(&types.Account{}, &nbpeer.Peer{})
|
|
require.NoError(t, err, "Failed to auto-migrate tables")
|
|
|
|
type location struct {
|
|
nbpeer.Location
|
|
ConnectionIP net.IP
|
|
}
|
|
|
|
type peer struct {
|
|
nbpeer.Peer
|
|
Location location `gorm:"embedded;embeddedPrefix:location_"`
|
|
}
|
|
|
|
type account struct {
|
|
types.Account
|
|
Peers []peer `gorm:"foreignKey:AccountID;references:id"`
|
|
}
|
|
|
|
a := &account{
|
|
Account: types.Account{Id: "123"},
|
|
}
|
|
|
|
err = db.Save(a).Error
|
|
require.NoError(t, err, "Failed to insert account")
|
|
|
|
a.Peers = []peer{
|
|
{Location: location{ConnectionIP: net.IP{10, 0, 0, 1}}},
|
|
}
|
|
|
|
err = db.Save(a).Error
|
|
require.NoError(t, err, "Failed to insert blob data")
|
|
|
|
var blobValue string
|
|
err = db.Model(&nbpeer.Peer{}).Select("location_connection_ip").First(&blobValue).Error
|
|
assert.NoError(t, err, "Failed to fetch blob data")
|
|
|
|
err = migration.MigrateNetIPFieldFromBlobToJSON[nbpeer.Peer](context.Background(), db, "location_connection_ip", "")
|
|
require.NoError(t, err, "Migration should not fail with net.IP blob data")
|
|
|
|
var jsonStr string
|
|
db.Model(&nbpeer.Peer{}).Select("location_connection_ip").First(&jsonStr)
|
|
assert.JSONEq(t, `"10.0.0.1"`, jsonStr, "Data should be migrated")
|
|
}
|
|
|
|
func TestMigrateNetIPFieldFromBlobToJSON_WithJSONData(t *testing.T) {
|
|
db := setupDatabase(t)
|
|
|
|
err := db.AutoMigrate(&types.Account{}, &nbpeer.Peer{})
|
|
require.NoError(t, err, "Failed to auto-migrate tables")
|
|
|
|
account := &types.Account{
|
|
Id: "1234",
|
|
}
|
|
|
|
err = db.Save(account).Error
|
|
require.NoError(t, err, "Failed to insert account")
|
|
|
|
account.PeersG = []nbpeer.Peer{
|
|
{
|
|
AccountID: "1234",
|
|
Location: nbpeer.Location{ConnectionIP: net.IP{10, 0, 0, 1}},
|
|
Status: &nbpeer.PeerStatus{LastSeen: time.Now()},
|
|
},
|
|
}
|
|
|
|
err = db.Save(account).Error
|
|
require.NoError(t, err, "Failed to insert JSON data")
|
|
|
|
err = migration.MigrateNetIPFieldFromBlobToJSON[nbpeer.Peer](context.Background(), db, "location_connection_ip", "")
|
|
require.NoError(t, err, "Migration should not fail with net.IP JSON data")
|
|
|
|
var jsonStr string
|
|
db.Model(&nbpeer.Peer{}).Select("location_connection_ip").First(&jsonStr)
|
|
assert.JSONEq(t, `"10.0.0.1"`, jsonStr, "Data should be unchanged")
|
|
}
|
|
|
|
func TestMigrateSetupKeyToHashedSetupKey_ForPlainKey(t *testing.T) {
|
|
db := setupDatabase(t)
|
|
|
|
err := db.AutoMigrate(&types.SetupKey{}, &nbpeer.Peer{})
|
|
require.NoError(t, err, "Failed to auto-migrate tables")
|
|
|
|
err = db.Save(&types.SetupKey{
|
|
Id: "1",
|
|
Key: "EEFDAB47-C1A5-4472-8C05-71DE9A1E8382",
|
|
UpdatedAt: time.Now(),
|
|
}).Error
|
|
require.NoError(t, err, "Failed to insert setup key")
|
|
|
|
err = migration.MigrateSetupKeyToHashedSetupKey[types.SetupKey](context.Background(), db)
|
|
require.NoError(t, err, "Migration should not fail to migrate setup key")
|
|
|
|
var key types.SetupKey
|
|
err = db.Model(&types.SetupKey{}).First(&key).Error
|
|
assert.NoError(t, err, "Failed to fetch setup key")
|
|
|
|
assert.Equal(t, "EEFDA****", key.KeySecret, "Key should be secret")
|
|
assert.Equal(t, "9+FQcmNd2GCxIK+SvHmtp6PPGV4MKEicDS+xuSQmvlE=", key.Key, "Key should be hashed")
|
|
}
|
|
|
|
func TestMigrateSetupKeyToHashedSetupKey_ForAlreadyMigratedKey_Case1(t *testing.T) {
|
|
db := setupDatabase(t)
|
|
|
|
err := db.AutoMigrate(&types.SetupKey{})
|
|
require.NoError(t, err, "Failed to auto-migrate tables")
|
|
|
|
err = db.Save(&types.SetupKey{
|
|
Id: "1",
|
|
Key: "9+FQcmNd2GCxIK+SvHmtp6PPGV4MKEicDS+xuSQmvlE=",
|
|
KeySecret: "EEFDA****",
|
|
UpdatedAt: time.Now(),
|
|
}).Error
|
|
require.NoError(t, err, "Failed to insert setup key")
|
|
|
|
err = migration.MigrateSetupKeyToHashedSetupKey[types.SetupKey](context.Background(), db)
|
|
require.NoError(t, err, "Migration should not fail to migrate setup key")
|
|
|
|
var key types.SetupKey
|
|
err = db.Model(&types.SetupKey{}).First(&key).Error
|
|
assert.NoError(t, err, "Failed to fetch setup key")
|
|
|
|
assert.Equal(t, "EEFDA****", key.KeySecret, "Key should be secret")
|
|
assert.Equal(t, "9+FQcmNd2GCxIK+SvHmtp6PPGV4MKEicDS+xuSQmvlE=", key.Key, "Key should be hashed")
|
|
}
|
|
|
|
func TestMigrateSetupKeyToHashedSetupKey_ForAlreadyMigratedKey_Case2(t *testing.T) {
|
|
db := setupDatabase(t)
|
|
|
|
err := db.AutoMigrate(&types.SetupKey{})
|
|
require.NoError(t, err, "Failed to auto-migrate tables")
|
|
|
|
err = db.Save(&types.SetupKey{
|
|
Id: "1",
|
|
Key: "9+FQcmNd2GCxIK+SvHmtp6PPGV4MKEicDS+xuSQmvlE=",
|
|
UpdatedAt: time.Now(),
|
|
}).Error
|
|
require.NoError(t, err, "Failed to insert setup key")
|
|
|
|
err = migration.MigrateSetupKeyToHashedSetupKey[types.SetupKey](context.Background(), db)
|
|
require.NoError(t, err, "Migration should not fail to migrate setup key")
|
|
|
|
var key types.SetupKey
|
|
err = db.Model(&types.SetupKey{}).First(&key).Error
|
|
assert.NoError(t, err, "Failed to fetch setup key")
|
|
|
|
assert.Equal(t, "9+FQcmNd2GCxIK+SvHmtp6PPGV4MKEicDS+xuSQmvlE=", key.Key, "Key should be hashed")
|
|
}
|
|
|
|
func TestDropIndex(t *testing.T) {
|
|
db := setupDatabase(t)
|
|
|
|
err := db.AutoMigrate(&types.SetupKey{})
|
|
require.NoError(t, err, "Failed to auto-migrate tables")
|
|
|
|
err = db.Save(&types.SetupKey{
|
|
Id: "1",
|
|
Key: "9+FQcmNd2GCxIK+SvHmtp6PPGV4MKEicDS+xuSQmvlE=",
|
|
UpdatedAt: time.Now(),
|
|
}).Error
|
|
require.NoError(t, err, "Failed to insert setup key")
|
|
|
|
exist := db.Migrator().HasIndex(&types.SetupKey{}, "idx_setup_keys_account_id")
|
|
assert.True(t, exist, "Should have the index")
|
|
|
|
err = migration.DropIndex[types.SetupKey](context.Background(), db, "idx_setup_keys_account_id")
|
|
require.NoError(t, err, "Migration should not fail to remove index")
|
|
|
|
exist = db.Migrator().HasIndex(&types.SetupKey{}, "idx_setup_keys_account_id")
|
|
assert.False(t, exist, "Should not have the index")
|
|
}
|
|
|
|
func TestCreateIndex(t *testing.T) {
|
|
db := setupDatabase(t)
|
|
err := db.AutoMigrate(&nbpeer.Peer{})
|
|
assert.NoError(t, err, "Failed to auto-migrate tables")
|
|
|
|
indexName := "idx_account_ip"
|
|
|
|
err = migration.CreateIndexIfNotExists[nbpeer.Peer](context.Background(), db, indexName, "account_id", "ip")
|
|
assert.NoError(t, err, "Migration should not fail to create index")
|
|
|
|
exist := db.Migrator().HasIndex(&nbpeer.Peer{}, indexName)
|
|
assert.True(t, exist, "Should have the index")
|
|
}
|
|
|
|
func TestCreateIndexIfExists(t *testing.T) {
|
|
db := setupDatabase(t)
|
|
err := db.AutoMigrate(&nbpeer.Peer{})
|
|
assert.NoError(t, err, "Failed to auto-migrate tables")
|
|
|
|
indexName := "idx_account_ip"
|
|
|
|
err = migration.CreateIndexIfNotExists[nbpeer.Peer](context.Background(), db, indexName, "account_id", "ip")
|
|
assert.NoError(t, err, "Migration should not fail to create index")
|
|
|
|
exist := db.Migrator().HasIndex(&nbpeer.Peer{}, indexName)
|
|
assert.True(t, exist, "Should have the index")
|
|
|
|
err = migration.CreateIndexIfNotExists[nbpeer.Peer](context.Background(), db, indexName, "account_id", "ip")
|
|
assert.NoError(t, err, "Create index should not fail if index exists")
|
|
|
|
exist = db.Migrator().HasIndex(&nbpeer.Peer{}, indexName)
|
|
assert.True(t, exist, "Should have the index")
|
|
}
|
|
|
|
type testPeer struct {
|
|
ID string `gorm:"primaryKey"`
|
|
Key string `gorm:"index"`
|
|
PeerStatusLastSeen time.Time
|
|
PeerStatusConnected bool
|
|
}
|
|
|
|
func (testPeer) TableName() string {
|
|
return "peers"
|
|
}
|
|
|
|
func setupPeerTestDB(t *testing.T) *gorm.DB {
|
|
t.Helper()
|
|
db := setupDatabase(t)
|
|
_ = db.Migrator().DropTable(&testPeer{})
|
|
err := db.AutoMigrate(&testPeer{})
|
|
require.NoError(t, err, "Failed to auto-migrate tables")
|
|
return db
|
|
}
|
|
|
|
func TestRemoveDuplicatePeerKeys_NoDuplicates(t *testing.T) {
|
|
db := setupPeerTestDB(t)
|
|
|
|
now := time.Now()
|
|
peers := []testPeer{
|
|
{ID: "peer1", Key: "key1", PeerStatusLastSeen: now},
|
|
{ID: "peer2", Key: "key2", PeerStatusLastSeen: now},
|
|
{ID: "peer3", Key: "key3", PeerStatusLastSeen: now},
|
|
}
|
|
|
|
for _, p := range peers {
|
|
err := db.Create(&p).Error
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
err := migration.RemoveDuplicatePeerKeys(context.Background(), db)
|
|
require.NoError(t, err)
|
|
|
|
var count int64
|
|
db.Model(&testPeer{}).Count(&count)
|
|
assert.Equal(t, int64(len(peers)), count, "All peers should remain when no duplicates")
|
|
}
|
|
|
|
func TestRemoveDuplicatePeerKeys_WithDuplicates(t *testing.T) {
|
|
db := setupPeerTestDB(t)
|
|
|
|
now := time.Now()
|
|
peers := []testPeer{
|
|
{ID: "peer1", Key: "key1", PeerStatusLastSeen: now.Add(-2 * time.Hour)},
|
|
{ID: "peer2", Key: "key1", PeerStatusLastSeen: now.Add(-1 * time.Hour)},
|
|
{ID: "peer3", Key: "key1", PeerStatusLastSeen: now},
|
|
{ID: "peer4", Key: "key2", PeerStatusLastSeen: now},
|
|
{ID: "peer5", Key: "key3", PeerStatusLastSeen: now.Add(-1 * time.Hour)},
|
|
{ID: "peer6", Key: "key3", PeerStatusLastSeen: now},
|
|
}
|
|
|
|
for _, p := range peers {
|
|
err := db.Create(&p).Error
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
err := migration.RemoveDuplicatePeerKeys(context.Background(), db)
|
|
require.NoError(t, err)
|
|
|
|
var count int64
|
|
db.Model(&testPeer{}).Count(&count)
|
|
assert.Equal(t, int64(3), count, "Should have 3 peers after removing duplicates")
|
|
|
|
var remainingPeers []testPeer
|
|
err = db.Find(&remainingPeers).Error
|
|
require.NoError(t, err)
|
|
|
|
remainingIDs := make(map[string]bool)
|
|
for _, p := range remainingPeers {
|
|
remainingIDs[p.ID] = true
|
|
}
|
|
|
|
assert.True(t, remainingIDs["peer3"], "peer3 should remain (most recent for key1)")
|
|
assert.True(t, remainingIDs["peer4"], "peer4 should remain (only peer for key2)")
|
|
assert.True(t, remainingIDs["peer6"], "peer6 should remain (most recent for key3)")
|
|
|
|
assert.False(t, remainingIDs["peer1"], "peer1 should be deleted (older duplicate)")
|
|
assert.False(t, remainingIDs["peer2"], "peer2 should be deleted (older duplicate)")
|
|
assert.False(t, remainingIDs["peer5"], "peer5 should be deleted (older duplicate)")
|
|
}
|
|
|
|
func TestRemoveDuplicatePeerKeys_EmptyTable(t *testing.T) {
|
|
db := setupPeerTestDB(t)
|
|
|
|
err := migration.RemoveDuplicatePeerKeys(context.Background(), db)
|
|
require.NoError(t, err, "Should not fail on empty table")
|
|
}
|
|
|
|
func TestRemoveDuplicatePeerKeys_NoTable(t *testing.T) {
|
|
db := setupDatabase(t)
|
|
_ = db.Migrator().DropTable(&testPeer{})
|
|
|
|
err := migration.RemoveDuplicatePeerKeys(context.Background(), db)
|
|
require.NoError(t, err, "Should not fail when table does not exist")
|
|
}
|
|
|
|
type testParent struct {
|
|
ID string `gorm:"primaryKey"`
|
|
}
|
|
|
|
func (testParent) TableName() string {
|
|
return "test_parents"
|
|
}
|
|
|
|
type testChild struct {
|
|
ID string `gorm:"primaryKey"`
|
|
ParentID string
|
|
}
|
|
|
|
func (testChild) TableName() string {
|
|
return "test_children"
|
|
}
|
|
|
|
type testChildWithFK struct {
|
|
ID string `gorm:"primaryKey"`
|
|
ParentID string `gorm:"index"`
|
|
Parent *testParent `gorm:"foreignKey:ParentID"`
|
|
}
|
|
|
|
func (testChildWithFK) TableName() string {
|
|
return "test_children"
|
|
}
|
|
|
|
func setupOrphanTestDB(t *testing.T, models ...any) *gorm.DB {
|
|
t.Helper()
|
|
db := setupDatabase(t)
|
|
for _, m := range models {
|
|
_ = db.Migrator().DropTable(m)
|
|
}
|
|
err := db.AutoMigrate(models...)
|
|
require.NoError(t, err, "Failed to auto-migrate tables")
|
|
return db
|
|
}
|
|
|
|
func TestCleanupOrphanedResources_NoChildTable(t *testing.T) {
|
|
db := setupDatabase(t)
|
|
_ = db.Migrator().DropTable(&testChild{})
|
|
_ = db.Migrator().DropTable(&testParent{})
|
|
|
|
err := migration.CleanupOrphanedResources[testChild, testParent](context.Background(), db, "parent_id")
|
|
require.NoError(t, err, "Should not fail when child table does not exist")
|
|
}
|
|
|
|
func TestCleanupOrphanedResources_NoParentTable(t *testing.T) {
|
|
db := setupDatabase(t)
|
|
_ = db.Migrator().DropTable(&testParent{})
|
|
_ = db.Migrator().DropTable(&testChild{})
|
|
|
|
err := db.AutoMigrate(&testChild{})
|
|
require.NoError(t, err)
|
|
|
|
err = migration.CleanupOrphanedResources[testChild, testParent](context.Background(), db, "parent_id")
|
|
require.NoError(t, err, "Should not fail when parent table does not exist")
|
|
}
|
|
|
|
func TestCleanupOrphanedResources_EmptyTables(t *testing.T) {
|
|
db := setupOrphanTestDB(t, &testParent{}, &testChild{})
|
|
|
|
err := migration.CleanupOrphanedResources[testChild, testParent](context.Background(), db, "parent_id")
|
|
require.NoError(t, err, "Should not fail on empty tables")
|
|
|
|
var count int64
|
|
db.Model(&testChild{}).Count(&count)
|
|
assert.Equal(t, int64(0), count)
|
|
}
|
|
|
|
func TestCleanupOrphanedResources_NoOrphans(t *testing.T) {
|
|
db := setupOrphanTestDB(t, &testParent{}, &testChild{})
|
|
|
|
require.NoError(t, db.Create(&testParent{ID: "p1"}).Error)
|
|
require.NoError(t, db.Create(&testParent{ID: "p2"}).Error)
|
|
require.NoError(t, db.Create(&testChild{ID: "c1", ParentID: "p1"}).Error)
|
|
require.NoError(t, db.Create(&testChild{ID: "c2", ParentID: "p2"}).Error)
|
|
|
|
err := migration.CleanupOrphanedResources[testChild, testParent](context.Background(), db, "parent_id")
|
|
require.NoError(t, err)
|
|
|
|
var count int64
|
|
db.Model(&testChild{}).Count(&count)
|
|
assert.Equal(t, int64(2), count, "All children should remain when no orphans")
|
|
}
|
|
|
|
func TestCleanupOrphanedResources_AllOrphans(t *testing.T) {
|
|
db := setupOrphanTestDB(t, &testParent{}, &testChild{})
|
|
|
|
require.NoError(t, db.Exec("INSERT INTO test_children (id, parent_id) VALUES (?, ?)", "c1", "gone1").Error)
|
|
require.NoError(t, db.Exec("INSERT INTO test_children (id, parent_id) VALUES (?, ?)", "c2", "gone2").Error)
|
|
require.NoError(t, db.Exec("INSERT INTO test_children (id, parent_id) VALUES (?, ?)", "c3", "gone3").Error)
|
|
|
|
err := migration.CleanupOrphanedResources[testChild, testParent](context.Background(), db, "parent_id")
|
|
require.NoError(t, err)
|
|
|
|
var count int64
|
|
db.Model(&testChild{}).Count(&count)
|
|
assert.Equal(t, int64(0), count, "All orphaned children should be deleted")
|
|
}
|
|
|
|
func TestCleanupOrphanedResources_MixedValidAndOrphaned(t *testing.T) {
|
|
db := setupOrphanTestDB(t, &testParent{}, &testChild{})
|
|
|
|
require.NoError(t, db.Create(&testParent{ID: "p1"}).Error)
|
|
require.NoError(t, db.Create(&testParent{ID: "p2"}).Error)
|
|
|
|
require.NoError(t, db.Create(&testChild{ID: "c1", ParentID: "p1"}).Error)
|
|
require.NoError(t, db.Create(&testChild{ID: "c2", ParentID: "p2"}).Error)
|
|
require.NoError(t, db.Create(&testChild{ID: "c3", ParentID: "p1"}).Error)
|
|
|
|
require.NoError(t, db.Exec("INSERT INTO test_children (id, parent_id) VALUES (?, ?)", "c4", "gone1").Error)
|
|
require.NoError(t, db.Exec("INSERT INTO test_children (id, parent_id) VALUES (?, ?)", "c5", "gone2").Error)
|
|
|
|
err := migration.CleanupOrphanedResources[testChild, testParent](context.Background(), db, "parent_id")
|
|
require.NoError(t, err)
|
|
|
|
var remaining []testChild
|
|
require.NoError(t, db.Order("id").Find(&remaining).Error)
|
|
|
|
assert.Len(t, remaining, 3, "Only valid children should remain")
|
|
assert.Equal(t, "c1", remaining[0].ID)
|
|
assert.Equal(t, "c2", remaining[1].ID)
|
|
assert.Equal(t, "c3", remaining[2].ID)
|
|
}
|
|
|
|
func TestCleanupOrphanedResources_Idempotent(t *testing.T) {
|
|
db := setupOrphanTestDB(t, &testParent{}, &testChild{})
|
|
|
|
require.NoError(t, db.Create(&testParent{ID: "p1"}).Error)
|
|
require.NoError(t, db.Create(&testChild{ID: "c1", ParentID: "p1"}).Error)
|
|
require.NoError(t, db.Exec("INSERT INTO test_children (id, parent_id) VALUES (?, ?)", "c2", "gone").Error)
|
|
|
|
ctx := context.Background()
|
|
|
|
err := migration.CleanupOrphanedResources[testChild, testParent](ctx, db, "parent_id")
|
|
require.NoError(t, err)
|
|
|
|
var count int64
|
|
db.Model(&testChild{}).Count(&count)
|
|
assert.Equal(t, int64(1), count)
|
|
|
|
err = migration.CleanupOrphanedResources[testChild, testParent](ctx, db, "parent_id")
|
|
require.NoError(t, err)
|
|
|
|
db.Model(&testChild{}).Count(&count)
|
|
assert.Equal(t, int64(1), count, "Count should remain the same after second run")
|
|
}
|
|
|
|
func TestCleanupOrphanedResources_SkipsWhenForeignKeyExists(t *testing.T) {
|
|
engine := os.Getenv("NETBIRD_STORE_ENGINE")
|
|
if engine != "postgres" && engine != "mysql" {
|
|
t.Skip("FK constraint early-exit test requires postgres or mysql")
|
|
}
|
|
|
|
db := setupDatabase(t)
|
|
_ = db.Migrator().DropTable(&testChildWithFK{})
|
|
_ = db.Migrator().DropTable(&testParent{})
|
|
|
|
err := db.AutoMigrate(&testParent{}, &testChildWithFK{})
|
|
require.NoError(t, err)
|
|
|
|
require.NoError(t, db.Create(&testParent{ID: "p1"}).Error)
|
|
require.NoError(t, db.Create(&testParent{ID: "p2"}).Error)
|
|
require.NoError(t, db.Create(&testChildWithFK{ID: "c1", ParentID: "p1"}).Error)
|
|
require.NoError(t, db.Create(&testChildWithFK{ID: "c2", ParentID: "p2"}).Error)
|
|
|
|
switch engine {
|
|
case "postgres":
|
|
require.NoError(t, db.Exec("ALTER TABLE test_children DROP CONSTRAINT fk_test_children_parent").Error)
|
|
require.NoError(t, db.Exec("DELETE FROM test_parents WHERE id = ?", "p2").Error)
|
|
require.NoError(t, db.Exec(
|
|
"ALTER TABLE test_children ADD CONSTRAINT fk_test_children_parent "+
|
|
"FOREIGN KEY (parent_id) REFERENCES test_parents(id) NOT VALID",
|
|
).Error)
|
|
case "mysql":
|
|
require.NoError(t, db.Exec("SET FOREIGN_KEY_CHECKS = 0").Error)
|
|
require.NoError(t, db.Exec("ALTER TABLE test_children DROP FOREIGN KEY fk_test_children_parent").Error)
|
|
require.NoError(t, db.Exec("DELETE FROM test_parents WHERE id = ?", "p2").Error)
|
|
require.NoError(t, db.Exec(
|
|
"ALTER TABLE test_children ADD CONSTRAINT fk_test_children_parent "+
|
|
"FOREIGN KEY (parent_id) REFERENCES test_parents(id)",
|
|
).Error)
|
|
require.NoError(t, db.Exec("SET FOREIGN_KEY_CHECKS = 1").Error)
|
|
}
|
|
|
|
err = migration.CleanupOrphanedResources[testChildWithFK, testParent](context.Background(), db, "parent_id")
|
|
require.NoError(t, err)
|
|
|
|
var count int64
|
|
db.Model(&testChildWithFK{}).Count(&count)
|
|
assert.Equal(t, int64(2), count, "Both rows should survive — migration must skip when FK constraint exists")
|
|
}
|
|
|
|
// legacyCostRow is the pre-breakdown shape of the usage table: cost was stored
|
|
// as a total plus a cache portion, with no per-bucket columns. Used to build a
|
|
// realistic pre-upgrade table for the fold migration to run against.
|
|
type legacyCostRow struct {
|
|
ID string `gorm:"primaryKey"`
|
|
AccountID string
|
|
Model string
|
|
CostUSD float64
|
|
CacheCostUSD float64
|
|
}
|
|
|
|
func (legacyCostRow) TableName() string { return "agent_network_request_usage" }
|
|
|
|
// TestFoldCostAggregatesIntoBuckets_PreservesHistoricalCost covers the upgrade
|
|
// path: a table written under the old schema must come out with its per-row
|
|
// total and cache cost unchanged, because dropping cost_usd without folding it
|
|
// forward would silently zero every historical row's spend.
|
|
func TestFoldCostAggregatesIntoBuckets_PreservesHistoricalCost(t *testing.T) {
|
|
ctx := context.Background()
|
|
db := setupDatabase(t)
|
|
// setupDatabase hands back a process-shared database, so start from a clean
|
|
// table rather than inheriting rows from another test.
|
|
require.NoError(t, db.Migrator().DropTable(&agentNetworkTypes.AgentNetworkUsage{}))
|
|
|
|
require.NoError(t, db.AutoMigrate(&legacyCostRow{}), "legacy table must be created")
|
|
require.NoError(t, db.Create(&legacyCostRow{
|
|
ID: "u1", AccountID: "acct-1", Model: "claude-sonnet-4-6", CostUSD: 0.0123, CacheCostUSD: 0.0029,
|
|
}).Error)
|
|
require.NoError(t, db.Create(&legacyCostRow{
|
|
ID: "u2", AccountID: "acct-1", Model: "gpt-4o", CostUSD: 0.5, CacheCostUSD: 0,
|
|
}).Error)
|
|
// A zero-cost row (denied / unpriced request) must stay zero, not be touched.
|
|
require.NoError(t, db.Create(&legacyCostRow{ID: "u3", AccountID: "acct-1", Model: "gw/unpriced"}).Error)
|
|
|
|
// AutoMigrate adds the per-bucket columns alongside the legacy ones, exactly
|
|
// as a real upgrade does before the post-auto migrations run.
|
|
require.NoError(t, db.AutoMigrate(&agentNetworkTypes.AgentNetworkUsage{}), "new columns must be added")
|
|
|
|
require.NoError(t, migration.FoldCostAggregatesIntoBuckets[agentNetworkTypes.AgentNetworkUsage](ctx, db))
|
|
|
|
assert.False(t, db.Migrator().HasColumn(&agentNetworkTypes.AgentNetworkUsage{}, "cost_usd"),
|
|
"legacy cost_usd column must be dropped once folded")
|
|
assert.False(t, db.Migrator().HasColumn(&agentNetworkTypes.AgentNetworkUsage{}, "cache_cost_usd"),
|
|
"legacy cache_cost_usd column must be dropped once folded")
|
|
|
|
var rows []*agentNetworkTypes.AgentNetworkUsage
|
|
require.NoError(t, db.Order("id").Find(&rows).Error)
|
|
require.Len(t, rows, 3)
|
|
|
|
// u1: total and cache portion both preserved; the read/write and
|
|
// input/output splits are unknowable for a legacy row, so the cache total
|
|
// lands on cached_input and the remainder on input.
|
|
assert.InDelta(t, 0.0123, rows[0].TotalCostUSD(), 1e-9, "historical total must survive the fold")
|
|
assert.InDelta(t, 0.0029, rows[0].CacheCostUSD(), 1e-9, "historical cache cost must survive the fold")
|
|
assert.InDelta(t, 0.0094, rows[0].InputCostUSD, 1e-9, "non-cache remainder lands on input")
|
|
assert.InDelta(t, 0.0029, rows[0].CachedInputCostUSD, 1e-9, "legacy cache total lands on cached input")
|
|
assert.Zero(t, rows[0].CacheCreationCostUSD, "legacy rows carry no read/write split to recover")
|
|
assert.Zero(t, rows[0].OutputCostUSD, "legacy rows carry no input/output split to recover")
|
|
|
|
// u2: no cache spend — the whole total is the non-cache remainder.
|
|
assert.InDelta(t, 0.5, rows[1].TotalCostUSD(), 1e-9, "cache-free historical total must survive")
|
|
assert.Zero(t, rows[1].CacheCostUSD(), "a cache-free row must stay cache-free")
|
|
|
|
// u3: zero stays zero rather than being rewritten.
|
|
assert.Zero(t, rows[2].TotalCostUSD(), "an unpriced row must remain unpriced")
|
|
}
|
|
|
|
// TestFoldCostAggregatesIntoBuckets_SkipsAlreadyMigrated proves the migration is
|
|
// safe to re-run: with no legacy column present it is a no-op that leaves a
|
|
// true four-way split untouched.
|
|
func TestFoldCostAggregatesIntoBuckets_SkipsAlreadyMigrated(t *testing.T) {
|
|
ctx := context.Background()
|
|
db := setupDatabase(t)
|
|
require.NoError(t, db.Migrator().DropTable(&agentNetworkTypes.AgentNetworkUsage{}))
|
|
|
|
require.NoError(t, db.AutoMigrate(&agentNetworkTypes.AgentNetworkUsage{}))
|
|
// Timestamp must be set explicitly: a zero time.Time serialises as
|
|
// '0000-00-00 00:00:00', which MySQL rejects under strict mode.
|
|
require.NoError(t, db.Create(&agentNetworkTypes.AgentNetworkUsage{
|
|
ID: "u1", AccountID: "acct-1", Model: "claude-sonnet-4-6",
|
|
Timestamp: time.Date(2026, 5, 5, 9, 0, 0, 0, time.UTC),
|
|
InputCostUSD: 0.001, CachedInputCostUSD: 0.002, CacheCreationCostUSD: 0.003, OutputCostUSD: 0.004,
|
|
}).Error)
|
|
|
|
require.NoError(t, migration.FoldCostAggregatesIntoBuckets[agentNetworkTypes.AgentNetworkUsage](ctx, db),
|
|
"running against an already-migrated table must be a no-op, not an error")
|
|
|
|
var row agentNetworkTypes.AgentNetworkUsage
|
|
require.NoError(t, db.First(&row, "id = ?", "u1").Error)
|
|
assert.InDelta(t, 0.001, row.InputCostUSD, 1e-9, "a true split must not be rewritten")
|
|
assert.InDelta(t, 0.002, row.CachedInputCostUSD, 1e-9)
|
|
assert.InDelta(t, 0.003, row.CacheCreationCostUSD, 1e-9)
|
|
assert.InDelta(t, 0.004, row.OutputCostUSD, 1e-9)
|
|
assert.InDelta(t, 0.01, row.TotalCostUSD(), 1e-9, "derived total sums the four buckets")
|
|
}
|