feat: upload audio, get a private Swedish transcript, discuss it (#33, ADR-033) #39
@@ -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")
|
||||
}
|
||||
Reference in New Issue
Block a user