Files
new-api/model/migration_dialector_test.go
CaIon 043ff99a51 fix: handle legacy database constraints and scoped policies
Resolve obsolete PostgreSQL single-column unique constraints by their actual
catalog names inside a locked transaction. Preserve unrelated indexes and
roll back constraint removal when foreign keys depend on them.

Load legacy authorization policies as structured fields. Preserve scoped
rows during baseline reseeding and load unsupported own/other scopes as
deny, preventing fallback to broader admin grants. Existing global grants
remain usable; unsupported scoped grants require administrator review.

Validation: fresh databases and rc.37 through rc.27, 60/60 combinations on
SQLite 3.50.4, MySQL 5.7.44/8.0.46, and PostgreSQL 9.6.24/16.15. Each ran
InitDB, separate InitLogDB, and authz.Init twice with persisted data.
Focused schema, foreign-key rollback, and master/slave policy reload
regressions passed, along with the authz package tests.

Security references: OWASP ASVS 5.0.0 controls 8.2.1, 8.2.2, and 8.3.1;
OWASP Authorization Cheat Sheet (least privilege and deny by default).
2026-09-12 19:33:53 +08:00

221 lines
9.3 KiB
Go

package model
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/QuantumNous/new-api/common"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type MigrationIdentityFields struct {
ID int `gorm:"primaryKey"`
Name string `gorm:"size:64;unique"`
Reference string `gorm:"size:64;uniqueIndex"`
Provider string `gorm:"size:32;uniqueIndex:,composite:provider_subject"`
Subject string `gorm:"size:64;uniqueIndex:,composite:provider_subject"`
}
type migrationIdentityV1 struct {
MigrationIdentityFields
Digest string `gorm:"type:char(32)"`
}
type migrationIdentityV2 struct {
MigrationIdentityFields
Digest string `gorm:"type:char(64)"`
Note string `gorm:"size:128"`
}
type migrationConstraintV1 struct {
ID int `gorm:"primaryKey"`
Name string `gorm:"size:64"`
}
type migrationConstraintV2 struct {
ID int `gorm:"primaryKey"`
Name string `gorm:"size:64;unique"`
}
type migrationDecimalV1 struct {
ID int `gorm:"primaryKey"`
Price float64 `gorm:"type:decimal(10,6);default:0"`
}
type migrationDecimalV2 struct {
ID int `gorm:"primaryKey"`
Price float64 `gorm:"type:decimal(12,6);not null;default:0"`
}
type migrationDecimalV3 struct {
ID int `gorm:"primaryKey"`
Price float64 `gorm:"type:decimal(12,6);not null;default:1.25"`
}
func TestMigrationSchemaStability(t *testing.T) {
for _, dialect := range []string{"sqlite", "mysql", "postgres"} {
t.Run(dialect, func(t *testing.T) {
var dsn string
switch dialect {
case "sqlite":
dsn = "local"
previousPath := common.SQLitePath
common.SQLitePath = filepath.Join(t.TempDir(), "migration.db")
t.Cleanup(func() { common.SQLitePath = previousPath })
case "mysql":
dsn = os.Getenv("TEST_MYSQL_DSN")
case "postgres":
dsn = os.Getenv("TEST_POSTGRES_DSN")
}
if dsn == "" {
t.Skip("test database DSN is not configured")
}
t.Setenv("MIGRATION_TEST_DSN", dsn)
db, _, err := chooseDB("MIGRATION_TEST_DSN", false)
require.NoError(t, err)
sqlDB, err := db.DB()
require.NoError(t, err)
t.Cleanup(func() { _ = sqlDB.Close() })
recorder := &migrationSQLRecorder{}
db = db.Session(&gorm.Session{Logger: recorder})
t.Run("identity_and_indexes", func(t *testing.T) {
const table = "migration_identity_test"
t.Cleanup(func() { _ = db.Migrator().DropTable(table) })
require.NoError(t, db.Table(table).AutoMigrate(&migrationIdentityV1{}))
row := migrationIdentityV1{
MigrationIdentityFields: MigrationIdentityFields{ID: 1, Name: "root", Reference: "token-reference", Provider: "oidc", Subject: "subject"},
Digest: "old-digest",
}
require.NoError(t, db.Table(table).Create(&row).Error)
recorder.reset()
require.NoError(t, db.Table(table).AutoMigrate(&migrationIdentityV1{}))
assert.Empty(t, recorder.schemaMutations())
require.NoError(t, db.Table(table).AutoMigrate(&migrationIdentityV2{}))
columns, err := db.Table(table).Migrator().ColumnTypes(&migrationIdentityV2{})
require.NoError(t, err)
for _, column := range columns {
if column.Name() == "digest" {
length, ok := column.Length()
require.True(t, ok)
assert.EqualValues(t, 64, length)
}
}
assert.True(t, db.Table(table).Migrator().HasColumn(&migrationIdentityV2{}, "note"))
recorder.reset()
require.NoError(t, db.Table(table).AutoMigrate(&migrationIdentityV2{}))
assert.Empty(t, recorder.schemaMutations())
var saved migrationIdentityV2
require.NoError(t, db.Table(table).First(&saved, 1).Error)
assert.Equal(t, row.MigrationIdentityFields, saved.MigrationIdentityFields)
expectedDigest := row.Digest
if dialect == "postgres" {
expectedDigest += strings.Repeat(" ", 64-len(row.Digest))
}
assert.Equal(t, expectedDigest, saved.Digest)
for _, duplicate := range []migrationIdentityV2{
{MigrationIdentityFields: MigrationIdentityFields{Name: "root", Reference: "other-1", Provider: "other", Subject: "1"}},
{MigrationIdentityFields: MigrationIdentityFields{Name: "other-2", Reference: "token-reference", Provider: "other", Subject: "2"}},
{MigrationIdentityFields: MigrationIdentityFields{Name: "other-3", Reference: "other-3", Provider: "oidc", Subject: "subject"}},
} {
assert.Error(t, db.Table(table).Create(&duplicate).Error)
}
})
t.Run("unique_constraint_changes", func(t *testing.T) {
const table = "migration_constraint_test"
t.Cleanup(func() { _ = db.Migrator().DropTable(table) })
require.NoError(t, db.Table(table).AutoMigrate(&migrationConstraintV1{}))
require.NoError(t, db.Table(table).Create(&migrationConstraintV1{Name: "existing"}).Error)
require.NoError(t, db.Table(table).AutoMigrate(&migrationConstraintV2{}))
assert.Error(t, db.Table(table).Create(&migrationConstraintV2{Name: "existing"}).Error)
recorder.reset()
require.NoError(t, db.Table(table).AutoMigrate(&migrationConstraintV2{}))
assert.Empty(t, recorder.schemaMutations())
require.NoError(t, db.Table(table).AutoMigrate(&migrationConstraintV1{}))
require.NoError(t, db.Table(table).Create(&migrationConstraintV1{Name: "existing"}).Error)
})
if dialect == "postgres" {
t.Run("renamed_unique_constraints", func(t *testing.T) {
const table = "migration_renamed_unique"
t.Cleanup(func() { _ = db.Migrator().DropTable(table) })
tableDB := db.Table(table).Session(&gorm.Session{})
require.NoError(t, tableDB.AutoMigrate(&migrationConstraintV1{}))
original := migrationConstraintV1{Name: "existing"}
require.NoError(t, tableDB.Create(&original).Error)
for _, name := range []string{"models_model_name_key", `imported "model" name`} {
require.NoError(t, db.Exec("ALTER TABLE ? ADD CONSTRAINT ? UNIQUE (name)", clause.Table{Name: table}, clause.Column{Name: name}).Error)
}
require.NoError(t, db.Exec("ALTER TABLE ? ADD CONSTRAINT keep_composite UNIQUE (id, name)", clause.Table{Name: table}).Error)
require.NoError(t, db.Exec("CREATE INDEX keep_name_lookup ON ? (name)", clause.Table{Name: table}).Error)
// A unique field must keep the old names and still reject duplicates.
require.NoError(t, tableDB.AutoMigrate(&migrationConstraintV2{}))
require.Error(t, tableDB.Create(&migrationConstraintV2{Name: "existing"}).Error)
require.NoError(t, db.Exec("CREATE TABLE migration_unique_reference (name varchar(64) REFERENCES migration_renamed_unique(name))").Error)
t.Cleanup(func() { _ = db.Migrator().DropTable("migration_unique_reference") })
require.NoError(t, db.Exec("INSERT INTO migration_unique_reference (name) VALUES (?)", "existing").Error)
require.Error(t, tableDB.AutoMigrate(&migrationConstraintV1{}), "dependent foreign keys must not be cascaded away")
for _, name := range []string{"models_model_name_key", `imported "model" name`} {
assert.True(t, tableDB.Migrator().HasConstraint(&migrationConstraintV1{}, name), "all old constraints must survive rollback")
}
var references int64
require.NoError(t, db.Table("migration_unique_reference").Count(&references).Error)
assert.EqualValues(t, 1, references)
require.NoError(t, db.Migrator().DropTable("migration_unique_reference"))
// Removing column uniqueness must resolve both actual constraint names.
require.NoError(t, tableDB.AutoMigrate(&migrationConstraintV1{}))
recorder.reset()
require.NoError(t, tableDB.AutoMigrate(&migrationConstraintV1{}))
assert.Empty(t, recorder.schemaMutations())
require.NoError(t, tableDB.Create(&migrationConstraintV1{Name: "existing"}).Error)
assert.True(t, tableDB.Migrator().HasConstraint(&migrationConstraintV1{}, "keep_composite"))
assert.True(t, tableDB.Migrator().HasIndex(&migrationConstraintV1{}, "keep_name_lookup"))
var rows []migrationConstraintV1
require.NoError(t, tableDB.Order("id").Find(&rows).Error)
require.Len(t, rows, 2)
assert.Equal(t, original, rows[0])
})
}
if dialect == "mysql" {
t.Run("decimal_default_and_real_changes", func(t *testing.T) {
const table = "migration_decimal_test"
t.Cleanup(func() { _ = db.Migrator().DropTable(table) })
require.NoError(t, db.Table(table).AutoMigrate(&migrationDecimalV1{}))
require.NoError(t, db.Table(table).Create(&migrationDecimalV1{ID: 1, Price: 12.345678}).Error)
for _, target := range []any{&migrationDecimalV1{}, &migrationDecimalV2{}, &migrationDecimalV3{}} {
require.NoError(t, db.Table(table).AutoMigrate(target))
recorder.reset()
require.NoError(t, db.Table(table).AutoMigrate(target))
assert.Empty(t, recorder.schemaMutations())
}
columns, err := db.Table(table).Migrator().ColumnTypes(&migrationDecimalV3{})
require.NoError(t, err)
for _, column := range columns {
if column.Name() == "price" {
precision, scale, ok := column.DecimalSize()
require.True(t, ok)
assert.EqualValues(t, 12, precision)
assert.EqualValues(t, 6, scale)
nullable, ok := column.Nullable()
require.True(t, ok)
assert.False(t, nullable)
}
}
require.NoError(t, db.Table(table).Create(&map[string]any{"id": 2}).Error)
var prices []float64
require.NoError(t, db.Table(table).Order("id").Pluck("price", &prices).Error)
assert.Equal(t, []float64{12.345678, 1.25}, prices)
})
}
})
}
}