|
6 | 6 | "errors" |
7 | 7 | "fmt" |
8 | 8 | "maps" |
| 9 | + "net/url" |
9 | 10 | "strings" |
10 | 11 | "testing" |
11 | 12 | "testing/fstest" |
@@ -972,3 +973,110 @@ func TestApplySource_DirtyStateRecoveryOnRestart(t *testing.T) { |
972 | 973 | t.Errorf("after restart: version = %d, want 1 (dirty cleared, rolled back)", got) |
973 | 974 | } |
974 | 975 | } |
| 976 | + |
| 977 | +// TestVerifyMigrated covers the SKIP_MIGRATIONS boot guard: refuse an |
| 978 | +// un-migrated or behind or dirty database, tolerate exact-match and ahead |
| 979 | +// (compatibility mode), and work on a connection whose role has no DDL |
| 980 | +// privileges — the deployment mode the guard exists for. |
| 981 | +func TestVerifyMigrated(t *testing.T) { |
| 982 | + ctx := context.Background() |
| 983 | + connStr := startTestDB(t) |
| 984 | + full := []Source{coreSource(goodCoreFS)} // max embedded version 2 |
| 985 | + |
| 986 | + t.Run("unmigrated database is refused", func(t *testing.T) { |
| 987 | + err := VerifyMigrated(ctx, connStr, full) |
| 988 | + if err == nil || !strings.Contains(err.Error(), "has not been migrated") { |
| 989 | + t.Fatalf("error = %v, want missing-tracking-table refusal", err) |
| 990 | + } |
| 991 | + }) |
| 992 | + |
| 993 | + t.Run("pending migrations are refused", func(t *testing.T) { |
| 994 | + if _, err := applySource(ctx, connStr, coreSource(oneCoreFS)); err != nil { |
| 995 | + t.Fatalf("apply v1: %v", err) |
| 996 | + } |
| 997 | + err := VerifyMigrated(ctx, connStr, full) |
| 998 | + if err == nil || !strings.Contains(err.Error(), "requires version 2") { |
| 999 | + t.Fatalf("error = %v, want behind-binary refusal", err) |
| 1000 | + } |
| 1001 | + }) |
| 1002 | + |
| 1003 | + t.Run("fully migrated database passes", func(t *testing.T) { |
| 1004 | + if _, err := applySource(ctx, connStr, coreSource(goodCoreFS)); err != nil { |
| 1005 | + t.Fatalf("apply v2: %v", err) |
| 1006 | + } |
| 1007 | + if err := VerifyMigrated(ctx, connStr, full); err != nil { |
| 1008 | + t.Fatalf("VerifyMigrated() = %v, want nil", err) |
| 1009 | + } |
| 1010 | + }) |
| 1011 | + |
| 1012 | + t.Run("works without DDL privileges", func(t *testing.T) { |
| 1013 | + db, err := sql.Open("pgx", connStr) |
| 1014 | + if err != nil { |
| 1015 | + t.Fatal(err) |
| 1016 | + } |
| 1017 | + defer db.Close() |
| 1018 | + for _, q := range []string{ |
| 1019 | + `CREATE ROLE readonly LOGIN PASSWORD 'ro'`, |
| 1020 | + `GRANT USAGE ON SCHEMA public TO readonly`, |
| 1021 | + `GRANT SELECT ON ALL TABLES IN SCHEMA public TO readonly`, |
| 1022 | + } { |
| 1023 | + if _, err := db.ExecContext(ctx, q); err != nil { |
| 1024 | + t.Fatalf("%s: %v", q, err) |
| 1025 | + } |
| 1026 | + } |
| 1027 | + u, err := url.Parse(connStr) |
| 1028 | + if err != nil { |
| 1029 | + t.Fatal(err) |
| 1030 | + } |
| 1031 | + u.User = url.UserPassword("readonly", "ro") |
| 1032 | + if err := VerifyMigrated(ctx, u.String(), full); err != nil { |
| 1033 | + t.Fatalf("VerifyMigrated() as readonly = %v, want nil", err) |
| 1034 | + } |
| 1035 | + }) |
| 1036 | + |
| 1037 | + t.Run("database ahead of binary passes", func(t *testing.T) { |
| 1038 | + if err := VerifyMigrated(ctx, connStr, []Source{coreSource(oneCoreFS)}); err != nil { |
| 1039 | + t.Fatalf("VerifyMigrated() with older binary = %v, want nil (compatibility mode)", err) |
| 1040 | + } |
| 1041 | + }) |
| 1042 | + |
| 1043 | + t.Run("dirty tracking table is refused", func(t *testing.T) { |
| 1044 | + db, err := sql.Open("pgx", connStr) |
| 1045 | + if err != nil { |
| 1046 | + t.Fatal(err) |
| 1047 | + } |
| 1048 | + defer db.Close() |
| 1049 | + if _, err := db.ExecContext(ctx, `UPDATE schema_migrations SET dirty = true`); err != nil { |
| 1050 | + t.Fatal(err) |
| 1051 | + } |
| 1052 | + defer func() { |
| 1053 | + if _, err := db.ExecContext(ctx, `UPDATE schema_migrations SET dirty = false`); err != nil { |
| 1054 | + t.Fatal(err) |
| 1055 | + } |
| 1056 | + }() |
| 1057 | + err = VerifyMigrated(ctx, connStr, full) |
| 1058 | + if err == nil || !strings.Contains(err.Error(), "dirty") { |
| 1059 | + t.Fatalf("error = %v, want dirty refusal", err) |
| 1060 | + } |
| 1061 | + }) |
| 1062 | + |
| 1063 | + t.Run("resolved schema collision is refused", func(t *testing.T) { |
| 1064 | + // Schema "" resolves to public here, colliding with the explicit |
| 1065 | + // "public" source on the same tracking table — the same source set |
| 1066 | + // RunUp rejects. |
| 1067 | + collide := []Source{ |
| 1068 | + coreSource(goodCoreFS), |
| 1069 | + {Name: "explicit", Schema: "public", TrackingTable: "schema_migrations", FS: goodCoreFS, Dir: "core"}, |
| 1070 | + } |
| 1071 | + err := VerifyMigrated(ctx, connStr, collide) |
| 1072 | + if err == nil || !strings.Contains(err.Error(), "resolve to the same tracking table") { |
| 1073 | + t.Fatalf("error = %v, want resolved-schema collision refusal", err) |
| 1074 | + } |
| 1075 | + }) |
| 1076 | + |
| 1077 | + t.Run("no sources is a no-op", func(t *testing.T) { |
| 1078 | + if err := VerifyMigrated(ctx, "postgres://unused", nil); err != nil { |
| 1079 | + t.Fatalf("VerifyMigrated() with no sources = %v, want nil", err) |
| 1080 | + } |
| 1081 | + }) |
| 1082 | +} |
0 commit comments