SqlProof generates hundreds of valid test datasets for your Postgres or Supabase app, runs your queries as different users, and finds the one that breaks it.
$ pytest tests/test_rls_projects.py AssertionError: user db8a6d46-… can read another user's row via project_summaries: {'id': '00000000-…-000000000001', 'user_id': 'b51c2c58-…'} Falsifying example: test_users_only_see_their_own_rows(…) Draw 1: {'projects': [{'id': '00000000-…-000000000001', 'user_id': 'b51c2c58-…', 'name': ''}]} FAILED test_users_only_see_their_own_rows 1 failed in 5.07s # user db8a6d46 reads a row owned by b51c2c58 through # the project_summaries view. The projects table was fine. # Shrunk to the smallest case: 1 project.
Catches all 10 bugs planted in a multi-tenant Supabase app: RLS leaks, RPCs, triggers and queries. See the example →
Coding agents write RLS policies, views, RPCs and migrations fast, and nobody reads them closely. The bugs that matter, like one user reading another user's rows or a report that quietly drops a row, don't show up in happy-path tests with one hand-made user. SqlProof generates hundreds of valid datasets, runs your queries as each test user, and finds the one that breaks your rules.
as_rls_user sets the JWT claims so auth.uid() resolves to the user,
and switches to the authenticated role so policies actually apply. This test
checks that every row a user can read, through a table and a view, is theirs.
from hypothesis import given, strategies as st from sqlproof.contrib.supabase import as_rls_user @given(data=st.data()) def test_users_only_see_their_own_rows(supabase_proof, data): dataset = data.draw(supabase_proof.dataset_strategy( sizes={"projects": st.integers(min_value=1, max_value=6)}, )) with supabase_proof.client_for_dataset(dataset) as db: users = db.query("SELECT id::text FROM auth.users") for user in users: for source in ("projects", "project_summaries"): # a table and a view with as_rls_user(db, user["id"]): rows = db.query(f"SELECT id::text, user_id::text FROM {source}") leaked = [r for r in rows if r["user_id"] != user["id"]] assert not leaked, ( f"user {user['id']} can read another user's row via {source}: {leaked[0]}" )
E AssertionError: user db8a6d46-6d97-42c0-8251-c65688ff061d can read another user's row via project_summaries: {'id': '00000000-0000-0000-0000-000000000001', 'user_id': 'b51c2c58-9898-4d69-a148-82525e2556dd'} E Falsifying example: test_users_only_see_their_own_rows( E supabase_proof=<sqlproof.core.SqlProof object at 0x7f6961573490>, E data=data(...), E ) E Draw 1: {'projects': [{'id': '00000000-0000-0000-0000-000000000001', E 'user_id': 'b51c2c58-9898-4d69-a148-82525e2556dd', E 'name': ''}]} FAILED tests/test_rls_projects.py::test_users_only_see_their_own_rows - Asser... 1 failed in 5.07s # After ALTER VIEW project_summaries SET (security_invoker = true): 1 passed
supabase_proof ships with the pytest plugin and seeds a test pool of
auth.users. Views must be created WITH (security_invoker = true)
or they bypass the table's RLS. More in Testing Supabase apps.
Instead of one fixture and one expected answer, you state a rule that must always hold. SqlProof generates valid datasets with Hypothesis and tries to break it.
Respects foreign keys, CHECK constraints, UNIQUE, NOT NULL, and enum types automatically. No invalid data, no constraint violations.
When a rule breaks, Hypothesis shrinks the dataset to the smallest one that still fails, like the single project above, so you fix the bug instead of hunting for it.
Point it at your local Supabase with one environment variable, or let testcontainers start a throwaway Postgres. Each test runs in a savepoint that rolls back.
Drop it into pytest. Decorate a test with @sqlproof(...) or call proof.check() directly. No new runner to learn.
Reads your SQL file or introspects a live Postgres DB to extract tables, types, FKs, and constraints.
Creates random valid rows for every table in FK-dependency order. Respects all constraints.
Inserts the rows inside a savepoint, runs your test, then rolls back. Repeats for each generated dataset.
On failure, shrinks to the smallest dataset that still fails and shows it to you.
from sqlproof import SqlProof, sqlproof proof = SqlProof.from_schema_file('./schema.sql') @sqlproof(proof, sizes={ 'customers': 10, 'orders': 50 }, runs=50) def test_order_totals_non_negative(db): rows = db.query('SELECT total FROM orders') assert all(row['total'] >= 0 for row in rows) # On failure, SqlProof saves the shrunk counterexample under .sqlproof/failures/. # Replay it with: sqlproof replay .sqlproof/failures/<test>.json