package store_test import ( "context" "sort" "testing" "github.com/jackc/pgx/v5/pgxpool" "github.com/stretchr/testify/require" ) // Isolation proof for ADR-033: an uploaded transcript is PRIVATE content owned by // one user, so it lives in its own RLS-scoped table — never in the shared, // non-RLS `transcripts` (ADR-021). These tests drive the database directly (raw // SQL as the non-superuser `app` role) so they observe row-level security itself, // not any application-layer WHERE clause. The superuser pool is used only to seed // and to read pg_catalog; it bypasses RLS and so must never be the pool whose // answers the assertions rest on. // seedUploadedTranscript inserts one finished upload for userID as the superuser // (RLS bypassed so both users' rows land regardless of any GUC) and returns its id. // The text is synthetic — this repo is public, and a real transcript must never // appear in it (#33). func seedUploadedTranscript(t *testing.T, super *pgxpool.Pool, userID, text string) string { t.Helper() ctx := context.Background() _, err := super.Exec(ctx, `INSERT INTO users (id) VALUES ($1) ON CONFLICT DO NOTHING`, userID) require.NoError(t, err) var id string require.NoError(t, super.QueryRow(ctx, `INSERT INTO uploaded_transcripts (user_id, title, status, content) VALUES ($1, 'synthetic fixture', 'done', $2) RETURNING id`, userID, text).Scan(&id)) return id } func TestUploadedTranscriptsRLS_ScopedReadsSeeOnlyOwnRows(t *testing.T) { newStore(t) super := rawPool(t) resetDB(t, super) seedUploadedTranscript(t, super, userA, "text owned by A") seedUploadedTranscript(t, super, userB, "text owned by B") app := appPool(t, super) require.Equal(t, 0, scopedCount(t, app, "", "uploaded_transcripts"), "no tapir.current_user_id must be deny-all: this proves RLS is live, not bypassed") require.Equal(t, 1, scopedCount(t, app, userA, "uploaded_transcripts"), "user A must see exactly its own upload") require.Equal(t, 1, scopedCount(t, app, userB, "uploaded_transcripts"), "user B must see exactly its own upload") // Count alone cannot distinguish "A's row" from "B's row" — read the content. var got string ctx := context.Background() tx, err := app.Begin(ctx) require.NoError(t, err) defer tx.Rollback(ctx) //nolint:errcheck _, err = tx.Exec(ctx, `SELECT set_config('tapir.current_user_id', $1, true)`, userA) require.NoError(t, err) require.NoError(t, tx.QueryRow(ctx, `SELECT content FROM uploaded_transcripts`).Scan(&got)) require.Equal(t, "text owned by A", got, "A's scoped read must return A's text, never B's") } func TestUploadedTranscriptsRLS_CrossUserWritesAffectZeroRows(t *testing.T) { newStore(t) super := rawPool(t) resetDB(t, super) seedUploadedTranscript(t, super, userA, "text owned by A") bID := seedUploadedTranscript(t, super, userB, "text owned by B") app := appPool(t, super) require.Equal(t, int64(0), scopedRowsAffected(t, app, userA, `UPDATE uploaded_transcripts SET content = 'hacked' WHERE id = $1`, bID), "user A scoped UPDATE must touch zero of B's uploads") require.Equal(t, int64(0), scopedRowsAffected(t, app, userA, `DELETE FROM uploaded_transcripts WHERE id = $1`, bID), "user A scoped DELETE must touch zero of B's uploads") var content string require.NoError(t, super.QueryRow(context.Background(), `SELECT content FROM uploaded_transcripts WHERE id = $1`, bID).Scan(&content)) require.Equal(t, "text owned by B", content, "B's upload must survive A's writes unchanged") } func TestUploadedTranscriptsRLS_InsertForAnotherUserIsRejected(t *testing.T) { newStore(t) super := rawPool(t) resetDB(t, super) seedUploadedTranscript(t, super, userA, "text owned by A") _, err := super.Exec(context.Background(), `INSERT INTO users (id) VALUES ($1)`, userB) require.NoError(t, err) app := appPool(t, super) ctx := context.Background() tx, err := app.Begin(ctx) require.NoError(t, err) defer tx.Rollback(ctx) //nolint:errcheck _, err = tx.Exec(ctx, `SELECT set_config('tapir.current_user_id', $1, true)`, userA) require.NoError(t, err) // Scoped as A, plant a row that claims to belong to B. The policy's WITH CHECK // (defaulting to USING under FOR ALL) must refuse it. _, err = tx.Exec(ctx, `INSERT INTO uploaded_transcripts (user_id, title, status, content) VALUES ($1, 'planted', 'done', 'planted')`, userB) require.Error(t, err, "a row scoped as A must not be insertable on behalf of B") require.Contains(t, err.Error(), "row-level security") } func TestUploadedTranscripts_RowSecurityIsEnabledForcedAndKeyedByUser(t *testing.T) { newStore(t) super := rawPool(t) ctx := context.Background() var enabled, forced bool require.NoError(t, super.QueryRow(ctx, `SELECT relrowsecurity, relforcerowsecurity FROM pg_class WHERE oid = 'public.uploaded_transcripts'::regclass`).Scan(&enabled, &forced)) require.True(t, enabled, "uploaded_transcripts must have row-level security enabled") require.True(t, forced, "FORCE is what binds the table-owner role tapir; without it the policy is dead in production") var userIDNotNull bool require.NoError(t, super.QueryRow(ctx, `SELECT is_nullable = 'NO' FROM information_schema.columns WHERE table_name = 'uploaded_transcripts' AND column_name = 'user_id'`).Scan(&userIDNotNull)) require.True(t, userIDNotNull, "an upload with no owner would match no policy and be unreachable") } func TestUploadedTranscripts_DeleteUserRemovesTheirUploads(t *testing.T) { s := newStore(t) super := rawPool(t) resetDB(t, super) seedUploadedTranscript(t, super, userA, "text owned by A") bID := seedUploadedTranscript(t, super, userB, "text owned by B") require.NoError(t, s.DeleteUser(context.Background(), userA)) var aRows, bRows int ctx := context.Background() require.NoError(t, super.QueryRow(ctx, `SELECT count(*) FROM uploaded_transcripts WHERE user_id = $1`, userA).Scan(&aRows)) require.NoError(t, super.QueryRow(ctx, `SELECT count(*) FROM uploaded_transcripts WHERE id = $1`, bID).Scan(&bRows)) require.Equal(t, 0, aRows, "deleting a user must remove their private transcripts (ON DELETE CASCADE)") require.Equal(t, 1, bRows, "deleting A must not touch B's transcripts") } // rowSecurityOffTables are the public-schema tables that deliberately have row // security OFF. The list is exact on purpose: a NEW table added without RLS must // fail this test and force a decision, instead of passing CI by omission — the // failure mode ADR-033 names, where a naive reuse "leaks private content across // tenants and passes CI green". // // - transcripts: shared public caption content, the single exception for CONTENT // (ADR-021). It is the one that must never gain a private row. // - user_identities: the Dex-subject -> user_id map, read at login before any // user is known (migration 004). Holds no user content. // - invitations: exists before the user does, so there is no user to scope by; // the single-use token is the capability (migration 009). // - schema_migrations: golang-migrate bookkeeping. // // #33 describes `transcripts` as "the only non-RLS table". That is true of content // and false of tables; this test asserts the real set. var rowSecurityOffTables = []string{ "invitations", "schema_migrations", "transcripts", "user_identities", } func TestRowSecurityOffTablesAreExactlyTheKnownSet(t *testing.T) { newStore(t) super := rawPool(t) rows, err := super.Query(context.Background(), `SELECT c.relname FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace WHERE n.nspname = 'public' AND c.relkind IN ('r', 'p') AND NOT c.relrowsecurity ORDER BY c.relname`) require.NoError(t, err) defer rows.Close() var got []string for rows.Next() { var name string require.NoError(t, rows.Scan(&name)) got = append(got, name) } require.NoError(t, rows.Err()) want := append([]string{}, rowSecurityOffTables...) sort.Strings(want) require.Equal(t, want, got, "every public table must have row security ON unless it is in rowSecurityOffTables; "+ "adding a table here is a decision that needs an ADR") }