package db import ( "context" "database/sql" "encoding/json" "os" "path/filepath" "strings" "testing" "ymhut-box/server/unified-management/internal/config" ) func TestOpenImportsJSONPrototypeIntoSQLite(t *testing.T) { root := t.TempDir() path := filepath.Join(root, "unified.sqlite") prototype := state{ Admins: []adminRow{{ ID: 1, Username: "admin", PasswordHash: passwordHash("admin"), PasswordChanged: false, CreatedAt: "2026-01-01T00:00:00Z", UpdatedAt: "2026-01-01T00:00:00Z", }}, Feedbacks: []Feedback{{Code: "FB-20260101-ABCDEF", Title: "Imported", Type: "issue", Severity: "normal", Body: "hello"}}, Sources: []Source{{CategoryID: "ip", CategoryName: "IP", SourceID: "ip-demo", Name: "IP Demo", APIURL: "https://example.com/ip", Enabled: true, ClientVisible: true}}, } data, err := json.Marshal(prototype) if err != nil { t.Fatal(err) } if err := os.WriteFile(path, data, 0o640); err != nil { t.Fatal(err) } store, err := Open(&config.Config{ StorageDir: root, Database: config.DatabaseConfig{ Provider: "sqlite", SQLitePath: path, FailoverEnabled: true, HealthIntervalSec: 3600, MaxOpenConns: 1, MaxIdleConns: 1, ConnMaxLifetimeSeconds: 60, }, UploadGuard: config.UploadGuardConfig{MaxZipFiles: 80, MaxDecompressedBytes: 30 << 20, MaxSingleFileBytes: 8 << 20, MaxCompressionRatio: 120, MaxReadableTextBytes: 256 << 10, AllowUnexpectedZipFiles: true}, }) if err != nil { t.Fatal(err) } defer store.Close() if _, _, err := store.VerifyAdminPassword(context.Background(), "admin", "admin"); err != nil { t.Fatal(err) } if _, err := store.GetFeedback("FB-20260101-ABCDEF"); err != nil { t.Fatal(err) } if count, err := store.CountSources(); err != nil || count != 1 { t.Fatalf("CountSources = %d, %v", count, err) } matches, _ := filepath.Glob(path + ".json-prototype-*.bak") if len(matches) != 1 { t.Fatalf("expected prototype backup, got %v", matches) } } func TestVerifyAdminPasswordUsesLocalSQLiteWhenRemoteIsUnavailable(t *testing.T) { root := t.TempDir() path := filepath.Join(root, "unified.sqlite") store, err := Open(&config.Config{ StorageDir: root, Database: config.DatabaseConfig{ Provider: "sqlite", SQLitePath: path, FailoverEnabled: true, HealthIntervalSec: 3600, MaxOpenConns: 1, MaxIdleConns: 1, ConnMaxLifetimeSeconds: 60, }, }) if err != nil { t.Fatal(err) } defer store.Close() if err := store.EnsureDefaultAdmin(context.Background()); err != nil { t.Fatal(err) } remote, err := sql.Open("sqlite", filepath.Join(root, "closed-remote.sqlite")) if err != nil { t.Fatal(err) } _ = remote.Close() store.cfg.Database.Provider = "mysql" store.mu.Lock() store.remoteDB = remote store.remoteDialect = dialectFor("sqlite") store.db = remote store.dialect = store.remoteDialect store.status.ActiveProvider = "mysql" store.status.ConfigProvider = "mysql" store.mu.Unlock() if _, ok, err := store.VerifyAdminPassword(context.Background(), "admin", "admin"); err != nil || !ok { t.Fatalf("VerifyAdminPassword local priority failed, ok=%v err=%v", ok, err) } } func TestOpenRecordsCurrentSchemaVersion(t *testing.T) { root := t.TempDir() path := filepath.Join(root, "unified.sqlite") store, err := Open(&config.Config{ StorageDir: root, Database: config.DatabaseConfig{ Provider: "sqlite", SQLitePath: path, FailoverEnabled: true, HealthIntervalSec: 3600, MaxOpenConns: 1, MaxIdleConns: 1, ConnMaxLifetimeSeconds: 60, }, }) if err != nil { t.Fatal(err) } defer store.Close() var description string if err := store.localDB.QueryRow(`SELECT description FROM schema_migrations WHERE version = ?`, CurrentSchemaVersion).Scan(&description); err != nil { t.Fatal(err) } if description == "" { t.Fatal("schema version description is empty") } } func TestChangeAdminPasswordPersistsWhenRemoteSyncFails(t *testing.T) { root := t.TempDir() path := filepath.Join(root, "unified.sqlite") store, err := Open(&config.Config{ StorageDir: root, Database: config.DatabaseConfig{ Provider: "sqlite", SQLitePath: path, FailoverEnabled: true, HealthIntervalSec: 3600, MaxOpenConns: 1, MaxIdleConns: 1, ConnMaxLifetimeSeconds: 60, }, }) if err != nil { t.Fatal(err) } defer store.Close() if err := store.EnsureDefaultAdmin(context.Background()); err != nil { t.Fatal(err) } remote, err := sql.Open("sqlite", filepath.Join(root, "closed-remote-password.sqlite")) if err != nil { t.Fatal(err) } _ = remote.Close() store.cfg.Database.Provider = "mysql" store.mu.Lock() store.remoteDB = remote store.remoteDialect = dialectFor("sqlite") store.status.ConfigProvider = "mysql" store.mu.Unlock() warning, err := store.ChangeAdminPasswordWithWarning(context.Background(), "admin", "admin", "new-local-password") if err != nil { t.Fatal(err) } if warning == "" { t.Fatal("expected remote sync warning") } if _, ok, err := store.VerifyAdminPassword(context.Background(), "admin", "new-local-password"); err != nil || !ok { t.Fatalf("new password was not persisted locally, ok=%v err=%v", ok, err) } if _, ok, err := store.VerifyAdminPassword(context.Background(), "admin", "admin"); err != nil || ok { t.Fatalf("old password still works, ok=%v err=%v", ok, err) } } func TestChangeAdminPasswordAcceptsRemoteCurrentPasswordAndPersistsLocal(t *testing.T) { root := t.TempDir() path := filepath.Join(root, "unified.sqlite") store, err := Open(&config.Config{ StorageDir: root, Database: config.DatabaseConfig{ Provider: "sqlite", SQLitePath: path, FailoverEnabled: true, HealthIntervalSec: 3600, MaxOpenConns: 1, MaxIdleConns: 1, ConnMaxLifetimeSeconds: 60, }, }) if err != nil { t.Fatal(err) } defer store.Close() if err := store.EnsureDefaultAdmin(context.Background()); err != nil { t.Fatal(err) } remote, err := sql.Open("sqlite", filepath.Join(root, "remote-password.sqlite")) if err != nil { t.Fatal(err) } remoteDialect := dialectFor("sqlite") if err := store.migrate(remote, remoteDialect); err != nil { t.Fatal(err) } if err := store.ensureDefaultAdminOn(remote, remoteDialect); err != nil { t.Fatal(err) } if err := store.changeAdminPasswordOn(remote, remoteDialect, "admin", passwordHash("remote-current-password"), Now(), false); err != nil { t.Fatal(err) } defer remote.Close() store.cfg.Database.Provider = "mysql" store.mu.Lock() store.remoteDB = remote store.remoteDialect = remoteDialect store.status.ConfigProvider = "mysql" store.mu.Unlock() if _, err := store.ChangeAdminPasswordWithWarning(context.Background(), "admin", "remote-current-password", "merged-password"); err != nil { t.Fatal(err) } if _, ok, err := store.verifyAdminPasswordOn(store.localDB, store.localDialect, "admin", "merged-password"); err != nil || !ok { t.Fatalf("new password was not persisted to local sqlite, ok=%v err=%v", ok, err) } if _, ok, err := store.verifyAdminPasswordOn(remote, remoteDialect, "admin", "merged-password"); err != nil || !ok { t.Fatalf("new password was not synced to remote, ok=%v err=%v", ok, err) } } func TestChangeAdminPasswordRejectsWeakPasswords(t *testing.T) { root := t.TempDir() path := filepath.Join(root, "unified.sqlite") store, err := Open(&config.Config{ StorageDir: root, Database: config.DatabaseConfig{ Provider: "sqlite", SQLitePath: path, FailoverEnabled: true, HealthIntervalSec: 3600, MaxOpenConns: 1, MaxIdleConns: 1, ConnMaxLifetimeSeconds: 60, }, }) if err != nil { t.Fatal(err) } defer store.Close() if err := store.EnsureDefaultAdmin(context.Background()); err != nil { t.Fatal(err) } for _, next := range []string{"", "short", "admin"} { if _, err := store.ChangeAdminPasswordWithWarning(context.Background(), "admin", "admin", next); err == nil { t.Fatalf("expected password %q to be rejected", next) } } } func TestMySQLSchemaAvoidsTextKeys(t *testing.T) { statements := strings.Join(schemaStatements(dialectFor("mysql")), "\n") for _, forbidden := range []string{ "TEXT NOT NULL UNIQUE", "TEXT PRIMARY KEY", "TEXT NOT NULL PRIMARY KEY", "key VARCHAR(191) NOT NULL PRIMARY KEY", } { if strings.Contains(statements, forbidden) { t.Fatalf("mysql schema contains forbidden fragment %q:\n%s", forbidden, statements) } } if !strings.Contains(statements, "`key` VARCHAR(191) NOT NULL PRIMARY KEY") { t.Fatalf("system_settings.key must be quoted for MySQL:\n%s", statements) } } func TestDashboardOverviewKeepsChecksForDeletedSources(t *testing.T) { root := t.TempDir() path := filepath.Join(root, "unified.sqlite") store, err := Open(&config.Config{ StorageDir: root, Database: config.DatabaseConfig{ Provider: "sqlite", SQLitePath: path, FailoverEnabled: true, HealthIntervalSec: 3600, MaxOpenConns: 1, MaxIdleConns: 1, ConnMaxLifetimeSeconds: 60, }, }) if err != nil { t.Fatal(err) } defer store.Close() source, err := store.UpsertSource(Source{ CategoryID: "video", CategoryName: "视频", SourceID: "video-demo", Name: "演示接口", APIURL: "https://example.com/video.json", Enabled: true, ClientVisible: true, }) if err != nil { t.Fatal(err) } if err := store.RecordSourceCheck(source.ID, "ok", 123, ""); err != nil { t.Fatal(err) } if err := store.DeleteSource(source.SourceID); err != nil { t.Fatal(err) } overview, err := store.DashboardOverview(10) if err != nil { t.Fatal(err) } checks, ok := overview["heartbeats"].([]map[string]any) if !ok { t.Fatalf("heartbeats has unexpected type %T", overview["heartbeats"]) } if len(checks) != 1 { t.Fatalf("expected deleted source check to remain visible, got %d", len(checks)) } if checks[0]["sourceId"] == "" || checks[0]["name"] == "" { t.Fatalf("deleted source check should have fallback sourceId/name: %#v", checks[0]) } }