mirror of
https://github.com/supabase/supabase.git
synced 2026-10-16 14:49:10 +03:00
chore: rework and extend database testing docs and exemples
This commit is contained in:
1 parent
d00bdef97f
commit
c9bf2a2f1a
4 files changed
+781
-527
No files matched your search
@@ -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',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
Reference in new issue
Block a user