package migrations import ( "crypto/sha256" "encoding/hex" "fmt" "os" "path/filepath" "sort" "strconv" "strings" "testing" ) // checksumFileName ist die eingefrorene Prüfsummenliste. const checksumFileName = "checksums.txt" // TestReleasedMigrationsAreImmutable haelt das Migrationsverfahren fest (Phase 22). // // **Eine ausgelieferte Migration darf sich nie wieder aendern.** Der Grund ist // unangenehm einfach: Datenbanken, die sie bereits angewandt haben, fuehren sie // nicht erneut aus — golang-migrate merkt sich nur die Versionsnummer. Eine // nachtraeglich geaenderte Migration wirkt deshalb ausschliesslich auf // **neue** Installationen. Das Ergebnis sind zwei Schemata mit derselben // Versionsnummer, und der Unterschied faellt erst auf, wenn eine Abfrage auf // einer der beiden Anlagen scheitert. // // Wer etwas aendern will, schreibt eine neue Migration. Immer. // // Aendert sich eine Zeile hier, ist die Frage nicht "wie mache ich den Test // gruen", sondern: Hat diese Migration schon jemand angewandt? Wenn ja, ist die // Aenderung falsch, egal wie klein sie ist. func TestReleasedMigrationsAreImmutable(testInstance *testing.T) { currentChecksums, computeError := computeMigrationChecksums() if computeError != nil { testInstance.Fatalf("die Prüfsummen ließen sich nicht bilden: %v", computeError) } frozenContent, readError := os.ReadFile(checksumFileName) if readError != nil { testInstance.Fatalf("die eingefrorene Prüfsummenliste ließ sich nicht lesen: %v", readError) } frozenChecksums := parseChecksumFile(string(frozenContent)) for fileName, frozenDigest := range frozenChecksums { currentDigest, isPresent := currentChecksums[fileName] if !isPresent { testInstance.Errorf("die ausgelieferte Migration %s fehlt.\n"+ " Anlagen, die sie angewandt haben, lassen sich damit nicht mehr zurücknehmen.", fileName) continue } if currentDigest != frozenDigest { testInstance.Errorf("die ausgelieferte Migration %s wurde verändert.\n"+ " Datenbanken, die sie bereits angewandt haben, führen sie nicht erneut aus — "+ "die Änderung wirkt nur auf neue Installationen.\n"+ " Schreiben Sie stattdessen eine neue Migration.", fileName) } } // Neue Migrationen sind der Normalfall und kein Fehler; sie müssen aber // eingetragen werden, sonst schützt die Liste ab morgen nichts mehr. for fileName := range currentChecksums { if _, isFrozen := frozenChecksums[fileName]; !isFrozen { testInstance.Errorf("die neue Migration %s ist nicht in %s eingetragen.\n"+ " Ohne Eintrag bleibt sie auf Dauer ungeschützt.", fileName, checksumFileName) } } } // TestEveryMigrationHasBothDirections prueft die Vollstaendigkeit. // // Eine Migration ohne Gegenstueck laesst sich nicht zurueckziehen. Das faellt // genau dann auf, wenn man es braucht: nach einem missglueckten Upgrade in der // Produktion. func TestEveryMigrationHasBothDirections(testInstance *testing.T) { directoryEntries, readError := os.ReadDir(".") if readError != nil { testInstance.Fatalf("das Migrationsverzeichnis ließ sich nicht lesen: %v", readError) } directionsByVersion := make(map[string]map[string]bool) for _, singleEntry := range directoryEntries { if !strings.HasSuffix(singleEntry.Name(), ".sql") { continue } versionPart, directionPart, isValid := splitMigrationName(singleEntry.Name()) if !isValid { testInstance.Errorf("die Datei %q folgt nicht dem Namensschema "+ "_..sql", singleEntry.Name()) continue } if directionsByVersion[versionPart] == nil { directionsByVersion[versionPart] = make(map[string]bool) } directionsByVersion[versionPart][directionPart] = true } if len(directionsByVersion) == 0 { testInstance.Fatal("es wurde keine einzige Migration gefunden") } for versionPart, foundDirections := range directionsByVersion { if !foundDirections["up"] { testInstance.Errorf("zur Migration %s fehlt die Aufwärtsrichtung", versionPart) } if !foundDirections["down"] { testInstance.Errorf("zur Migration %s fehlt die Abwärtsrichtung; "+ "sie ließe sich nicht zurücknehmen", versionPart) } } // Lückenlose Nummerierung: Eine fehlende Nummer bedeutet entweder eine // verlorene Migration oder eine, die jemand aus dem Verzeichnis genommen // hat, nachdem sie schon lief. versionNumbers := make([]int, 0, len(directionsByVersion)) for versionPart := range directionsByVersion { parsedNumber, parseError := strconv.Atoi(versionPart) if parseError != nil { testInstance.Errorf("die Version %q ist keine Zahl", versionPart) continue } versionNumbers = append(versionNumbers, parsedNumber) } sort.Ints(versionNumbers) for numberIndex, versionNumber := range versionNumbers { if versionNumber != numberIndex+1 { testInstance.Errorf("die Nummerierung hat eine Lücke: erwartet %d, gefunden %d", numberIndex+1, versionNumber) break } } } // computeMigrationChecksums bildet die Pruefsumme jeder Migrationsdatei. // // Ueber das eingebettete Dateisystem und nicht ueber die Festplatte: Genau // dieser Inhalt landet im Binary, und nur er wird jemals ausgefuehrt. func computeMigrationChecksums() (map[string]string, error) { directoryEntries, readError := FS.ReadDir(".") if readError != nil { return nil, readError } checksumsByName := make(map[string]string, len(directoryEntries)) for _, singleEntry := range directoryEntries { if !strings.HasSuffix(singleEntry.Name(), ".sql") { continue } fileContent, contentError := FS.ReadFile(singleEntry.Name()) if contentError != nil { return nil, fmt.Errorf("%s: %w", singleEntry.Name(), contentError) } contentDigest := sha256.Sum256(fileContent) checksumsByName[singleEntry.Name()] = hex.EncodeToString(contentDigest[:]) } return checksumsByName, nil } // parseChecksumFile liest die eingefrorene Liste. func parseChecksumFile(fileContent string) map[string]string { parsedChecksums := make(map[string]string, 32) for _, currentLine := range strings.Split(fileContent, "\n") { trimmedLine := strings.TrimSpace(currentLine) if trimmedLine == "" || strings.HasPrefix(trimmedLine, "#") { continue } digestPart, namePart, hasSeparator := strings.Cut(trimmedLine, " ") if !hasSeparator { continue } parsedChecksums[strings.TrimSpace(namePart)] = strings.TrimSpace(digestPart) } return parsedChecksums } // splitMigrationName zerlegt einen Migrationsdateinamen. func splitMigrationName(fileName string) (version string, direction string, isValid bool) { baseName := strings.TrimSuffix(filepath.Base(fileName), ".sql") remainingName, directionPart, hasDirection := cutLast(baseName, ".") if !hasDirection || (directionPart != "up" && directionPart != "down") { return "", "", false } versionPart, _, hasDescription := strings.Cut(remainingName, "_") if !hasDescription || versionPart == "" { return "", "", false } return versionPart, directionPart, true } // cutLast trennt an der letzten Fundstelle. func cutLast(fullText string, separator string) (before string, after string, found bool) { separatorIndex := strings.LastIndex(fullText, separator) if separatorIndex < 0 { return fullText, "", false } return fullText[:separatorIndex], fullText[separatorIndex+len(separator):], true }