diff --git a/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts b/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts index 3e66408ef1c..d4cfac71182 100644 --- a/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts +++ b/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts @@ -1922,10 +1922,14 @@ export const local_development: NavMenuConstant = { url: undefined, items: [ { name: 'Getting started', url: '/guides/local-development/testing/overview' }, - { name: 'Unit testing', url: '/guides/database/testing' }, { - name: 'Integration testing', - url: '/guides/local-development/managing-config', + name: 'pgTAP advanced guide', + url: '/guides/local-development/testing/pgtap-extended', + }, + { name: 'Database testing', url: '/guides/database/testing' }, + { + name: 'RLS policies testing', + url: '/guides/database/extensions/pgtap#testing-rls-policies', }, ], }, diff --git a/apps/docs/content/guides/database/testing/pg-regress.mdx b/apps/docs/content/guides/database/testing/pg-regress.mdx deleted file mode 100644 index 2116649b925..00000000000 --- a/apps/docs/content/guides/database/testing/pg-regress.mdx +++ /dev/null @@ -1,405 +0,0 @@ ---- -id: 'pg-regress' -title: 'Testing with pg_regress' -description: 'Use pg_regress for snapshot-based testing of your database, including RLS policies and query results.' ---- - -# Testing with pg_regress - -pg_regress is a powerful testing approach that uses snapshot testing, similar to Jest snapshots in JavaScript. It's particularly useful for testing database behavior, RLS policies, and catching unintended changes in query results. - -## Why pg_regress? - -1. **Snapshot Testing**: Easily catch unintended changes in database behavior -2. **SQL-Native**: Write tests in pure SQL without learning new testing frameworks -3. **Version Control Friendly**: Changes in behavior are clearly visible in git diffs -4. **Transaction-Based**: Tests run in isolation and don't affect the database state -5. **Concurrent Execution**: Tests can run in parallel since they're transaction-based - -## Getting Started - -1. Create a test directory structure: - -```bash -mkdir -p supabase/tests/regress/{sql,expected} -``` - -2. Create your first test file (`supabase/tests/regress/sql/schema_test.sql`): - -```sql --- Test database schema structure -begin; - --- Test table existence and structure -\d auth.users; -\d public.todos; - --- Test column existence and types -select - column_name, - data_type -from information_schema.columns -where table_schema = 'auth' - and table_name = 'users' -order by ordinal_position; - --- Test foreign key constraints -select - tc.table_schema, - tc.table_name, - kcu.column_name, - ccu.table_schema AS foreign_table_schema, - ccu.table_name AS foreign_table_name, - ccu.column_name AS foreign_column_name -from information_schema.table_constraints tc -join information_schema.key_column_usage kcu - on tc.constraint_name = kcu.constraint_name -join information_schema.constraint_column_usage ccu - on ccu.constraint_name = tc.constraint_name -where tc.constraint_type = 'FOREIGN KEY' - and tc.table_schema = 'public' - and tc.table_name = 'todos' -order by kcu.ordinal_position; - -rollback; -``` - -3. Run the test to generate expected output: - -```bash -pg_regress --inputdir=supabase/tests/regress/sql --outputdir=supabase/tests/regress/expected schema_test -``` - -## Example Tests - -### 1. Testing Database Structure - -File: `supabase/tests/regress/sql/database_structure.sql` - -```sql -begin; - --- Test extension availability -select extname, extversion -from pg_extension -where extname in ('pgtap', 'uuid-ossp', 'pg_jwt') -order by extname; - --- Test schema existence -select schema_name -from information_schema.schemata -where schema_name in ('public', 'auth', 'storage') -order by schema_name; - --- Test role existence -select rolname -from pg_roles -where rolname in ('anon', 'authenticated', 'service_role') -order by rolname; - -rollback; -``` - -### 2. Testing RLS Policies - -File: `supabase/tests/regress/sql/rls_policies.sql` - -```sql -begin; - --- Create test table -create table profiles ( - id uuid primary key default gen_random_uuid(), - name text, - user_id uuid references auth.users(id) -); - --- Enable RLS -alter table profiles enable row level security; - --- Create policies -create policy "Public profiles are viewable by everyone" - on profiles for select using (true); - -create policy "Users can insert their own profile" - on profiles for insert with check (auth.uid() = user_id); - -create policy "Users can update own profile" - on profiles for update using (auth.uid() = user_id); - --- List all policies -select schemaname, tablename, policyname, permissive, roles, cmd, qual -from pg_policies -where tablename = 'profiles' -order by policyname; - --- Test policy effects --- As anonymous user -set local role anon; -select current_user; -insert into profiles (name, user_id) values ('Test User', 'test-uuid'); -- Should fail -select count(*) from profiles; -- Should succeed (public read) - --- As authenticated user -set local role authenticated; -set local "request.jwt.claims" to '{"sub": "test-uuid"}'; -select current_user; -insert into profiles (name, user_id) values ('Test User', 'test-uuid'); -- Should succeed -insert into profiles (name, user_id) values ('Other User', 'other-uuid'); -- Should fail -update profiles set name = 'Updated Name' where user_id = 'test-uuid'; -- Should succeed -update profiles set name = 'Hacked Name' where user_id = 'other-uuid'; -- Should fail - -rollback; -``` - -### 3. Testing Functions and Triggers - -File: `supabase/tests/regress/sql/functions_triggers.sql` - -```sql -begin; - --- Create test table with trigger -create table audit_logs ( - id serial primary key, - table_name text, - operation text, - record_id uuid, - old_data jsonb, - new_data jsonb, - timestamp timestamptz default now() -); - --- Create audit function -create or replace function audit_changes() -returns trigger as $$ -begin - insert into audit_logs (table_name, operation, record_id, old_data, new_data) - values ( - TG_TABLE_NAME, - TG_OP, - coalesce(NEW.id, OLD.id), - case when TG_OP = 'DELETE' then row_to_json(OLD)::jsonb else null end, - case when TG_OP in ('INSERT', 'UPDATE') then row_to_json(NEW)::jsonb else null end - ); - return coalesce(NEW, OLD); -end; -$$ language plpgsql security definer; - --- Create test table -create table posts ( - id uuid primary key default gen_random_uuid(), - title text, - content text, - user_id uuid references auth.users(id) -); - --- Add trigger -create trigger posts_audit -after insert or update or delete on posts -for each row execute function audit_changes(); - --- Test function info -select - p.proname, - p.prorettype::regtype, - p.prosecdef, - p.provolatile, - p.pronargs -from pg_proc p -join pg_namespace n on p.pronamespace = n.oid -where n.nspname = 'public' - and p.proname = 'audit_changes'; - --- Test trigger info -select - t.tgname, - t.tgenabled, - t.tgtype, - p.proname as trigger_function -from pg_trigger t -join pg_class c on t.tgrelid = c.oid -join pg_proc p on t.tgfoid = p.oid -where c.relname = 'posts' -order by t.tgname; - --- Test trigger functionality -insert into posts (title, content, user_id) -values ('Test Post', 'Content', 'test-uuid'); - -update posts -set title = 'Updated Post' -where user_id = 'test-uuid'; - -delete from posts -where user_id = 'test-uuid'; - --- Check audit logs -select - table_name, - operation, - old_data->>'title' as old_title, - new_data->>'title' as new_title -from audit_logs -order by id; - -rollback; -``` - -### 4. Testing Complex RLS Scenarios - -File: `supabase/tests/regress/sql/complex_rls.sql` - -```sql -begin; - --- Create test tables -create table organizations ( - id uuid primary key default gen_random_uuid(), - name text not null -); - -create table org_members ( - org_id uuid references organizations(id), - user_id uuid references auth.users(id), - role text check (role in ('owner', 'admin', 'member')), - primary key (org_id, user_id) -); - -create table projects ( - id uuid primary key default gen_random_uuid(), - name text not null, - org_id uuid references organizations(id), - is_public boolean default false -); - --- Enable RLS -alter table organizations enable row level security; -alter table org_members enable row level security; -alter table projects enable row level security; - --- Create policies -create policy "Users can view organizations they're members of" - on organizations for select - using ( - exists ( - select 1 from org_members - where org_members.org_id = organizations.id - and org_members.user_id = auth.uid() - ) - ); - -create policy "Users can view public projects" - on projects for select - using (is_public); - -create policy "Users can view private projects in their organizations" - on projects for select - using ( - exists ( - select 1 from org_members - where org_members.org_id = projects.org_id - and org_members.user_id = auth.uid() - ) - ); - --- Insert test data -insert into organizations (id, name) values - ('org1-uuid', 'Org 1'), - ('org2-uuid', 'Org 2'); - -insert into org_members (org_id, user_id, role) values - ('org1-uuid', 'user1-uuid', 'admin'), - ('org1-uuid', 'user2-uuid', 'member'), - ('org2-uuid', 'user2-uuid', 'owner'); - -insert into projects (id, name, org_id, is_public) values - ('proj1-uuid', 'Public Project', 'org1-uuid', true), - ('proj2-uuid', 'Private Org 1 Project', 'org1-uuid', false), - ('proj3-uuid', 'Private Org 2 Project', 'org2-uuid', false); - --- Test as different users --- Test as User 1 (admin in Org 1) -set local role authenticated; -set local "request.jwt.claims" to '{"sub": "user1-uuid"}'; - -select current_user, current_setting('request.jwt.claims'); - --- Should see Org 1 but not Org 2 -select id, name from organizations order by name; - --- Should see all Org 1 projects and public projects -select id, name, is_public from projects order by name; - --- Test as User 2 (member in Org 1, owner in Org 2) -set local "request.jwt.claims" to '{"sub": "user2-uuid"}'; - -select current_user, current_setting('request.jwt.claims'); - --- Should see both organizations -select id, name from organizations order by name; - --- Should see all projects -select id, name, is_public from projects order by name; - --- Test as anonymous user -set local role anon; - -select current_user; - --- Should see no organizations -select id, name from organizations order by name; - --- Should only see public projects -select id, name, is_public from projects order by name; - -rollback; -``` - -### 5. Testing Storage Policies - -File: `supabase/tests/regress/sql/storage_policies.sql` - -```sql -begin; - --- Create test bucket -insert into storage.buckets (id, name) -values ('avatars', 'User Avatars'); - --- Create storage objects -insert into storage.objects (bucket_id, name, owner, metadata) -values - ('avatars', 'user1.jpg', 'user1-uuid', '{"size": 1024, "mimetype": "image/jpeg"}'), - ('avatars', 'user2.jpg', 'user2-uuid', '{"size": 2048, "mimetype": "image/jpeg"}'); - --- Test as anonymous user -set local role anon; -select current_user; - --- Should only see public bucket info -select id, name from storage.buckets; - --- Should see no objects -select bucket_id, name, owner from storage.objects; - --- Test as authenticated user -set local role authenticated; -set local "request.jwt.claims" to '{"sub": "user1-uuid"}'; - -select current_user, current_setting('request.jwt.claims'); - --- Should see all buckets -select id, name from storage.buckets; - --- Should only see own objects -select bucket_id, name, owner from storage.objects order by name; - -rollback; -``` - -## Additional Resources - -- [Structured Postgres Regression Tests](https://yrashk.com/blog/2023/04/23/structured-postgres-regression-tests/) -- [PostgreSQL pg_regress Documentation](https://www.postgresql.org/docs/current/regress.html) -- [Supabase Management API Tests](https://github.com/supabase/supabase/tree/master/apps/management-api/tests) - Real-world examples diff --git a/apps/docs/content/guides/local-development/testing/overview.mdx b/apps/docs/content/guides/local-development/testing/overview.mdx index 26d3410c1aa..40a1b9b90c9 100644 --- a/apps/docs/content/guides/local-development/testing/overview.mdx +++ b/apps/docs/content/guides/local-development/testing/overview.mdx @@ -6,7 +6,18 @@ description: 'Learn how develop and test your database schema, tables, functions Testing is a critical part of database development, especially when working with features like Row Level Security (RLS) policies. This guide provides a comprehensive approach to testing your Supabase database. -## Quick Start: Testing RLS in 15 Minutes +## Testing Approaches + +### 1. Database Unit Testing with pgTAP + +[pgTAP](https://pgtap.org) is a unit testing framework for PostgreSQL that allows you to test: + +- Database structure (tables, columns, constraints) +- Row Level Security (RLS) policies +- Functions and procedures +- Data integrity + +#### pgTAP: Testing RLS in 15 Minutes Let's walk through a complete example of setting up and testing RLS policies for a simple todo application: @@ -34,69 +45,74 @@ using (auth.uid() = user_id); 2. Set up your testing environment: ```bash -# Create test directory -mkdir -p ./supabase/tests/database - -# Enable pgTAP extension if not already enabled -supabase sql 'create extension if not exists pgtap with schema extensions;' - -# Create test file -touch ./supabase/tests/database/todos_rls.test.sql +# Create a new test for our policies using supabase cli +supabase test new todos_rls.test ``` 3. Write your RLS tests: ```sql begin; +-- install tests utilities +-- install pgtap extension for testing +create extension if not exists pgtap with schema extensions; +-- Start declare we'll have 6 test cases in our test suite select plan(6); +-- Setup our testing data +-- Set up auth.users entries +insert into auth.users (id, email) values + ('123e4567-e89b-12d3-a456-426614174000', 'user1@test.com'), + ('987fcdeb-51a2-43d7-9012-345678901234', 'user2@test.com'); + +-- Create test todos +insert into public.todos (task, user_id) values + ('User 1 Task 1', '123e4567-e89b-12d3-a456-426614174000'), + ('User 1 Task 2', '123e4567-e89b-12d3-a456-426614174000'), + ('User 2 Task 1', '987fcdeb-51a2-43d7-9012-345678901234'); + -- Test 1: Verify RLS is enabled -select has_row_level_security('public'::name, 'todos'::name); +select results_eq( + $$SELECT relrowsecurity FROM pg_class WHERE relname = 'todos' AND relnamespace = 'public'::regnamespace$$, + ARRAY[true], + 'Row Level Security should be enabled on todos table' +); -- Test 2: Verify policy exists select policies_are('public', 'todos', ARRAY['Users can only access their own todos']); --- Set up test data -insert into auth.users (id, email) values - ('d0c2eb55-5c60-4415-93f9-8743b3b0743e', 'user1@test.com'), - ('51f0518c-0d3b-4937-b98f-2a50b8aa1997', 'user2@test.com'); - -insert into public.todos (task, user_id) values - ('User 1 Task 1', 'd0c2eb55-5c60-4415-93f9-8743b3b0743e'), - ('User 1 Task 2', 'd0c2eb55-5c60-4415-93f9-8743b3b0743e'), - ('User 2 Task 1', '51f0518c-0d3b-4937-b98f-2a50b8aa1997'); - --- Test as User 1 +-- as User 1 set local role authenticated; -set local "request.jwt.claims" to '{"sub": "d0c2eb55-5c60-4415-93f9-8743b3b0743e"}'; +set local request.jwt.claim.sub = '123e4567-e89b-12d3-a456-426614174000'; -- Test 3: User 1 should only see their own todos select results_eq( - 'select count(*) from todos', - ARRAY[2::bigint], - 'User 1 should only see their 2 todos' + 'select count(*) from todos', + ARRAY[2::bigint], + 'User 1 should only see their 2 todos' ); -- Test 4: User 1 can create their own todo select lives_ok( - $$insert into todos (task, user_id) values ('New Task', 'd0c2eb55-5c60-4415-93f9-8743b3b0743e')$$, - 'User 1 can create their own todo' + $$insert into todos (task, user_id) values ('New Task', '123e4567-e89b-12d3-a456-426614174000'::uuid)$$, + 'User 1 can create their own todo' ); --- Test as User 2 -set local "request.jwt.claims" to '{"sub": "51f0518c-0d3b-4937-b98f-2a50b8aa1997"}'; +-- as User 2 +set local request.jwt.claim.sub = '987fcdeb-51a2-43d7-9012-345678901234'; -- Test 5: User 2 should only see their own todos select results_eq( - 'select count(*) from todos', - ARRAY[1::bigint], - 'User 2 should only see their 1 todo' + 'select count(*) from todos', + ARRAY[1::bigint], + 'User 2 should only see their 1 todo' ); -- Test 6: User 2 cannot modify User 1's todo -select throws_ok( - $$update todos set task = 'Hacked!' where user_id = 'd0c2eb55-5c60-4415-93f9-8743b3b0743e'$$, - 'User 2 cannot modify User 1 todos' +SELECT results_ne( + $$ update todos set task = 'Hacked!' where user_id = '123e4567-e89b-12d3-a456-426614174000'::uuid returning 1 $$, + $$ values(1) $$, + 'User 2 cannot modify User 1 todos' ); select * from finish(); @@ -107,112 +123,95 @@ rollback; ```bash supabase test db -``` - -## Testing Approaches - -### 1. Database Unit Testing with pgTAP - -[pgTAP](https://pgtap.org) is a unit testing framework for PostgreSQL that allows you to test: - -- Database structure (tables, columns, constraints) -- Row Level Security (RLS) policies -- Functions and procedures -- Data integrity - -#### Common pgTAP Testing Patterns - -1. Testing Database Structure: - -```sql -begin; -select plan(4); - --- Test table existence -select has_table('public', 'todos', 'Table todos should exist'); - --- Test column existence -select has_column('public', 'todos', 'task', 'Column task should exist'); -select has_column('public', 'todos', 'user_id', 'Column user_id should exist'); - --- Test foreign key constraint -select col_is_fk('public', 'todos', 'user_id', 'user_id should be a foreign key'); - -select * from finish(); -rollback; -``` - -2. Testing RLS Policies with Different Roles: - -```sql -begin; -select plan(3); - --- Test anonymous access -set local role anon; -select results_eq( - 'select count(*) from todos', - ARRAY[0::bigint], - 'Anonymous users should see no todos' -); - --- Test authenticated access with correct user -set local role authenticated; -set local "request.jwt.claims" to '{"sub": "user-id"}'; -select results_eq( - 'select count(*) from todos where user_id = ''user-id''', - ARRAY[1::bigint], - 'User should see their own todos' -); - --- Test authenticated access with wrong user -set local "request.jwt.claims" to '{"sub": "wrong-user"}'; -select results_eq( - 'select count(*) from todos where user_id = ''user-id''', - ARRAY[0::bigint], - 'User should not see other users todos' -); - -select * from finish(); -rollback; +psql:todos_rls.test.sql:4: NOTICE: extension "pgtap" already exists, skipping +./todos_rls.test.sql .. ok +All tests successful. +Files=1, Tests=6, 0 wallclock secs ( 0.01 usr + 0.00 sys = 0.01 CPU) +Result: PASS ``` ### 2. Application-Level Testing -Testing through your application code provides end-to-end verification. Here's an example using TypeScript and Jest: +Testing through your application code provides end-to-end verification. Here's an example using TypeScript and Jest that mirrors our pgTAP tests above: ```typescript import { createClient } from '@supabase/supabase-js' +import { beforeAll, describe, expect, it } from '@jest/globals' describe('Todos RLS', () => { const supabase = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_ANON_KEY!) - it('should enforce RLS policies', async () => { - // Sign in as user 1 - const { - data: { user: user1 }, - } = await supabase.auth.signInWithPassword({ + // Test users from our pgTAP example + const USER_1_ID = '123e4567-e89b-12d3-a456-426614174000' + const USER_2_ID = '987fcdeb-51a2-43d7-9012-345678901234' + + beforeAll(async () => { + // Setup test data + const adminSupabase = createClient(process.env.SUPABASE_URL!, process.env.SERVICE_ROLE_KEY!) + + // Create test users (using service role to bypass email verification) + await adminSupabase.auth.admin.createUser({ + email: 'user1@test.com', + password: 'password123', + user_metadata: { id: USER_1_ID }, + }) + await adminSupabase.auth.admin.createUser({ + email: 'user2@test.com', + password: 'password123', + user_metadata: { id: USER_2_ID }, + }) + + // Create initial todos + await adminSupabase.from('todos').insert([ + { task: 'User 1 Task 1', user_id: USER_1_ID }, + { task: 'User 1 Task 2', user_id: USER_1_ID }, + { task: 'User 2 Task 1', user_id: USER_2_ID }, + ]) + }) + + it('should allow User 1 to only see their own todos', async () => { + // Sign in as User 1 + await supabase.auth.signInWithPassword({ email: 'user1@test.com', password: 'password123', }) - // Create a todo as user 1 - const { data: todo } = await supabase - .from('todos') - .insert({ task: 'Test task', user_id: user1.id }) - .select() - .single() + const { data: todos } = await supabase.from('todos').select('*') + expect(todos).toHaveLength(2) + todos?.forEach((todo) => { + expect(todo.user_id).toBe(USER_1_ID) + }) + }) - // Sign in as user 2 + it('should allow User 1 to create their own todo', async () => { + // Still signed in as User 1 + const { error } = await supabase.from('todos').insert({ task: 'New Task', user_id: USER_1_ID }) + + expect(error).toBeNull() + }) + + it('should allow User 2 to only see their own todos', async () => { + // Sign in as User 2 await supabase.auth.signInWithPassword({ email: 'user2@test.com', password: 'password123', }) - // Try to access user 1's todo - const { data: user2Todos } = await supabase.from('todos').select().eq('id', todo.id) + const { data: todos } = await supabase.from('todos').select('*') + expect(todos).toHaveLength(1) + todos?.forEach((todo) => { + expect(todo.user_id).toBe(USER_2_ID) + }) + }) - expect(user2Todos).toHaveLength(0) // User 2 should not see User 1's todo + it('should prevent User 2 from modifying User 1 todos', async () => { + // Still signed in as User 2 + const { error } = await supabase + .from('todos') + .update({ task: 'Hacked!' }) + .eq('user_id', USER_1_ID) + + expect(error).not.toBeNull() }) }) ``` @@ -274,6 +273,7 @@ jobs: For more complex, real-world examples of database testing, check out: - [Basejump's Database Tests](https://github.com/usebasejump/basejump/tree/main/supabase/tests/database) - A production-grade example of testing RLS policies +- [RLS Guide and Best Practices](https://github.com/orgs/supabase/discussions/14576) ## Troubleshooting @@ -294,3 +294,5 @@ Common issues and solutions: - [pgTAP Documentation](https://pgtap.org) - [Supabase CLI Reference](https://supabase.com/docs/reference/cli/supabase-test) +- [pgTap supabase reference](https://supabase.com/docs/guides/database/extensions/pgtap?queryGroups=database-method&database-method=sql#testing-rls-policies) +- [Database testing reference](https://supabase.com/docs/guides/database/testing) diff --git a/apps/docs/content/guides/local-development/testing/pgtap-extended.mdx b/apps/docs/content/guides/local-development/testing/pgtap-extended.mdx new file mode 100644 index 00000000000..97748e5db50 --- /dev/null +++ b/apps/docs/content/guides/local-development/testing/pgtap-extended.mdx @@ -0,0 +1,653 @@ +--- +id: 'local-development-testing-pgtap-extended' +title: 'Advanced pgTAP Testing' +description: 'Learn how to leverage dbdev and test helpers for advanced database testing.' +--- + +While basic pgTAP provides excellent testing capabilities, you can enhance your testing workflow using database development tools and helper packages. This guide covers advanced testing techniques using dbdev and community-maintained test helpers. + +## Using Database.dev (dbdev) + +[Database.dev](https://database.dev) is a package manager for PostgreSQL that allows you to install and use community-maintained packages, including testing utilities. + +### Setting up dbdev + +To use dbdev and its packages, you'll need to install some prerequisites: + +```sql +create extension if not exists http with schema extensions; +create extension if not exists pg_tle; +drop extension if exists "supabase-dbdev"; +select pgtle.uninstall_extension_if_exists('supabase-dbdev'); +select + pgtle.install_extension( + 'supabase-dbdev', + resp.contents ->> 'version', + 'PostgreSQL package manager', + resp.contents ->> 'sql' + ) +from http( + ( + 'GET', + 'https://api.database.dev/rest/v1/' + || 'package_versions?select=sql,version' + || '&package_name=eq.supabase-dbdev' + || '&order=version.desc' + || '&limit=1', + array[ + ('apiKey', 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InhtdXB0cHBsZnZpaWZyYndtbXR2Iiwicm9sZSI6ImFub24iLCJpYXQiOjE2ODAxMDczNzIsImV4cCI6MTk5NTY4MzM3Mn0.z2CN0mvO2No8wSi46Gw59DFGCTJrzM0AQKsu_5k134s')::http_header + ], + null, + null + ) +) x, +lateral ( + select + ((row_to_json(x) -> 'content') #>> '{}')::json -> 0 +) resp(contents); +create extension "supabase-dbdev"; +select dbdev.install('supabase-dbdev'); +drop extension if exists "supabase-dbdev"; +create extension "supabase-dbdev"; +``` + +### Installing Test Helpers + +The Basejump Supabase Test Helpers package provides utilities that simplify testing Supabase-specific features: + +```sql +select dbdev.install('basejump-supabase_test_helpers'); +create extension if not exists "basejump-supabase_test_helpers" version '0.0.6'; +``` + +## Test Helper Benefits + +The test helpers package provides several advantages over writing raw pgTAP tests: + +1. **Simplified User Management** + + - Create test users with `tests.create_supabase_user()` + - Switch contexts with `tests.authenticate_as()` + - Retrieve user IDs using `tests.get_supabase_uid()` + +2. **RLS Testing Utilities** + + - Verify RLS status with `tests.rls_enabled()` + - Test policy enforcement + - Simulate different user contexts + +3. **Reduced Boilerplate** + - No need to manually insert auth.users + - Simplified JWT claim management + - Cleaner test setup and teardown + +## Schema-wide RLS Testing + +When working with Row Level Security, it's crucial to ensure that RLS is enabled on all tables that need it. You can create a simple test to verify RLS is enabled across an entire schema: + +```sql +begin; +select plan(1); + +-- Verify RLS is enabled on all tables in the public schema +select tests.rls_enabled('public'); + +select * from finish(); +rollback; +``` + +## Test File Organization + +When working with multiple test files that share common setup requirements, it's beneficial to create a single "pre-test" file that handles the global environment setup. This approach reduces duplication and ensures consistent test environments. + +### Creating a Pre-test Hook + +Since pgTAP test files are executed in alphabetical order, you can create a setup file that runs first by using a naming convention like `000-setup-tests-hooks.sql`: + +```bash +supabase test new 000-setup-tests-hooks +``` + +This setup file should contain: + +1. All shared extensions and dependencies +2. Common test utilities +3. A simple "no-op" test to verify the setup + +Here's an example setup file: + +```sql +-- install tests utilities +-- instal pgtap extension for testing +create extension if not exists pgtap with schema extensions; +/* +--------------------- +---- install dbdev ---- +---------------------- +Requires: + - pg_tle: https://github.com/aws/pg_tle + - pgsql-http: https://github.com/pramsey/pgsql-http +*/ +create extension if not exists http with schema extensions; +create extension if not exists pg_tle; +drop extension if exists "supabase-dbdev"; +select pgtle.uninstall_extension_if_exists('supabase-dbdev'); +select + pgtle.install_extension( + 'supabase-dbdev', + resp.contents ->> 'version', + 'PostgreSQL package manager', + resp.contents ->> 'sql' + ) +from http( + ( + 'GET', + 'https://api.database.dev/rest/v1/' + || 'package_versions?select=sql,version' + || '&package_name=eq.supabase-dbdev' + || '&order=version.desc' + || '&limit=1', + array[ + ('apiKey', 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InhtdXB0cHBsZnZpaWZyYndtbXR2Iiwicm9sZSI6ImFub24iLCJpYXQiOjE2ODAxMDczNzIsImV4cCI6MTk5NTY4MzM3Mn0.z2CN0mvO2No8wSi46Gw59DFGCTJrzM0AQKsu_5k134s')::http_header + ], + null, + null + ) +) x, +lateral ( + select + ((row_to_json(x) -> 'content') #>> '{}')::json -> 0 +) resp(contents); +create extension "supabase-dbdev"; +select dbdev.install('supabase-dbdev'); +drop extension if exists "supabase-dbdev"; +create extension "supabase-dbdev"; +-- Install test helpers +select dbdev.install('basejump-supabase_test_helpers'); +create extension if not exists "basejump-supabase_test_helpers" version '0.0.6'; + +-- Verify setup with a no-op test +begin; +select plan(1); +select ok(true, 'Pre-test hook completed successfully'); +select * from finish(); +rollback; +``` + +### Benefits + +This approach offers several advantages: + +- Reduces code duplication across test files +- Ensures consistent test environment setup +- Makes it easier to maintain and update shared dependencies +- Provides immediate feedback if the setup process fails + +Your subsequent test files (e.g., `001-auth-tests.sql`, `002-rls-tests.sql`) can focus solely on their specific test cases, knowing that the environment is properly configured. + +## Example: Advanced RLS Testing + +Here's a complete example using test helpers to verify RLS policies putting it all together: + +```sql +begin; +-- Assuming we already have our: 000-setup-tests-hooks.sql file we can use tests helpers +select plan(6); + +-- Set up test data + +-- Create our test supabase users +select tests.create_supabase_user('user1@test.com'); +select tests.create_supabase_user('user2@test.com'); + +-- Create our test data +insert into public.todos (task, user_id) values + ('User 1 Task 1', tests.get_supabase_uid('user1@test.com')), + ('User 1 Task 2', tests.get_supabase_uid('user1@test.com')), + ('User 2 Task 1', tests.get_supabase_uid('user2@test.com')); + +-- Test 1: Verify RLS is enabled +select tests.rls_enabled('public', 'todos'); + +-- Test 2: Verify policy exists +select policies_are('public', 'todos', ARRAY['Users can only access their own todos']); + +-- Test as User 1 +select tests.authenticate_as('user1@test.com'); + +-- Test 3: User 1 should only see their own todos +select results_eq( + 'select count(*) from todos', + ARRAY[2::bigint], + 'User 1 should only see their 2 todos' +); + +-- Test 4: User 1 can create their own todo +select lives_ok( + $$insert into todos (task, user_id) values ('New Task', tests.get_supabase_uid('user1@test.com'))$$, + 'User 1 can create their own todo' +); + +-- Test as User 2 +select tests.authenticate_as('user2@test.com'); + +-- Test 5: User 2 should only see their own todos +select results_eq( + 'select count(*) from todos', + ARRAY[1::bigint], + 'User 2 should only see their 1 todo' +); + +-- Test 6: User 2 cannot modify User 1's todo +SELECT results_ne( + $$ update todos set task = 'Hacked!' where user_id = tests.get_supabase_uid('user1@test.com') returning 1 $$, + $$ values(1) $$, + 'User 2 cannot modify User 1 todos' +); + +select * from finish(); +rollback; +``` + +## Not Another Todo App: Testing Complex Organizations + +While todo apps are great for learning, let's explore testing a more realistic scenario: a multi-tenant content publishing platform. This example demonstrates how to test complex permissions, plan restrictions, and content management. + +### System Overview + +Our platform implements: + +- Organizations with tiered plans (free/pro/enterprise) +- Role-based access (owner/admin/editor/viewer) +- Content management (posts/comments) +- Premium content restrictions +- Plan-based limitations + +### What Makes This Complex? + +1. **Layered Permissions** + + - Role hierarchies affect access rights + - Plan types influence user capabilities + - Content state (draft/published) affects permissions + +2. **Business Rules** + - Free plan post limits + - Premium content visibility + - Cross-organization security + +### Testing Focus Areas + +When writing tests, we'll verify: + +- Organization member access control +- Content visibility across roles +- Plan limitation enforcement +- Cross-organization data isolation + +#### 1. App schema definitions + +Or app schema tables will be defined like so: + +```sql +create table public.profiles ( + id uuid references auth.users(id) primary key, + username text unique not null, + full_name text, + bio text, + created_at timestamptz default now(), + updated_at timestamptz default now() +); + +create table public.organizations ( + id bigint primary key generated always as identity, + name text not null, + slug text unique not null, + plan_type text not null check (plan_type in ('free', 'pro', 'enterprise')), + max_posts int not null default 5, + created_at timestamptz default now() +); + +create table public.org_members ( + org_id bigint references public.organizations(id) on delete cascade, + user_id uuid references auth.users(id) on delete cascade, + role text not null check (role in ('owner', 'admin', 'editor', 'viewer')), + created_at timestamptz default now(), + primary key (org_id, user_id) +); + +create table public.posts ( + id bigint primary key generated always as identity, + title text not null, + content text not null, + author_id uuid references public.profiles(id) not null, + org_id bigint references public.organizations(id), + status text not null check (status in ('draft', 'published', 'archived')), + is_premium boolean default false, + scheduled_for timestamptz, + category text, + view_count int default 0, + published_at timestamptz, + created_at timestamptz default now(), + updated_at timestamptz default now() +); + +create table public.comments ( + id bigint primary key generated always as identity, + post_id bigint references public.posts(id) on delete cascade, + author_id uuid references public.profiles(id), + content text not null, + is_deleted boolean default false, + created_at timestamptz default now(), + updated_at timestamptz default now() +); +``` + +#### 2. RLS policies declaration + +Now we want to setup the RLS policies for each of our tables: + +```sql +-- Helper function for role checks +create or replace function public.get_user_org_role(org_id bigint, user_id uuid) +returns text as $$ + select role from org_members + where org_id = $1 and user_id = $2; +-- Note the use of security definer to avoid RLS checking recursion issue +-- see: https://supabase.com/docs/guides/database/postgres/row-level-security#use-security-definer-functions +$$ language sql security definer; + +-- Enable RLS for all tables +alter table public.profiles enable row level security; +alter table public.organizations enable row level security; +alter table public.org_members enable row level security; +alter table public.posts enable row level security; +alter table public.comments enable row level security; + +-- Profiles policies +create policy "Public profiles are viewable by everyone" + on public.profiles for select using (true); + +create policy "Users can insert their own profile" + on public.profiles for insert with check (auth.uid() = id); + +create policy "Users can update their own profile" + on public.profiles for update using (auth.uid() = id) + with check (auth.uid() = id); + +-- Organizations policies +create policy "Public org info visible to all" + on public.organizations for select using (true); + +create policy "Org management restricted to owners" + on public.organizations for all using ( + public.get_user_org_role(id, auth.uid()) = 'owner' + ); + +-- Org Members policies +create policy "Members visible to org members" + on public.org_members for select using ( + public.get_user_org_role(org_id, auth.uid()) is not null + ); + +create policy "Member management restricted to admins and owners" + on public.org_members for all using ( + public.get_user_org_role(org_id, auth.uid()) in ('owner', 'admin') + ); + + +-- Posts policies +create policy "Complex post visibility" + on public.posts for select using ( + -- Published non-premium posts are visible to all + (status = 'published' and not is_premium) + or + -- Premium posts visible to org members only + (status = 'published' and is_premium and + public.get_user_org_role(org_id, auth.uid()) is not null) + or + -- All posts visible to editors and above + public.get_user_org_role(org_id, auth.uid()) in ('owner', 'admin', 'editor') + ); + +create policy "Post creation rules" + on public.posts for insert with check ( + -- Must be org member with appropriate role + public.get_user_org_role(org_id, auth.uid()) in ('owner', 'admin', 'editor') + and + -- Check org post limits for free plans + ( + select plan_type = 'free' and + (select count(*) from posts p where p.org_id = posts.org_id) < max_posts + from organizations + where id = posts.org_id + ) + ); + +create policy "Post update rules" + on public.posts for update using ( + exists ( + select 1 + where + -- Editors can update non-published posts + (public.get_user_org_role(org_id, auth.uid()) = 'editor' and status != 'published') + or + -- Admins and owners can update any post + public.get_user_org_role(org_id, auth.uid()) in ('owner', 'admin') + ) + ); + +-- Comments policies +create policy "Comments on published posts are viewable by everyone" + on public.comments for select using ( + exists ( + select 1 from public.posts + where id = post_id + and status = 'published' + ) + and not is_deleted + ); + +create policy "Authenticated users can create comments" + on public.comments for insert with check (auth.uid() = author_id); + +create policy "Users can update their own comments" + on public.comments for update using (author_id = auth.uid()); +``` + +#### 3. Test cases: + +Now we have everything we need, let's write our RLS test cases, note that each section could be in it's own test: + +```sql +-- Assuming we already have our: 000-setup-tests-hooks.sql file we can use tests helpers +begin; +-- Declare total number of tests +select plan(10); + +-- Create our test users +select tests.create_supabase_user('org_owner', 'owner@test.com'); +select tests.create_supabase_user('org_admin', 'admin@test.com'); +select tests.create_supabase_user('org_editor', 'editor@test.com'); +select tests.create_supabase_user('premium_user', 'premium@test.com'); +select tests.create_supabase_user('free_user', 'free@test.com'); +select tests.create_supabase_user('scheduler', 'scheduler@test.com'); +select tests.create_supabase_user('free_author', 'free_author@test.com'); + +-- Create profiles for test users +insert into profiles (id, username, full_name) +values + (tests.get_supabase_uid('org_owner'), 'org_owner', 'Organization Owner'), + (tests.get_supabase_uid('org_admin'), 'org_admin', 'Organization Admin'), + (tests.get_supabase_uid('org_editor'), 'org_editor', 'Organization Editor'), + (tests.get_supabase_uid('premium_user'), 'premium_user', 'Premium User'), + (tests.get_supabase_uid('free_user'), 'free_user', 'Free User'), + (tests.get_supabase_uid('scheduler'), 'scheduler', 'Scheduler User'), + (tests.get_supabase_uid('free_author'), 'free_author', 'Free Author'); + +-- First authenticate as service role to bypass RLS for initial setup +select tests.authenticate_as_service_role(); + +-- Create test organizations and setup data +with new_org as ( + insert into organizations (name, slug, plan_type, max_posts) + values + ('Test Org', 'test-org', 'pro', 100), + ('Premium Org', 'premium-org', 'enterprise', 1000), + ('Schedule Org', 'schedule-org', 'pro', 100), + ('Free Org', 'free-org', 'free', 2) + returning id, slug +), +-- Setup members and posts +member_setup as ( + insert into org_members (org_id, user_id, role) + select + org.id, + user_id, + role + from new_org org cross join ( + values + (tests.get_supabase_uid('org_owner'), 'owner'), + (tests.get_supabase_uid('org_admin'), 'admin'), + (tests.get_supabase_uid('org_editor'), 'editor'), + (tests.get_supabase_uid('premium_user'), 'viewer'), + (tests.get_supabase_uid('scheduler'), 'editor'), + (tests.get_supabase_uid('free_author'), 'editor') + ) as members(user_id, role) + where org.slug = 'test-org' + or (org.slug = 'premium-org' and role = 'viewer') + or (org.slug = 'schedule-org' and role = 'editor') + or (org.slug = 'free-org' and role = 'editor') +) +-- Setup initial posts +insert into posts (title, content, org_id, author_id, status, is_premium, scheduled_for) +select + title, + content, + org.id, + author_id, + status, + is_premium, + scheduled_for +from new_org org cross join ( + values + ('Premium Post', 'Premium content', tests.get_supabase_uid('premium_user'), 'published', true, null), + ('Free Post', 'Free content', tests.get_supabase_uid('premium_user'), 'published', false, null), + ('Future Post', 'Future content', tests.get_supabase_uid('scheduler'), 'published', false, '2024-01-02 12:00:00+00'::timestamptz) +) as posts(title, content, author_id, status, is_premium, scheduled_for) +where org.slug in ('premium-org', 'schedule-org'); + +-- Test owner privileges +select tests.authenticate_as('org_owner'); +select lives_ok( + $$ + update organizations + set name = 'Updated Org' + where id = (select id from organizations limit 1) + $$, + 'Owner can update organization' +); + +-- Test admin privileges +select tests.authenticate_as('org_admin'); +select results_eq( + $$select count(*) from org_members$$, + ARRAY[6::bigint], + 'Admin can view all members' +); + +-- Test editor restrictions +select tests.authenticate_as('org_editor'); +select throws_ok( + $$ + insert into org_members (org_id, user_id, role) + values ( + (select id from organizations limit 1), + (select tests.get_supabase_uid('org_editor')), + 'viewer' + ) + $$, + '42501', + 'new row violates row-level security policy for table "org_members"', + 'Editor cannot manage members' +); + +-- Premium Content Access Tests +select tests.authenticate_as('premium_user'); +select results_eq( + $$select count(*) from posts where org_id = (select id from organizations where slug = 'premium-org')$$, + ARRAY[3::bigint], + 'Premium user can see all posts' +); + +select tests.clear_authentication(); +select results_eq( + $$select count(*) from posts where org_id = (select id from organizations where slug = 'premium-org')$$, + ARRAY[2::bigint], + 'Anonymous users can only see free posts' +); + +-- Time-Based Publishing Tests +select tests.authenticate_as('scheduler'); +select tests.freeze_time('2024-01-01 12:00:00+00'::timestamptz); + +select results_eq( + $$select count(*) from posts where scheduled_for > now() and org_id = (select id from organizations where slug = 'schedule-org')$$, + ARRAY[1::bigint], + 'Can see scheduled posts' +); + +select tests.freeze_time('2024-01-02 13:00:00+00'::timestamptz); + +select results_eq( + $$select count(*) from posts where scheduled_for < now() and org_id = (select id from organizations where slug = 'schedule-org')$$, + ARRAY[1::bigint], + 'Can see posts after schedule time' +); + +select tests.unfreeze_time(); + +-- Plan Limit Tests +select tests.authenticate_as('free_author'); + +select lives_ok( + $$ + insert into posts (title, content, org_id, author_id, status) + select 'Post 1', 'Content 1', id, auth.uid(), 'draft' + from organizations where slug = 'free-org' limit 1 + $$, + 'First post creates successfully' +); + +select lives_ok( + $$ + insert into posts (title, content, org_id, author_id, status) + select 'Post 2', 'Content 2', id, auth.uid(), 'draft' + from organizations where slug = 'free-org' limit 1 + $$, + 'Second post creates successfully' +); + +select throws_ok( + $$ + insert into posts (title, content, org_id, author_id, status) + select 'Post 3', 'Content 3', id, auth.uid(), 'draft' + from organizations where slug = 'free-org' limit 1 + $$, + '42501', + 'new row violates row-level security policy for table "posts"', + 'Cannot exceed free plan post limit' +); + +select * from finish(); +rollback; +``` + +## Additional Resources + +- [Basejump Test Helpers Documentation](https://database.dev/basejump/supabase_test_helpers) +- [Basejump Test Helpers Reference](https://github.com/usebasejump/supabase-test-helpers) +- [Basejump RLS writting blog post](https://usebasejump.com/blog/testing-on-supabase-with-pgtap) +- [Database.dev Package Registry](https://database.dev) +- [pgTLE Documentation](https://github.com/aws/pg_tle) +- [Supabase RLS Performances and Best Practices](https://github.com/orgs/supabase/discussions/14576)