Files
chorus/migrations/migrations_test.go

429 lines
15 KiB
Go

package migrations
import (
"os"
"path/filepath"
"regexp"
"slices"
"strings"
"testing"
)
func TestMigrationPairsAndProductionTables(t *testing.T) {
upFiles, err := filepath.Glob("*.up.sql")
if err != nil {
t.Fatal(err)
}
downFiles, err := filepath.Glob("*.down.sql")
if err != nil {
t.Fatal(err)
}
if len(upFiles) == 0 || len(upFiles) != len(downFiles) {
t.Fatalf("migration pairs mismatch: up=%d down=%d", len(upFiles), len(downFiles))
}
wantFiles := []string{
"000001_mvp0_base.up.sql",
"000002_admin_baseline.up.sql",
"000003_mvp1_routing.up.sql",
"000004_mvp1_admin_seed.up.sql",
"000005_provider_plaintext_credentials.up.sql",
"000006_mvp2_openapi_governance.up.sql",
"000007_admin_navigation_localization.up.sql",
"000008_portal_username_login.up.sql",
"000009_admin_grouped_navigation.up.sql",
"000010_admin_generation_media.up.sql",
"000011_admin_portal_user_management.up.sql",
"000012_portal_registration.up.sql",
}
slices.Sort(upFiles)
if !slices.Equal(upFiles, wantFiles) {
t.Fatalf("up migrations = %v, want %v", upFiles, wantFiles)
}
for _, upFile := range upFiles {
downFile := strings.TrimSuffix(upFile, ".up.sql") + ".down.sql"
if _, err := os.Stat(downFile); err != nil {
t.Errorf("%s has no matching down migration: %v", upFile, err)
}
}
content, err := os.ReadFile("000001_mvp0_base.up.sql")
if err != nil {
t.Fatal(err)
}
sql := string(content)
for _, table := range []string{"users", "providers", "provider_models", "prompt_templates", "generations", "generation_inputs", "generation_outputs"} {
pattern := regexp.MustCompile(`(?i)CREATE TABLE\s+` + regexp.QuoteMeta(table) + `\s*\(`)
if !pattern.MatchString(sql) {
t.Errorf("migration does not create %s", table)
}
}
for _, required := range []string{"rendered_prompt", "attempts", "error_code", "error_message", "lease_token", "uq_generations_user_idempotency", "idx_generations_queue"} {
if !strings.Contains(sql, required) {
t.Errorf("migration is missing %s", required)
}
}
}
func TestPortalRegistrationMigrationContracts(t *testing.T) {
up, err := os.ReadFile("000012_portal_registration.up.sql")
if err != nil {
t.Fatal(err)
}
down, err := os.ReadFile("000012_portal_registration.down.sql")
if err != nil {
t.Fatal(err)
}
upSQL, downSQL := string(up), string(down)
for _, required := range []string{
"MODIFY COLUMN email VARCHAR(320) NULL",
"CREATE TABLE portal_registration_policy",
"VALUES (1, FALSE, 1)",
"CREATE TABLE portal_auth_events",
"chorus.registration-policy.get",
"chorus.registration-policy.update",
"/api/v1/chorus/registration-policy",
"sys_menu_api_rule",
"sys_casbin_rule",
} {
if !strings.Contains(upSQL, required) {
t.Errorf("portal registration migration is missing %s", required)
}
}
for _, forbidden := range []string{"password", "email VARCHAR(320) NOT NULL", "AutoMigrate"} {
if strings.Contains(upSQL, forbidden) {
t.Errorf("portal registration migration contains forbidden value %s", forbidden)
}
}
if !strings.HasPrefix(strings.TrimSpace(downSQL), "-- This ALTER") || !strings.Contains(downSQL, "MODIFY COLUMN email VARCHAR(320) NOT NULL") {
t.Error("portal registration rollback must fail before other changes when NULL emails exist")
}
for _, required := range []string{"DROP TABLE portal_auth_events", "DROP TABLE portal_registration_policy", "chorus.registration-policy.get", "chorus.registration-policy.update"} {
if !strings.Contains(downSQL, required) {
t.Errorf("portal registration rollback is missing %s", required)
}
}
}
func TestAdminPortalUserManagementMigrationContracts(t *testing.T) {
up, err := os.ReadFile("000011_admin_portal_user_management.up.sql")
if err != nil {
t.Fatal(err)
}
down, err := os.ReadFile("000011_admin_portal_user_management.down.sql")
if err != nil {
t.Fatal(err)
}
for _, required := range []string{
"chorus.users.create", "chorus.users.password.reset", "sys_menu_api_rule", "sys_casbin_rule", "/chorus/users",
} {
if !strings.Contains(string(up), required) || !strings.Contains(string(down), required) {
t.Errorf("portal user management migration is missing reversible contract %s", required)
}
}
for _, route := range []string{"/api/v1/chorus/users", "/api/v1/chorus/users/:id/password"} {
if !strings.Contains(string(up), route) {
t.Errorf("portal user management migration is missing route %s", route)
}
}
for _, forbidden := range []string{"INSERT INTO users", "DELETE FROM users", "password_hash", "AutoMigrate"} {
if strings.Contains(string(up), forbidden) || strings.Contains(string(down), forbidden) {
t.Errorf("portal user permission migration must not touch %s", forbidden)
}
}
}
func TestAdminGenerationMediaMigrationContracts(t *testing.T) {
up, err := os.ReadFile("000010_admin_generation_media.up.sql")
if err != nil {
t.Fatal(err)
}
down, err := os.ReadFile("000010_admin_generation_media.down.sql")
if err != nil {
t.Fatal(err)
}
for _, required := range []string{
"chorus.generations.get", "chorus.generations.input.read", "chorus.generations.output.read",
"chorus.generations.output.thumbnail", "sys_menu_api_rule", "sys_casbin_rule", "/chorus/generations",
} {
if !strings.Contains(string(up), required) || !strings.Contains(string(down), required) {
t.Errorf("generation media migration is missing reversible contract %s", required)
}
}
for _, forbidden := range []string{"generation_inputs", "generation_outputs", "storage_key", "AutoMigrate"} {
if strings.Contains(string(up), forbidden) || strings.Contains(string(down), forbidden) {
t.Errorf("generation media permission migration must not touch %s", forbidden)
}
}
}
func TestAdminGroupedNavigationMigrationContracts(t *testing.T) {
read := func(name string) string {
t.Helper()
content, err := os.ReadFile(name)
if err != nil {
t.Fatal(err)
}
return string(content)
}
up := read("000009_admin_grouped_navigation.up.sql")
for _, required := range []string{
"chorus_admin_grouped_navigation_up_guard",
"'/chorus/configuration'",
"'/chorus/monitoring'",
"'/chorus/access'",
"'/chorus/system'",
"'/chorus/users', '/chorus/access', '终端用户'",
"'/chorus/system/admins'",
"'/chorus/system/roles'",
"'/chorus/system/menus'",
"'/chorus/system/apis'",
"'/chorus/system/login-logs'",
"'admin/sys-user/index'",
"'admin/sys-role/index'",
"'admin/sys-menu/index'",
"'admin/sys-api/index'",
"'admin/sys-login-log/index'",
"chorus.system.admins.list",
"chorus.system.roles.list",
"chorus.system.menus.list",
"chorus.system.apis.list",
"chorus.system.login-logs.list",
"sys_role_menu",
"sys_menu_api_rule",
"sys_casbin_rule",
} {
if !strings.Contains(up, required) {
t.Errorf("grouped navigation migration is missing %s", required)
}
}
for _, forbidden := range []string{
"chorus.system.menus.create",
"chorus.system.menus.update",
"chorus.system.menus.delete",
"chorus.system.apis.create",
"chorus.system.apis.update",
"chorus.system.apis.delete",
"chorus.system.login-logs.delete",
"automigrate",
"insert into sys_user",
} {
if strings.Contains(strings.ToLower(up), forbidden) {
t.Errorf("grouped navigation migration contains forbidden %s", forbidden)
}
}
down := read("000009_admin_grouped_navigation.down.sql")
for _, required := range []string{
"chorus_admin_grouped_navigation_down_guard",
"'Chorus 运营'",
"'/chorus/users', '用户管理', 70",
"DELETE FROM sys_api",
"handle LIKE 'chorus.system.%'",
"DELETE FROM sys_menu",
"'/chorus/configuration'",
"'/chorus/monitoring'",
"'/chorus/access'",
"'/chorus/system'",
} {
if !strings.Contains(down, required) {
t.Errorf("grouped navigation rollback is missing %s", required)
}
}
}
func TestPortalUsernameLoginMigrationContracts(t *testing.T) {
up, err := os.ReadFile("000008_portal_username_login.up.sql")
if err != nil {
t.Fatal(err)
}
for _, required := range []string{
"ADD COLUMN username VARCHAR(64) NULL",
"CONCAT('user_', id)",
"MODIFY COLUMN username VARCHAR(64) NOT NULL",
"ADD UNIQUE KEY uq_users_username (username)",
"ADD CONSTRAINT chk_users_username CHECK",
"BINARY username = BINARY LOWER(username)",
} {
if !strings.Contains(string(up), required) {
t.Errorf("username login migration is missing %s", required)
}
}
down, err := os.ReadFile("000008_portal_username_login.down.sql")
if err != nil {
t.Fatal(err)
}
for _, required := range []string{"DROP CHECK chk_users_username", "DROP INDEX uq_users_username", "DROP COLUMN username"} {
if !strings.Contains(string(down), required) {
t.Errorf("username login rollback is missing %s", required)
}
}
if strings.Contains(strings.ToLower(string(up)), "automigrate") {
t.Error("username login migration must not use AutoMigrate")
}
}
func TestAdminNavigationLocalizationMigrationContracts(t *testing.T) {
read := func(name string) string {
t.Helper()
content, err := os.ReadFile(name)
if err != nil {
t.Fatal(err)
}
return string(content)
}
up := read("000007_admin_navigation_localization.up.sql")
for _, required := range []string{
"WHEN '/chorus' THEN 'Chorus 运营'",
"WHEN '/chorus/providers' THEN '上游服务商'",
"WHEN '/chorus/models' THEN '模型配置'",
"WHEN '/chorus/routes' THEN '路由策略'",
"WHEN '/chorus/templates' THEN '提示词模板'",
"WHEN '/chorus/health' THEN '上游健康'",
"WHEN '/chorus/generations' THEN '生成记录'",
"WHEN '/chorus/users' THEN '用户管理'",
"WHEN '/chorus/api-keys' THEN 'API 密钥'",
"WHEN '/chorus/api-keys' THEN 80",
} {
if !strings.Contains(up, required) {
t.Errorf("localized navigation migration is missing %s", required)
}
}
for _, forbidden := range []string{"parent_id", "paths =", "component =", "permission =", "menu_name ="} {
if strings.Contains(strings.ToLower(up), forbidden) {
t.Errorf("localized navigation migration must not change %s", forbidden)
}
}
down := read("000007_admin_navigation_localization.down.sql")
for _, required := range []string{
"WHEN '/chorus/providers' THEN 'Providers'",
"WHEN '/chorus/routes' THEN 'Route Pools'",
"WHEN '/chorus/templates' THEN 'Prompt Templates'",
"WHEN '/chorus/health' THEN 'Provider Health'",
"WHEN '/chorus/api-keys' THEN 'API Keys'",
"WHEN '/chorus/api-keys' THEN 70",
} {
if !strings.Contains(down, required) {
t.Errorf("localized navigation rollback is missing %s", required)
}
}
}
func TestMVP2MigrationContracts(t *testing.T) {
read := func(name string) string {
t.Helper()
content, err := os.ReadFile(name)
if err != nil {
t.Fatal(err)
}
return string(content)
}
up := read("000006_mvp2_openapi_governance.up.sql")
for _, required := range []string{
"CREATE TABLE api_keys", "CREATE TABLE api_audit_events", "secret_hash BINARY(32)",
"uq_api_keys_public_id", "idx_api_keys_user_status", "idx_api_keys_last_used", "CHAR_LENGTH(public_id) = 24",
"CHAR_LENGTH(key_prefix) = 32", "available_at DATETIME(6)",
"idx_generations_queue (status, available_at", "/chorus/api-keys",
"chorus.api-keys.list", "chorus.api-keys.get", "chorus.api-keys.revoke",
} {
if !strings.Contains(up, required) {
t.Errorf("MVP-2 migration is missing %s", required)
}
}
if strings.Contains(strings.ToLower(up), "automigrate") {
t.Error("MVP-2 migration must not use AutoMigrate")
}
down := read("000006_mvp2_openapi_governance.down.sql")
for _, required := range []string{
"chorus_mvp2_governance_down_guard", "EXISTS (SELECT 1 FROM api_keys)",
"DROP TABLE api_audit_events", "DROP TABLE api_keys", "DROP COLUMN available_at",
"ADD KEY idx_generations_queue (status, lease_until, created_at)",
} {
if !strings.Contains(down, required) {
t.Errorf("MVP-2 rollback is missing %s", required)
}
}
}
func TestMVP1MigrationContracts(t *testing.T) {
read := func(name string) string {
t.Helper()
content, err := os.ReadFile(name)
if err != nil {
t.Fatal(err)
}
return string(content)
}
adminBaseline := read("000002_admin_baseline.up.sql")
for _, table := range []string{
"sys_user", "sys_role", "sys_menu", "sys_api", "sys_role_menu", "sys_menu_api_rule",
"sys_casbin_rule", "sys_tables", "sys_columns",
} {
if !regexp.MustCompile(`(?i)CREATE TABLE\s+` + regexp.QuoteMeta(table) + `\s*\(`).MatchString(adminBaseline) {
t.Errorf("admin baseline does not create %s", table)
}
}
if strings.Contains(strings.ToLower(adminBaseline), "automigrate") {
t.Error("admin baseline must not use AutoMigrate")
}
routing := read("000003_mvp1_routing.up.sql")
for _, required := range []string{
"provider_credentials", "provider_model_capabilities", "route_pools", "active_routes",
"route_pool_members", "route_member_runtime", "provider_connectivity_checks", "admin_audit_events",
"active_credential_id", "route_snapshot", "provider_attempt_count", "default_role_rule", "note",
"uq_provider_credentials_version", "uq_route_pools_slug", "uq_route_pool_members_model",
"idx_provider_connectivity_checks_model", "uq_admin_audit_events_request_action",
"DROP CHECK chk_providers_auth", "UPDATE providers SET api_key_enc = NULL",
} {
if !strings.Contains(routing, required) {
t.Errorf("MVP-1 routing migration is missing %s", required)
}
}
seed := read("000004_mvp1_admin_seed.up.sql")
for _, required := range []string{"chorus_operator", "chorus.providers.credential.rotate", "sys_role_menu", "sys_menu_api_rule", "sys_casbin_rule"} {
if !strings.Contains(seed, required) {
t.Errorf("admin seed is missing %s", required)
}
}
if strings.Contains(seed, "INSERT INTO sys_user") {
t.Error("admin seed must not create a known administrator account")
}
plaintext := read("000005_provider_plaintext_credentials.up.sql")
for _, required := range []string{
"chorus_plaintext_credentials_up_guard", "EXISTS (SELECT 1 FROM provider_credentials)",
"DROP COLUMN api_key_enc", "DROP COLUMN key_id", "ADD COLUMN api_key VARCHAR(4096)",
"chorus.providers.credential.get", "Cache-Control",
} {
if !strings.Contains(plaintext, required) {
t.Errorf("plaintext credential migration is missing %s", required)
}
}
plaintextDown := read("000005_provider_plaintext_credentials.down.sql")
for _, required := range []string{"chorus_plaintext_credentials_down_guard", "EXISTS (SELECT 1 FROM provider_credentials)", "ADD COLUMN api_key_enc JSON", "ADD COLUMN key_id"} {
if !strings.Contains(plaintextDown, required) {
t.Errorf("plaintext credential rollback is missing %s", required)
}
}
adminDown := read("000002_admin_baseline.down.sql")
for _, required := range []string{"chorus_admin_baseline_down_guard", "INSERT INTO chorus_admin_baseline_down_guard", "EXISTS (SELECT 1 FROM sys_user)"} {
if !strings.Contains(adminDown, required) {
t.Errorf("admin baseline down migration is missing rollback guard %s", required)
}
}
for _, name := range []string{"000002_admin_baseline.down.sql", "000003_mvp1_routing.down.sql", "000004_mvp1_admin_seed.down.sql"} {
down := read(name)
if strings.Contains(strings.ToLower(down), "delete from sys_menu where title") || strings.Contains(strings.ToLower(down), "delete from sys_api where title") {
t.Errorf("%s must delete Chorus seed data by stable keys, not titles", name)
}
}
}