package store import ( "strings" "testing" "testing/fstest" ) // The refusals in this file are the ones that decide whether a schema can be trusted months // later. Each has a wrong answer that looks helpful — skip it, apply it anyway, warn and carry on // — and each of those turns "this database disagrees with these files" into a silence. func load(t *testing.T, files fstest.MapFS) ([]Migration, error) { t.Helper() return LoadMigrations(files, "migrations") } func file(body string) *fstest.MapFile { return &fstest.MapFile{Data: []byte(body)} } func TestMigrationsAreReadInNumericOrder(t *testing.T) { // Read from a directory, which has no order of its own. Alphabetical happens to agree with // numeric while the numbers are the same width, which is exactly why this is asserted rather // than assumed. got, err := load(t, fstest.MapFS{ "migrations/0010-tenth.sql": file("select 10"), "migrations/0002-second.sql": file("select 2"), "migrations/0001-first.sql": file("select 1"), }) if err != nil { t.Fatal(err) } var order []int for _, m := range got { order = append(order, m.Number) } if len(order) != 3 || order[0] != 1 || order[1] != 2 || order[2] != 10 { t.Errorf("migrations came back in order %v; they run in the order they are returned", order) } } func TestAMisnamedFileIsAnErrorRatherThanSkipped(t *testing.T) { // The tempting behaviour is to ignore anything that does not match, so that a README can sit // in the directory. The cost is that a migration named `001-thing.sql` or `0002_thing.sql` is // then ignored in silence, and the schema simply lacks it — surfacing later as a missing // column, a long way from the file that was misnamed. _, err := load(t, fstest.MapFS{ "migrations/0001-first.sql": file("select 1"), "migrations/0002_second.sql": file("select 2"), }) if err == nil { t.Fatal("a misnamed migration was skipped silently; it would never run and nothing would say so") } } func TestTwoMigrationsWithOneNumberAreRefused(t *testing.T) { // Order is the entire guarantee. Two files with one number have none, and whichever the // filesystem returned first would win. _, err := load(t, fstest.MapFS{ "migrations/0001-first.sql": file("select 1"), "migrations/0001-also-first.sql": file("select 2"), }) if err == nil { t.Fatal("two migrations numbered 0001 were accepted") } } func TestAnEmptyMigrationIsRefused(t *testing.T) { // An empty migration records that something happened and changes nothing — the one state that // cannot be told apart from a mistake, and it is recorded as done for ever. _, err := load(t, fstest.MapFS{"migrations/0001-nothing.sql": file(" \n\t\n")}) if err == nil { t.Fatal("an empty migration was accepted") } } func TestAChangedMigrationIsRefused(t *testing.T) { // The one that matters most. The database holds what the old file said; the repository holds // the new one; nothing anywhere holds the difference. Applying it again would be wrong and // skipping it silently leaves the two permanently out of step. migrations := []Migration{{Number: 1, Name: "nodes", Checksum: "aaaa"}} applied := []Applied{{Number: 1, Name: "nodes", Checksum: "bbbb"}} _, err := Pending(migrations, applied) if err == nil { t.Fatal("a migration whose file changed after it ran was accepted") } if !strings.Contains(err.Error(), "0001-nodes") { t.Errorf("the refusal does not name the migration: %v", err) } } func TestAnUnchangedMigrationIsNotPending(t *testing.T) { // The other half, and the one that makes the run idempotent. Without it every start would // re-apply the whole schema. migrations := []Migration{{Number: 1, Name: "nodes", Checksum: "aaaa"}} applied := []Applied{{Number: 1, Name: "nodes", Checksum: "aaaa"}} pending, err := Pending(migrations, applied) if err != nil { t.Fatal(err) } if len(pending) != 0 { t.Errorf("an already-applied migration came back as pending; every start would re-run it") } } func TestAMigrationArrivingBelowTheHighWaterMarkIsRefused(t *testing.T) { // Two branches take the same next number; they merge in whatever order they landed. The file // is fine and applying it now would run the schema in an order nobody tested — which is the // same class of fault as applying them out of order deliberately, arriving by accident. migrations := []Migration{ {Number: 1, Name: "nodes", Checksum: "aaaa"}, {Number: 2, Name: "late", Checksum: "cccc"}, {Number: 3, Name: "third", Checksum: "bbbb"}, } applied := []Applied{ {Number: 1, Name: "nodes", Checksum: "aaaa"}, {Number: 3, Name: "third", Checksum: "bbbb"}, } _, err := Pending(migrations, applied) if err == nil { t.Fatal("a migration numbered below one that already ran was applied out of order") } if !strings.Contains(err.Error(), "0002-late") { t.Errorf("the refusal does not name the migration: %v", err) } } func TestEveryDisagreementIsReportedNotOnlyTheFirst(t *testing.T) { // A person looking at this is deciding what to do about a schema. Being told one problem, // fixing it, and being told the next is how a single decision becomes four. migrations := []Migration{ {Number: 1, Name: "one", Checksum: "aaaa"}, {Number: 2, Name: "two", Checksum: "cccc"}, } applied := []Applied{ {Number: 1, Name: "one", Checksum: "changed"}, {Number: 3, Name: "three", Checksum: "dddd"}, } _, err := Pending(migrations, applied) if err == nil { t.Fatal("expected refusals") } if !strings.Contains(err.Error(), "0001-one") || !strings.Contains(err.Error(), "0002-two") { t.Errorf("only some problems were reported: %v", err) } } func TestAContextNameThatCannotBeADatabaseIsRefused(t *testing.T) { // The name becomes an environment variable and a database name. One that is valid in one and // not the other fails at bootstrap, on a machine with no mesh on it and nobody watching. // // Asserted on *which* refusal fired, not merely that one did. Open has a second reason to // fail a line later — no credential — and an unusable name would have produced an error // either way, so a test asking only "was there an error" passes with this check deleted. // It was written that way first and confirmed to defend nothing. for _, name := range []string{"", "Inventory", "my-context", "9lives", "drop table"} { _, err := Open(t.Context(), name) if err == nil { t.Errorf("%q was accepted as a context name", name) continue } if !strings.Contains(err.Error(), "not a usable context name") { t.Errorf("%q was refused for the wrong reason: %v", name, err) } } } func TestAContextWithoutItsCredentialIsRefused(t *testing.T) { // And the message has to say that reusing another context's connection is not the remedy, // because it is the obvious one and it is how ADR 0008 gets quietly undone. t.Setenv(Variable("inventory"), "") _, err := Open(t.Context(), "inventory") if err == nil { t.Fatal("a context with no credential opened a store") } if !strings.Contains(err.Error(), Variable("inventory")) { t.Errorf("the refusal does not name the variable that is missing: %v", err) } } func TestAMalformedConnectionStringIsNotQuotedBack(t *testing.T) { // The value carries a password. An error message that quotes what it could not parse puts it // into a log, on the one machine where the bootstrap output is being watched by a person. secret := "hunter2-this-must-not-appear" t.Setenv(Variable("inventory"), "postgres://user:"+secret+"@host:notaport/db") _, err := Open(t.Context(), "inventory") if err == nil { t.Fatal("a malformed connection string was accepted") } if strings.Contains(err.Error(), secret) { t.Errorf("the password appeared in the error: %v", err) } } func TestTheVariableAndDatabaseFollowTheContextName(t *testing.T) { if got := Variable("inventory"); got != "MESH_STORE_INVENTORY" { t.Errorf("Variable(inventory) = %q", got) } if got := Database("inventory"); got != "inventory" { t.Errorf("Database(inventory) = %q", got) } }