429 lines
15 KiB
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)
|
|
}
|
|
}
|
|
}
|