package pgstore import ( "crypto/sha256" "fmt" "io/fs" "os" "regexp" "strconv" "strings" "testing" ) // The migration SET is checkable without a database, and that check is worth having: a file whose // name does not parse is not "skipped", it silently never runs. func TestMigrationSetIsWellFormed(t *testing.T) { names, err := fs.Glob(Migrations(), "*") if err != nil { t.Fatal(err) } if len(names) == 0 { t.Fatal("no migrations embedded") } nameRe := regexp.MustCompile(`^(\d{5})_[a-z0-9_]+\.sql$`) prev := 0 for _, name := range names { m := nameRe.FindStringSubmatch(name) if m == nil { t.Fatalf("%s: goose expects NNNNN_name.sql", name) } version, _ := strconv.Atoi(m[1]) if version <= prev { t.Fatalf("%s: versions must ascend and never repeat (previous %05d)", name, prev) } prev = version body, err := fs.ReadFile(Migrations(), name) if err != nil { t.Fatal(err) } for _, marker := range []string{"-- +goose Up", "-- +goose Down"} { if !strings.Contains(string(body), marker) { // A missing Down is not cosmetic: a rollout that cannot be rolled back is a // one-way door, and goose reports it only when someone tries to walk back. t.Fatalf("%s: missing %q", name, marker) } } } } // A released migration is immutable, and this is the check that makes that true rather than // intended. goose applies by NUMBER alone — no name, no checksum — so a file edited after it has // run somewhere silently never runs again, and a number reused for different SQL leaves that // database unable to roll back at all. Both were reproduced on a live PostgreSQL before this test // existed; the prose rule that was supposed to prevent them did not. func TestReleasedMigrationsAreUnchanged(t *testing.T) { manifest, err := os.ReadFile("migrations.sha256") if err != nil { t.Fatal(err) } recorded := map[string]string{} for line := range strings.Lines(string(manifest)) { line = strings.TrimSpace(line) if line == "" || strings.HasPrefix(line, "#") { continue } sum, name, ok := strings.Cut(line, " ") if !ok { t.Fatalf("migrations.sha256: cannot read %q", line) } recorded[name] = sum } names, err := fs.Glob(Migrations(), "*.sql") if err != nil { t.Fatal(err) } present := map[string]bool{} for _, name := range names { present[name] = true body, err := fs.ReadFile(Migrations(), name) if err != nil { t.Fatal(err) } got := fmt.Sprintf("%x", sha256.Sum256(body)) want, listed := recorded[name] if !listed { t.Errorf("%s is not in migrations.sha256: append its line when you add a migration", name) continue } if got != want { t.Errorf("%s changed after release (%s, recorded %s): a released migration is immutable — "+ "add a new one instead", name, got[:12], want[:12]) } } for name := range recorded { if !present[name] { t.Errorf("%s is listed in migrations.sha256 but gone: a released migration cannot be "+ "deleted, and its number cannot be reused", name) } } }