312 lines
10 KiB
Go
312 lines
10 KiB
Go
package test
|
|
|
|
import (
|
|
domainArticle "RenewCMS/internal/domain/article"
|
|
domainInvite "RenewCMS/internal/domain/invite"
|
|
"RenewCMS/internal/infrastructure/persistence/database"
|
|
"RenewCMS/internal/infrastructure/persistence/models"
|
|
"database/sql"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
mysqldriver "github.com/go-sql-driver/mysql"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
func migratedMySQL(t *testing.T) (*mysqlTestServer, database.MySQLConfig, *gorm.DB) {
|
|
t.Helper()
|
|
server := connectMySQLTestServer(t)
|
|
config := server.createDatabase(t)
|
|
connection := server.connect(t, config)
|
|
if err := database.Migrate(connection.DB); err != nil {
|
|
t.Fatalf("migrate: %v", err)
|
|
}
|
|
return server, config, connection.DB
|
|
}
|
|
|
|
func columnCount(t *testing.T, db *gorm.DB, query string, arguments ...any) int {
|
|
t.Helper()
|
|
var count int
|
|
if err := db.Raw(query, arguments...).Scan(&count).Error; err != nil {
|
|
t.Fatalf("query %q: %v", query, err)
|
|
}
|
|
return count
|
|
}
|
|
|
|
func TestMySQLMigration(t *testing.T) {
|
|
t.Run("Migration on MySQL", func(t *testing.T) {
|
|
_, _, db := migratedMySQL(t)
|
|
|
|
for _, table := range []string{"articles", "images", "users", "invites", "api_keys", "purge_settings"} {
|
|
if !db.Migrator().HasTable(table) {
|
|
t.Errorf("table %s must exist", table)
|
|
}
|
|
}
|
|
uniqueColumns := map[string][]string{
|
|
"users": {"username", "email", "password_reset_code", "verification_code"},
|
|
"api_keys": {"hashed_key"},
|
|
}
|
|
for table, columns := range uniqueColumns {
|
|
for _, column := range columns {
|
|
count := columnCount(t, db, "SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = DATABASE() AND table_name = ? AND column_name = ? AND non_unique = 0 AND seq_in_index = 1", table, column)
|
|
if count == 0 {
|
|
t.Errorf("a unique index on %s.%s must exist", table, column)
|
|
}
|
|
}
|
|
}
|
|
binaryTables := columnCount(t, db, "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE() AND table_collation = 'utf8mb4_bin'")
|
|
if binaryTables != 6 {
|
|
t.Errorf("every table must use the utf8mb4_bin collation, got %d of 6", binaryTables)
|
|
}
|
|
})
|
|
|
|
t.Run("migrating twice changes nothing", func(t *testing.T) {
|
|
_, _, db := migratedMySQL(t)
|
|
|
|
if err := database.Migrate(db); err != nil {
|
|
t.Errorf("the second migration must succeed: %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("the first user lacking a role becomes owner and the others admin", func(t *testing.T) {
|
|
_, _, db := migratedMySQL(t)
|
|
for _, name := range []string{"first", "second", "third"} {
|
|
insert := db.Exec("INSERT INTO users (username, password, email, role, is_verified, token_version) VALUES (?, 'x', ?, '', 1, 0)", name, name+"@x.example")
|
|
if insert.Error != nil {
|
|
t.Fatalf("insert %s: %v", name, insert.Error)
|
|
}
|
|
}
|
|
|
|
if err := database.Migrate(db); err != nil {
|
|
t.Fatalf("migrate: %v", err)
|
|
}
|
|
|
|
roles := map[string]string{}
|
|
var users []models.User
|
|
db.Find(&users)
|
|
for _, user := range users {
|
|
roles[user.Username] = user.Role
|
|
}
|
|
if roles["first"] != "owner" || roles["second"] != "admin" || roles["third"] != "admin" {
|
|
t.Errorf("unexpected roles %v", roles)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestMySQLTimes(t *testing.T) {
|
|
t.Run("times are stored and read back in UTC", func(t *testing.T) {
|
|
_, _, db := migratedMySQL(t)
|
|
repos := newContractRepositories(db)
|
|
zone := time.FixedZone("plus2", 2*3600)
|
|
expiration := time.Date(2030, 5, 6, 7, 8, 9, 0, zone)
|
|
user := contractUser("zoned", "zoned@x.example")
|
|
user.VerificationExpiration = expiration
|
|
|
|
created := mustCreateUser(t, repos, user)
|
|
read, err := repos.users.Get(created.ID)
|
|
|
|
if err != nil || !read.VerificationExpiration.Equal(expiration) || read.VerificationExpiration.Location() != time.UTC {
|
|
t.Errorf("expected %v in UTC, got %v (%v) err %v", expiration, read.VerificationExpiration, read.VerificationExpiration.Location(), err)
|
|
}
|
|
})
|
|
|
|
t.Run("sessions allow the zero date the driver sends for a zero time", func(t *testing.T) {
|
|
_, _, db := migratedMySQL(t)
|
|
|
|
var mode string
|
|
if err := db.Raw("SELECT @@SESSION.sql_mode").Scan(&mode).Error; err != nil {
|
|
t.Fatalf("read sql_mode: %v", err)
|
|
}
|
|
|
|
if strings.Contains(mode, "NO_ZERO_DATE") || !strings.Contains(mode, "STRICT_TRANS_TABLES") {
|
|
t.Errorf("unexpected session sql_mode %q", mode)
|
|
}
|
|
})
|
|
}
|
|
|
|
func populateForBackup(t *testing.T, repos contractRepositories) {
|
|
t.Helper()
|
|
trickyName := "o'brien \"quoted\" back\\slash\nline é😀"
|
|
mustCreateUser(t, repos, contractUser(trickyName, "tricky@x.example"))
|
|
second := contractUser("plain", "plain@x.example")
|
|
second.PasswordResetCode = "reset-code"
|
|
mustCreateUser(t, repos, second)
|
|
if _, err := repos.articles.Create(domainArticle.Article{Title: "Title", Body: "line1\nline2 'q' \"d\" \\ \x00 end", IsOnline: true}); err != nil {
|
|
t.Fatalf("create article: %v", err)
|
|
}
|
|
mustCreateImage(t, repos, "pic.png", strings.Repeat("c", 64))
|
|
if _, err := repos.invites.Create(domainInvite.Invite{Email: "i@x.example", HashedCode: "h", ExpiresAt: time.Date(2030, 1, 1, 0, 0, 0, 0, time.UTC), InvitedBy: 1}); err != nil {
|
|
t.Fatalf("create invite: %v", err)
|
|
}
|
|
}
|
|
|
|
func steppingClock(start time.Time) func() time.Time {
|
|
current := start
|
|
return func() time.Time {
|
|
current = current.Add(time.Second)
|
|
return current
|
|
}
|
|
}
|
|
|
|
func TestMySQLBackup(t *testing.T) {
|
|
t.Run("Backup before an update", func(t *testing.T) {
|
|
_, _, db := migratedMySQL(t)
|
|
populateForBackup(t, newContractRepositories(db))
|
|
directory := filepath.Join(t.TempDir(), "backups")
|
|
backup := database.NewMySQLBackup(db, directory, steppingClock(time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)))
|
|
|
|
var paths []string
|
|
for range 5 {
|
|
path, err := backup.Backup()
|
|
if err != nil {
|
|
t.Fatalf("backup: %v", err)
|
|
}
|
|
paths = append(paths, path)
|
|
}
|
|
|
|
entries, _ := os.ReadDir(directory)
|
|
if len(entries) != 3 {
|
|
t.Fatalf("only the 3 newest backups are kept, got %d", len(entries))
|
|
}
|
|
for _, path := range paths[2:] {
|
|
if filepath.Ext(path) != ".sql" || !strings.HasPrefix(filepath.Base(path), "renewcms-") {
|
|
t.Errorf("unexpected backup name %q", path)
|
|
}
|
|
if _, err := os.Stat(path); err != nil {
|
|
t.Errorf("the newest backup %q must remain: %v", path, err)
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("a failure returns an error and leaves no file", func(t *testing.T) {
|
|
server := connectMySQLTestServer(t)
|
|
connection := server.connect(t, server.createDatabase(t))
|
|
directory := filepath.Join(t.TempDir(), "backups")
|
|
backup := database.NewMySQLBackup(connection.DB, directory, time.Now)
|
|
if err := connection.Close(); err != nil {
|
|
t.Fatalf("close: %v", err)
|
|
}
|
|
|
|
path, err := backup.Backup()
|
|
|
|
if err == nil || path != "" {
|
|
t.Errorf("expected a failure, got %q %v", path, err)
|
|
}
|
|
if entries, _ := os.ReadDir(directory); len(entries) != 0 {
|
|
t.Errorf("no file may remain, got %d entries", len(entries))
|
|
}
|
|
})
|
|
|
|
t.Run("MySQL backup content", func(t *testing.T) {
|
|
server, _, db := migratedMySQL(t)
|
|
repos := newContractRepositories(db)
|
|
populateForBackup(t, repos)
|
|
directory := filepath.Join(t.TempDir(), "backups")
|
|
backup := database.NewMySQLBackup(db, directory, time.Now)
|
|
|
|
path, err := backup.Backup()
|
|
if err != nil {
|
|
t.Fatalf("backup: %v", err)
|
|
}
|
|
content, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
dump := string(content)
|
|
|
|
for _, table := range []string{"articles", "images", "users", "invites", "api_keys", "purge_settings"} {
|
|
if !strings.Contains(dump, "CREATE TABLE `"+table+"`") {
|
|
t.Errorf("the dump must contain the schema of %s", table)
|
|
}
|
|
}
|
|
for _, table := range []string{"articles", "images", "users", "invites"} {
|
|
if !strings.Contains(dump, "INSERT INTO `"+table+"`") {
|
|
t.Errorf("the dump must contain rows of %s", table)
|
|
}
|
|
}
|
|
if password := server.config.Password; password != "" && strings.Contains(dump, password) {
|
|
t.Errorf("the dump must not contain the DB_PASSWORD value")
|
|
}
|
|
|
|
restoredConfig := server.createDatabase(t)
|
|
replayDump(t, restoredConfig, dump)
|
|
restored := server.connect(t, restoredConfig)
|
|
restoredPath, err := database.NewMySQLBackup(restored.DB, filepath.Join(t.TempDir(), "again"), time.Now).Backup()
|
|
if err != nil {
|
|
t.Fatalf("backup of the restored database: %v", err)
|
|
}
|
|
restoredContent, _ := os.ReadFile(restoredPath)
|
|
if line := firstDifferingLine(withoutRedundantCharset(dump), withoutRedundantCharset(string(restoredContent))); line != "" {
|
|
t.Errorf("replaying the dump must restore the same schema and rows, first difference: %s", line)
|
|
}
|
|
restoredUsers := newContractRepositories(restored.DB).users.GetAll()
|
|
if len(restoredUsers) != 2 || restoredUsers[0].Username != "o'brien \"quoted\" back\\slash\nline é😀" || restoredUsers[1].PasswordResetCode != "reset-code" {
|
|
t.Errorf("unexpected restored users %+v", restoredUsers)
|
|
}
|
|
restoredArticles, _ := newContractRepositories(restored.DB).articles.Get(1)
|
|
if restoredArticles.Body != "line1\nline2 'q' \"d\" \\ \x00 end" {
|
|
t.Errorf("unexpected restored article body %q", restoredArticles.Body)
|
|
}
|
|
})
|
|
}
|
|
|
|
func replayDump(t *testing.T, config database.MySQLConfig, dump string) {
|
|
t.Helper()
|
|
parsed, err := mysqldriver.ParseDSN(database.MySQLDSN(config))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
parsed.MultiStatements = true
|
|
replay, err := sql.Open("mysql", parsed.FormatDSN())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer replay.Close()
|
|
if _, err := replay.Exec(dump); err != nil {
|
|
t.Fatalf("replay: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestMySQLUsersAreCaseSensitiveThroughTheDomain(t *testing.T) {
|
|
t.Run("Bob and bob can both register", func(t *testing.T) {
|
|
_, _, db := migratedMySQL(t)
|
|
repos := newContractRepositories(db)
|
|
mustCreateUser(t, repos, contractUser("Bob", "b1@x.example"))
|
|
|
|
other, err := repos.users.Create(contractUser("bob", "b2@x.example"))
|
|
_, missing := repos.users.GetByUsername("BOB")
|
|
|
|
if err != nil || other.Username != "bob" || missing == nil {
|
|
t.Errorf("created %+v err %v, uppercase lookup err %v", other, err, missing)
|
|
}
|
|
if _, err := repos.users.GetByEmail("B1@X.EXAMPLE"); err == nil {
|
|
t.Error("emails are compared exactly like SQLite does")
|
|
}
|
|
})
|
|
}
|
|
|
|
func firstDifferingLine(expected string, actual string) string {
|
|
expectedLines, actualLines := strings.Split(expected, "\n"), strings.Split(actual, "\n")
|
|
for index := 0; index < max(len(expectedLines), len(actualLines)); index++ {
|
|
var want, got string
|
|
if index < len(expectedLines) {
|
|
want = expectedLines[index]
|
|
}
|
|
if index < len(actualLines) {
|
|
got = actualLines[index]
|
|
}
|
|
if want != got {
|
|
return fmt.Sprintf("line %d: want %q got %q", index+1, want, got)
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func withoutRedundantCharset(dump string) string {
|
|
return strings.ReplaceAll(dump, " CHARACTER SET utf8mb4", "")
|
|
}
|