Skip to content

Commit a505bcd

Browse files
radimclaude
andcommitted
test: cover session guards, the 25006 write refusal, and role preflight
Live tests (TEST_DATABASE_URL-gated) assert the compliance guarantees directly: all four session GUCs are set, a write through the default connection path fails with SQLSTATE 25006, InjectStats still writes through a guarded connection, and RoleReport matches pg_roles. Pure tests cover the privilege classification and the --allow-privileged policy. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent d60c568 commit a505bcd

2 files changed

Lines changed: 250 additions & 0 deletions

File tree

cmd/dryrun/preflight_test.go

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
package main
2+
3+
import (
4+
"strings"
5+
"testing"
6+
7+
"github.com/boringsql/dryrun/internal/schema"
8+
)
9+
10+
// Policy tests for the role-safety preflight on prod-reading commands: a
11+
// privileged role is refused fail-closed, --allow-privileged downgrades the
12+
// refusal to a warning (nil error), and a plain role passes silently.
13+
func TestRolePreflight(t *testing.T) {
14+
privileged := &schema.RoleReport{Rolname: "postgres", Super: true}
15+
plain := &schema.RoleReport{Rolname: "dryrun_readonly"}
16+
17+
t.Run("privileged role refused without --allow-privileged", func(t *testing.T) {
18+
err := rolePreflight(privileged, false)
19+
if err == nil {
20+
t.Fatal("expected refusal for superuser role, got nil")
21+
}
22+
for _, want := range []string{"postgres", "superuser", "--allow-privileged", "dryrun-readonly-role.sql"} {
23+
if !strings.Contains(err.Error(), want) {
24+
t.Errorf("refusal message missing %q: %v", want, err)
25+
}
26+
}
27+
})
28+
29+
t.Run("privileged role allowed with --allow-privileged", func(t *testing.T) {
30+
if err := rolePreflight(privileged, true); err != nil {
31+
t.Fatalf("expected warning-only pass with --allow-privileged, got %v", err)
32+
}
33+
})
34+
35+
t.Run("plain role passes", func(t *testing.T) {
36+
if err := rolePreflight(plain, false); err != nil {
37+
t.Fatalf("expected pass for unprivileged role, got %v", err)
38+
}
39+
})
40+
41+
t.Run("every privilege bit trips the refusal", func(t *testing.T) {
42+
reports := []*schema.RoleReport{
43+
{Rolname: "r", Replication: true},
44+
{Rolname: "b", BypassRLS: true},
45+
}
46+
for _, r := range reports {
47+
if err := rolePreflight(r, false); err == nil {
48+
t.Errorf("report %+v should be refused", r)
49+
}
50+
}
51+
})
52+
}

internal/schema/guards_test.go

Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
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

Comments
 (0)