|
| 1 | +package schema |
| 2 | + |
| 3 | +import ( |
| 4 | + "context" |
| 5 | + "errors" |
| 6 | + "os" |
| 7 | + "testing" |
| 8 | + |
| 9 | + "github.com/jackc/pgx/v5/pgconn" |
| 10 | +) |
| 11 | + |
| 12 | +// Live tests for the connection-layer read-only enforcement (PLAN-compliance |
| 13 | +// Item 1). These assert the compliance guarantee itself: every session dryrun |
| 14 | +// opens is read-only with defensive timeouts, an attempted write fails with |
| 15 | +// SQLSTATE 25006, and the one sanctioned writer (InjectStats) can still write |
| 16 | +// through a guarded connection. Gated on TEST_DATABASE_URL like stats_test.go. |
| 17 | + |
| 18 | +func liveDryRun(t *testing.T) (*DryRun, context.Context) { |
| 19 | + t.Helper() |
| 20 | + url := os.Getenv("TEST_DATABASE_URL") |
| 21 | + if url == "" { |
| 22 | + t.Skip("TEST_DATABASE_URL not set; skipping live guards test") |
| 23 | + } |
| 24 | + ctx := context.Background() |
| 25 | + conn, err := Connect(ctx, url) |
| 26 | + if err != nil { |
| 27 | + t.Fatalf("Connect: %v", err) |
| 28 | + } |
| 29 | + t.Cleanup(conn.Close) |
| 30 | + return conn, ctx |
| 31 | +} |
| 32 | + |
| 33 | +func settingValue(t *testing.T, ctx context.Context, conn *DryRun, name string) string { |
| 34 | + t.Helper() |
| 35 | + var v string |
| 36 | + if err := conn.Pool().QueryRow(ctx, |
| 37 | + "SELECT setting FROM pg_settings WHERE name = $1", name).Scan(&v); err != nil { |
| 38 | + t.Fatalf("pg_settings(%s): %v", name, err) |
| 39 | + } |
| 40 | + return v |
| 41 | +} |
| 42 | + |
| 43 | +// The default connection path must set all four session guards. pg_settings |
| 44 | +// reports timeouts in milliseconds, so the expectations are the raw ms values |
| 45 | +// from DefaultSessionGuards. |
| 46 | +func TestConnect_SetsSessionGuards(t *testing.T) { |
| 47 | + conn, ctx := liveDryRun(t) |
| 48 | + |
| 49 | + want := map[string]string{ |
| 50 | + "default_transaction_read_only": "on", |
| 51 | + "statement_timeout": "30000", |
| 52 | + "lock_timeout": "2000", |
| 53 | + "idle_in_transaction_session_timeout": "10000", |
| 54 | + } |
| 55 | + for name, expected := range want { |
| 56 | + if got := settingValue(t, ctx, conn, name); got != expected { |
| 57 | + t.Errorf("%s = %q, want %q", name, got, expected) |
| 58 | + } |
| 59 | + } |
| 60 | +} |
| 61 | + |
| 62 | +// The compliance deliverable: a write through dryrun's default connection path |
| 63 | +// must fail with SQLSTATE 25006 (read_only_sql_transaction), not silently |
| 64 | +// succeed and not fail with some unrelated error. |
| 65 | +func TestConnect_RefusesWrites(t *testing.T) { |
| 66 | + conn, ctx := liveDryRun(t) |
| 67 | + |
| 68 | + _, err := conn.Pool().Exec(ctx, "CREATE TABLE guards_write_probe (id int)") |
| 69 | + if err == nil { |
| 70 | + conn.Pool().Exec(ctx, "DROP TABLE guards_write_probe") |
| 71 | + t.Fatal("write succeeded through the default read-only connection path") |
| 72 | + } |
| 73 | + var pgErr *pgconn.PgError |
| 74 | + if !errors.As(err, &pgErr) { |
| 75 | + t.Fatalf("expected *pgconn.PgError, got %T: %v", err, err) |
| 76 | + } |
| 77 | + if pgErr.Code != "25006" { |
| 78 | + t.Errorf("SQLSTATE = %s, want 25006 (read_only_sql_transaction): %v", pgErr.Code, err) |
| 79 | + } |
| 80 | +} |
| 81 | + |
| 82 | +// A guard with everything disabled must leave the session untouched — the |
| 83 | +// opt-out path for local-dev connections must not half-apply defaults. |
| 84 | +func TestConnectWithGuards_ZeroDisables(t *testing.T) { |
| 85 | + url := os.Getenv("TEST_DATABASE_URL") |
| 86 | + if url == "" { |
| 87 | + t.Skip("TEST_DATABASE_URL not set; skipping live guards test") |
| 88 | + } |
| 89 | + ctx := context.Background() |
| 90 | + conn, err := ConnectWithGuards(ctx, url, SessionGuards{}) |
| 91 | + if err != nil { |
| 92 | + t.Fatalf("ConnectWithGuards: %v", err) |
| 93 | + } |
| 94 | + defer conn.Close() |
| 95 | + |
| 96 | + if got := settingValue(t, ctx, conn, "default_transaction_read_only"); got != "off" { |
| 97 | + t.Errorf("default_transaction_read_only = %q, want off (guards disabled)", got) |
| 98 | + } |
| 99 | +} |
| 100 | + |
| 101 | +// Regression guard for the sanctioned writer: InjectStats runs through a |
| 102 | +// guarded (read-only-by-default) connection and must still be able to write, |
| 103 | +// because it opts out with SET TRANSACTION READ WRITE at the top of its tx. |
| 104 | +// Fixture setup/teardown uses a raw unguarded pool, mirroring the reality that |
| 105 | +// only dryrun's own sessions carry the guards. |
| 106 | +func TestInjectStats_WritesThroughGuardedConnection(t *testing.T) { |
| 107 | + raw := livePool(t) |
| 108 | + conn, ctx := liveDryRun(t) |
| 109 | + major := serverMajor(t, ctx, raw) |
| 110 | + |
| 111 | + if _, err := raw.Exec(ctx, ` |
| 112 | + DROP TABLE IF EXISTS guards_inject; |
| 113 | + CREATE TABLE guards_inject (id int PRIMARY KEY, v text); |
| 114 | + `); err != nil { |
| 115 | + t.Fatalf("setup: %v", err) |
| 116 | + } |
| 117 | + t.Cleanup(func() { raw.Exec(ctx, "DROP TABLE IF EXISTS guards_inject") }) |
| 118 | + |
| 119 | + a := &AnnotatedSchema{ |
| 120 | + Schema: &SchemaSnapshot{Tables: []Table{ |
| 121 | + {Schema: "public", Name: "guards_inject", Columns: []Column{{Name: "id"}, {Name: "v"}}}, |
| 122 | + }}, |
| 123 | + Planner: &PlannerStatsSnapshot{Tables: []TableSizingEntry{ |
| 124 | + {Table: qn("public", "guards_inject"), Sizing: TableSizing{Reltuples: 10_000, Relpages: 100}}, |
| 125 | + }}, |
| 126 | + } |
| 127 | + |
| 128 | + res, err := InjectStats(ctx, conn.Pool(), a, major) |
| 129 | + if err != nil { |
| 130 | + t.Fatalf("InjectStats through guarded connection: %v", err) |
| 131 | + } |
| 132 | + if res.TablesUpdated != 1 { |
| 133 | + t.Errorf("TablesUpdated = %d, want 1 (read-only opt-out broken?), warnings: %v", |
| 134 | + res.TablesUpdated, res.Warnings) |
| 135 | + } |
| 136 | +} |
| 137 | + |
| 138 | +// RoleReport must reflect pg_roles for current_user exactly — the preflight's |
| 139 | +// fail-closed decision hangs off these three booleans. |
| 140 | +func TestRoleReport_MatchesCatalog(t *testing.T) { |
| 141 | + conn, ctx := liveDryRun(t) |
| 142 | + |
| 143 | + report, err := conn.RoleReport(ctx) |
| 144 | + if err != nil { |
| 145 | + t.Fatalf("RoleReport: %v", err) |
| 146 | + } |
| 147 | + |
| 148 | + var ( |
| 149 | + rolname string |
| 150 | + super, repl, bypassrls bool |
| 151 | + ) |
| 152 | + err = conn.Pool().QueryRow(ctx, ` |
| 153 | + SELECT rolname, rolsuper, rolreplication, rolbypassrls |
| 154 | + FROM pg_roles WHERE rolname = current_user`). |
| 155 | + Scan(&rolname, &super, &repl, &bypassrls) |
| 156 | + if err != nil { |
| 157 | + t.Fatalf("pg_roles: %v", err) |
| 158 | + } |
| 159 | + |
| 160 | + if report.Rolname != rolname || report.Super != super || |
| 161 | + report.Replication != repl || report.BypassRLS != bypassrls { |
| 162 | + t.Errorf("RoleReport = %+v, want {%s %t %t %t}", report, rolname, super, repl, bypassrls) |
| 163 | + } |
| 164 | +} |
| 165 | + |
| 166 | +// Pure classification tests: which combinations count as privileged and how |
| 167 | +// they render in the refusal message. |
| 168 | +func TestRoleReport_Privileges(t *testing.T) { |
| 169 | + cases := []struct { |
| 170 | + name string |
| 171 | + report RoleReport |
| 172 | + privileged bool |
| 173 | + privs []string |
| 174 | + }{ |
| 175 | + {"plain role", RoleReport{Rolname: "dryrun_readonly"}, false, nil}, |
| 176 | + {"superuser", RoleReport{Rolname: "postgres", Super: true}, true, []string{"superuser"}}, |
| 177 | + {"replication", RoleReport{Replication: true}, true, []string{"replication"}}, |
| 178 | + {"bypassrls", RoleReport{BypassRLS: true}, true, []string{"bypassrls"}}, |
| 179 | + {"all three", RoleReport{Super: true, Replication: true, BypassRLS: true}, true, |
| 180 | + []string{"superuser", "replication", "bypassrls"}}, |
| 181 | + } |
| 182 | + for _, tc := range cases { |
| 183 | + t.Run(tc.name, func(t *testing.T) { |
| 184 | + if got := tc.report.Privileged(); got != tc.privileged { |
| 185 | + t.Errorf("Privileged() = %t, want %t", got, tc.privileged) |
| 186 | + } |
| 187 | + got := tc.report.Privileges() |
| 188 | + if len(got) != len(tc.privs) { |
| 189 | + t.Fatalf("Privileges() = %v, want %v", got, tc.privs) |
| 190 | + } |
| 191 | + for i := range got { |
| 192 | + if got[i] != tc.privs[i] { |
| 193 | + t.Errorf("Privileges()[%d] = %q, want %q", i, got[i], tc.privs[i]) |
| 194 | + } |
| 195 | + } |
| 196 | + }) |
| 197 | + } |
| 198 | +} |
0 commit comments