diff --git a/internal/adapters/store/uploaded_transcripts_isolation_test.go b/internal/adapters/store/uploaded_transcripts_isolation_test.go new file mode 100644 index 0000000..d8bd6f0 --- /dev/null +++ b/internal/adapters/store/uploaded_transcripts_isolation_test.go @@ -0,0 +1,196 @@ +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") +}