textmachine/platform/internal/pgstore/migrations_test.go

102 lines
3 KiB
Go

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)
}
}
}