Files
YMhut-box-C-/server/unified-management/internal/db/store_test.go
T
admin_gitea f00124c1c0
build-winui / winui (push) Waiting to run
更新客户端适配性问题
2026-06-28 08:56:45 +08:00

394 lines
12 KiB
Go

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 TestSchemaIndexStatementsAreDialectAware(t *testing.T) {
mysql := dialectFor("mysql")
sqlite := dialectFor("sqlite")
for _, statement := range schemaStatements(mysql) {
if strings.Contains(strings.ToUpper(statement), "CREATE INDEX IF NOT EXISTS") {
t.Fatalf("mysql schema statement contains unsupported index syntax: %s", statement)
}
}
index := schemaIndexes()[0]
mysqlStatement := createIndexStatement(mysql, index)
if strings.Contains(strings.ToUpper(mysqlStatement), "IF NOT EXISTS") {
t.Fatalf("mysql index statement contains unsupported syntax: %s", mysqlStatement)
}
if !strings.Contains(createIndexStatement(sqlite, index), "IF NOT EXISTS") {
t.Fatalf("sqlite index statement should remain idempotent")
}
}
func TestMigrateCreatesSQLiteIndexesIdempotently(t *testing.T) {
root := t.TempDir()
conn, err := sql.Open("sqlite", filepath.Join(root, "indexes.sqlite"))
if err != nil {
t.Fatal(err)
}
defer conn.Close()
store := &Store{}
d := dialectFor("sqlite")
if err := store.migrate(conn, d); err != nil {
t.Fatal(err)
}
if err := store.migrate(conn, d); err != nil {
t.Fatal(err)
}
var name string
if err := conn.QueryRow(`SELECT name FROM sqlite_master WHERE type = 'index' AND name = ?`, "idx_feedback_tickets_activity").Scan(&name); err != nil {
t.Fatal(err)
}
if name != "idx_feedback_tickets_activity" {
t.Fatalf("unexpected index name %q", name)
}
}
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])
}
}