Next.js Supabase V3 (#463)
Version 3 of the kit: - Radix UI replaced with Base UI (using the Shadcn UI patterns) - next-intl replaces react-i18next - enhanceAction deprecated; usage moved to next-safe-action - main layout now wrapped with [locale] path segment - Teams only mode - Layout updates - Zod v4 - Next.js 16.2 - Typescript 6 - All other dependencies updated - Removed deprecated Edge CSRF - Dynamic Github Action runner
This commit is contained in:
committed by
GitHub
parent
4912e402a3
commit
7ebff31475
961
docs/development/database-tests.mdoc
Normal file
961
docs/development/database-tests.mdoc
Normal file
@@ -0,0 +1,961 @@
|
||||
---
|
||||
status: "published"
|
||||
label: "Database tests"
|
||||
title: "Database Testing with pgTAP"
|
||||
description: "Learn how to write comprehensive database tests using pgTAP to secure your application against common vulnerabilities"
|
||||
order: 12
|
||||
---
|
||||
|
||||
Database testing is critical for ensuring your application's security and data integrity. This guide covers how to write comprehensive database tests using **pgTAP** and **Basejump utilities** to protect against common vulnerabilities.
|
||||
|
||||
## Why Database Testing Matters
|
||||
|
||||
Database tests verify that your **Row Level Security (RLS)** policies work correctly and protect against:
|
||||
|
||||
- **Unauthorized data access** - Users reading data they shouldn't see
|
||||
- **Data modification attacks** - Users updating/deleting records they don't own
|
||||
- **Privilege escalation** - Users gaining higher permissions than intended
|
||||
- **Cross-account data leaks** - Team members accessing other teams' data
|
||||
- **Storage security bypasses** - Unauthorized file access
|
||||
|
||||
## Test Infrastructure
|
||||
|
||||
### Required Extensions
|
||||
|
||||
Makerkit uses these extensions for database testing:
|
||||
|
||||
```sql
|
||||
-- Install Basejump test helpers
|
||||
create extension "basejump-supabase_test_helpers" version '0.0.6';
|
||||
|
||||
-- The extension provides authentication simulation
|
||||
-- and user management utilities
|
||||
```
|
||||
|
||||
### Makerkit Test Helpers
|
||||
|
||||
The `/apps/web/supabase/tests/database/00000-makerkit-helpers.sql` file provides essential utilities:
|
||||
|
||||
```sql
|
||||
-- Authenticate as a test user
|
||||
select makerkit.authenticate_as('user_identifier');
|
||||
|
||||
-- Get account by slug
|
||||
select makerkit.get_account_by_slug('team-slug');
|
||||
|
||||
-- Get account ID by slug
|
||||
select makerkit.get_account_id_by_slug('team-slug');
|
||||
|
||||
-- Set user as super admin
|
||||
select makerkit.set_super_admin();
|
||||
|
||||
-- Set MFA level
|
||||
select makerkit.set_session_aal('aal1'); -- or 'aal2'
|
||||
```
|
||||
|
||||
## Test Structure
|
||||
|
||||
Every pgTAP test follows this structure:
|
||||
|
||||
```sql
|
||||
begin;
|
||||
create extension "basejump-supabase_test_helpers" version '0.0.6';
|
||||
|
||||
select no_plan(); -- or select plan(N) for exact test count
|
||||
|
||||
-- Test setup
|
||||
select makerkit.set_identifier('user1', 'user1@example.com');
|
||||
select makerkit.set_identifier('user2', 'user2@example.com');
|
||||
|
||||
-- Your tests here
|
||||
select is(
|
||||
actual_result,
|
||||
expected_result,
|
||||
'Test description'
|
||||
);
|
||||
|
||||
select * from finish();
|
||||
rollback;
|
||||
```
|
||||
|
||||
## Bypassing RLS in Tests
|
||||
|
||||
When you need to set up test data or verify data exists independently of RLS policies, use role switching:
|
||||
|
||||
### Role Types for Testing
|
||||
|
||||
```sql
|
||||
-- postgres: Full superuser access, bypasses all RLS
|
||||
set local role postgres;
|
||||
|
||||
-- service_role: Service-level access, bypasses RLS
|
||||
set local role service_role;
|
||||
|
||||
-- authenticated: Normal user with RLS enforced (default for makerkit.authenticate_as)
|
||||
-- No need to set this explicitly - makerkit.authenticate_as() handles it
|
||||
```
|
||||
|
||||
### Common Patterns for Role Switching
|
||||
|
||||
#### Pattern 1: Setup Test Data
|
||||
```sql
|
||||
-- Use postgres role to insert test data that bypasses RLS
|
||||
set local role postgres;
|
||||
insert into accounts_memberships (account_id, user_id, account_role)
|
||||
values (team_id, user_id, 'member');
|
||||
|
||||
-- Test as normal user (RLS enforced)
|
||||
select makerkit.authenticate_as('member');
|
||||
select isnt_empty($$ select * from team_data $$, 'Member can see team data');
|
||||
```
|
||||
|
||||
#### Pattern 2: Verify Data Exists
|
||||
```sql
|
||||
-- Test that unauthorized user cannot see data
|
||||
select makerkit.authenticate_as('unauthorized_user');
|
||||
select is_empty($$ select * from private_data $$, 'Unauthorized user sees nothing');
|
||||
|
||||
-- Use postgres role to verify data actually exists
|
||||
set local role postgres;
|
||||
select isnt_empty($$ select * from private_data $$, 'Data exists (confirms RLS filtering)');
|
||||
```
|
||||
|
||||
#### Pattern 3: Grant Permissions for Testing
|
||||
```sql
|
||||
-- Use postgres role to grant permissions
|
||||
set local role postgres;
|
||||
insert into role_permissions (role, permission)
|
||||
values ('custom-role', 'invites.manage');
|
||||
|
||||
-- Test as user with the role
|
||||
select makerkit.authenticate_as('custom_role_user');
|
||||
select lives_ok($$ select create_invitation(...) $$, 'User with permission can invite');
|
||||
```
|
||||
|
||||
### When to Use Each Role
|
||||
|
||||
#### Use `postgres` role when:
|
||||
- Setting up complex test data with foreign key relationships
|
||||
- Inserting data that would normally be restricted by RLS
|
||||
- Verifying data exists independently of user permissions
|
||||
- Modifying system tables (roles, permissions, etc.)
|
||||
|
||||
#### Use `service_role` when:
|
||||
- You need RLS bypass but want to stay closer to application-level permissions
|
||||
- Testing service-level operations
|
||||
- Working with data that should be accessible to services but not users
|
||||
|
||||
#### Use `makerkit.authenticate_as()` when:
|
||||
- Testing normal user operations (automatically sets `authenticated` role)
|
||||
- Verifying RLS policies work correctly
|
||||
- Testing user-specific access patterns
|
||||
|
||||
### Complete Test Example
|
||||
|
||||
```sql
|
||||
begin;
|
||||
create extension "basejump-supabase_test_helpers" version '0.0.6';
|
||||
select no_plan();
|
||||
|
||||
-- Setup test users
|
||||
select makerkit.set_identifier('owner', 'owner@example.com');
|
||||
select makerkit.set_identifier('member', 'member@example.com');
|
||||
select makerkit.set_identifier('stranger', 'stranger@example.com');
|
||||
|
||||
-- Create team (as owner)
|
||||
select makerkit.authenticate_as('owner');
|
||||
select public.create_team_account('TestTeam');
|
||||
|
||||
-- Add member using postgres role (bypasses RLS)
|
||||
set local role postgres;
|
||||
insert into accounts_memberships (account_id, user_id, account_role)
|
||||
values (
|
||||
(select id from accounts where slug = 'testteam'),
|
||||
tests.get_supabase_uid('member'),
|
||||
'member'
|
||||
);
|
||||
|
||||
-- Test member access (RLS enforced)
|
||||
select makerkit.authenticate_as('member');
|
||||
select isnt_empty(
|
||||
$$ select * from accounts where slug = 'testteam' $$,
|
||||
'Member can see their team'
|
||||
);
|
||||
|
||||
-- Test stranger cannot see team (RLS enforced)
|
||||
select makerkit.authenticate_as('stranger');
|
||||
select is_empty(
|
||||
$$ select * from accounts where slug = 'testteam' $$,
|
||||
'Stranger cannot see team due to RLS'
|
||||
);
|
||||
|
||||
-- Verify team actually exists (bypass RLS)
|
||||
set local role postgres;
|
||||
select isnt_empty(
|
||||
$$ select * from accounts where slug = 'testteam' $$,
|
||||
'Team exists in database (confirms RLS is working, not missing data)'
|
||||
);
|
||||
|
||||
select * from finish();
|
||||
rollback;
|
||||
```
|
||||
|
||||
### Key Principles
|
||||
|
||||
1. **Use `postgres` role for test setup**, then switch back to test actual user permissions
|
||||
2. **Always verify data exists** using `postgres` role when testing that users cannot see data
|
||||
3. **Never test application logic as `postgres`** - it bypasses all security
|
||||
4. **Use role switching to confirm RLS is filtering**, not that data is missing
|
||||
|
||||
## Basic Security Testing Patterns
|
||||
|
||||
### 1. Testing Data Isolation
|
||||
|
||||
Verify users can only access their own data:
|
||||
|
||||
```sql
|
||||
-- Create test users
|
||||
select makerkit.set_identifier('owner', 'owner@example.com');
|
||||
select tests.create_supabase_user('stranger', 'stranger@example.com');
|
||||
|
||||
-- Owner creates a record
|
||||
select makerkit.authenticate_as('owner');
|
||||
insert into notes (title, content, user_id)
|
||||
values ('Secret Note', 'Private content', auth.uid());
|
||||
|
||||
-- Stranger cannot see the record
|
||||
select makerkit.authenticate_as('stranger');
|
||||
select is_empty(
|
||||
$$ select * from notes where title = 'Secret Note' $$,
|
||||
'Strangers cannot see other users notes'
|
||||
);
|
||||
```
|
||||
|
||||
### 2. Testing Write Protection
|
||||
|
||||
Ensure users cannot modify others' data:
|
||||
|
||||
```sql
|
||||
-- Owner creates a record
|
||||
select makerkit.authenticate_as('owner');
|
||||
insert into posts (title, user_id)
|
||||
values ('My Post', auth.uid()) returning id;
|
||||
|
||||
-- Store the post ID for testing
|
||||
\set post_id (select id from posts where title = 'My Post')
|
||||
|
||||
-- Stranger cannot update the record
|
||||
select makerkit.authenticate_as('stranger');
|
||||
select throws_ok(
|
||||
$$ update posts set title = 'Hacked!' where id = :post_id $$,
|
||||
'update or delete on table "posts" violates row-level security policy',
|
||||
'Strangers cannot update other users posts'
|
||||
);
|
||||
```
|
||||
|
||||
### 3. Testing Permission Systems
|
||||
|
||||
Verify role-based access control:
|
||||
|
||||
```sql
|
||||
-- Test that only users with 'posts.manage' permission can create posts
|
||||
select makerkit.authenticate_as('member');
|
||||
|
||||
select throws_ok(
|
||||
$$ insert into admin_posts (title, content) values ('Test', 'Content') $$,
|
||||
'new row violates row-level security policy',
|
||||
'Members without permission cannot create admin posts'
|
||||
);
|
||||
|
||||
-- Grant permission and test again
|
||||
set local role postgres;
|
||||
insert into user_permissions (user_id, permission)
|
||||
values (tests.get_supabase_uid('member'), 'posts.manage');
|
||||
|
||||
select makerkit.authenticate_as('member');
|
||||
select lives_ok(
|
||||
$$ insert into admin_posts (title, content) values ('Test', 'Content') $$,
|
||||
'Members with permission can create admin posts'
|
||||
);
|
||||
```
|
||||
|
||||
## Team Account Security Testing
|
||||
|
||||
### Testing Team Membership Access
|
||||
|
||||
```sql
|
||||
-- Setup team and members
|
||||
select makerkit.authenticate_as('owner');
|
||||
select public.create_team_account('TestTeam');
|
||||
|
||||
-- Add member to team
|
||||
set local role postgres;
|
||||
insert into accounts_memberships (account_id, user_id, account_role)
|
||||
values (
|
||||
makerkit.get_account_id_by_slug('testteam'),
|
||||
tests.get_supabase_uid('member'),
|
||||
'member'
|
||||
);
|
||||
|
||||
-- Test member can see team data
|
||||
select makerkit.authenticate_as('member');
|
||||
select isnt_empty(
|
||||
$$ select * from team_posts where account_id = makerkit.get_account_id_by_slug('testteam') $$,
|
||||
'Team members can see team posts'
|
||||
);
|
||||
|
||||
-- Test non-members cannot see team data
|
||||
select makerkit.authenticate_as('stranger');
|
||||
select is_empty(
|
||||
$$ select * from team_posts where account_id = makerkit.get_account_id_by_slug('testteam') $$,
|
||||
'Non-members cannot see team posts'
|
||||
);
|
||||
```
|
||||
|
||||
### Testing Role Hierarchy
|
||||
|
||||
```sql
|
||||
-- Test that members cannot promote themselves
|
||||
select makerkit.authenticate_as('member');
|
||||
|
||||
select throws_ok(
|
||||
$$ update accounts_memberships
|
||||
set account_role = 'owner'
|
||||
where user_id = auth.uid() $$,
|
||||
'Only the account_role can be updated',
|
||||
'Members cannot promote themselves to owner'
|
||||
);
|
||||
|
||||
-- Test that members cannot remove the owner
|
||||
select throws_ok(
|
||||
$$ delete from accounts_memberships
|
||||
where user_id = tests.get_supabase_uid('owner')
|
||||
and account_id = makerkit.get_account_id_by_slug('testteam') $$,
|
||||
'The primary account owner cannot be removed from the account membership list',
|
||||
'Members cannot remove the account owner'
|
||||
);
|
||||
```
|
||||
|
||||
## Storage Security Testing
|
||||
|
||||
```sql
|
||||
-- Test file access control
|
||||
select makerkit.authenticate_as('user1');
|
||||
|
||||
-- User can upload to their own folder
|
||||
select lives_ok(
|
||||
$$ insert into storage.objects (bucket_id, name, owner, owner_id)
|
||||
values ('avatars', auth.uid()::text, auth.uid(), auth.uid()) $$,
|
||||
'Users can upload files with their own UUID as filename'
|
||||
);
|
||||
|
||||
-- User cannot upload using another user's UUID as filename
|
||||
select makerkit.authenticate_as('user2');
|
||||
select throws_ok(
|
||||
$$ insert into storage.objects (bucket_id, name, owner, owner_id)
|
||||
values ('avatars', tests.get_supabase_uid('user1')::text, auth.uid(), auth.uid()) $$,
|
||||
'new row violates row-level security policy',
|
||||
'Users cannot upload files with other users UUIDs as filename'
|
||||
);
|
||||
```
|
||||
|
||||
## Common Testing Patterns
|
||||
|
||||
### 1. Cross-Account Data Isolation
|
||||
|
||||
```sql
|
||||
-- Verify team A members cannot access team B data
|
||||
select makerkit.authenticate_as('team_a_member');
|
||||
insert into documents (title, team_id)
|
||||
values ('Secret Doc', makerkit.get_account_id_by_slug('team-a'));
|
||||
|
||||
select makerkit.authenticate_as('team_b_member');
|
||||
select is_empty(
|
||||
$$ select * from documents where title = 'Secret Doc' $$,
|
||||
'Team B members cannot see Team A documents'
|
||||
);
|
||||
```
|
||||
|
||||
### 2. Function Security Testing
|
||||
|
||||
```sql
|
||||
-- Test that protected functions check permissions
|
||||
select makerkit.authenticate_as('regular_user');
|
||||
|
||||
select throws_ok(
|
||||
$$ select admin_delete_all_posts() $$,
|
||||
'permission denied for function admin_delete_all_posts',
|
||||
'Regular users cannot call admin functions'
|
||||
);
|
||||
|
||||
-- Test with proper permissions
|
||||
select makerkit.set_super_admin();
|
||||
select lives_ok(
|
||||
$$ select admin_delete_all_posts() $$,
|
||||
'Super admins can call admin functions'
|
||||
);
|
||||
```
|
||||
|
||||
### 3. Invitation Security Testing
|
||||
|
||||
```sql
|
||||
-- Test invitation creation permissions
|
||||
select makerkit.authenticate_as('member');
|
||||
|
||||
-- Members can invite to same or lower roles
|
||||
select lives_ok(
|
||||
$$ insert into invitations (email, account_id, role, invite_token)
|
||||
values ('new@example.com', makerkit.get_account_id_by_slug('team'), 'member', gen_random_uuid()) $$,
|
||||
'Members can invite other members'
|
||||
);
|
||||
|
||||
-- Members cannot invite to higher roles
|
||||
select throws_ok(
|
||||
$$ insert into invitations (email, account_id, role, invite_token)
|
||||
values ('admin@example.com', makerkit.get_account_id_by_slug('team'), 'owner', gen_random_uuid()) $$,
|
||||
'new row violates row-level security policy',
|
||||
'Members cannot invite owners'
|
||||
);
|
||||
```
|
||||
|
||||
## Advanced Testing Techniques
|
||||
|
||||
### 1. Testing Edge Cases
|
||||
|
||||
```sql
|
||||
-- Test NULL handling in RLS policies
|
||||
select lives_ok(
|
||||
$$ select * from posts where user_id IS NULL $$,
|
||||
'Queries with NULL filters should not crash'
|
||||
);
|
||||
|
||||
-- Test empty result sets
|
||||
select is_empty(
|
||||
$$ select * from posts where user_id = '00000000-0000-0000-0000-000000000000'::uuid $$,
|
||||
'Invalid UUIDs should return empty results'
|
||||
);
|
||||
```
|
||||
|
||||
### 2. Performance Testing
|
||||
|
||||
```sql
|
||||
-- Test that RLS policies don't create N+1 queries
|
||||
select makerkit.authenticate_as('team_owner');
|
||||
|
||||
-- This should be efficient even with many team members
|
||||
select isnt_empty(
|
||||
$$ select p.*, u.name from posts p join users u on p.user_id = u.id
|
||||
where p.team_id = makerkit.get_account_id_by_slug('large-team') $$,
|
||||
'Joined queries with RLS should perform well'
|
||||
);
|
||||
```
|
||||
|
||||
### 3. Testing Trigger Security
|
||||
|
||||
```sql
|
||||
-- Test that triggers properly validate permissions
|
||||
select makerkit.authenticate_as('regular_user');
|
||||
|
||||
select throws_ok(
|
||||
$$ update sensitive_settings set admin_only_field = 'hacked' $$,
|
||||
'You do not have permission to update this field',
|
||||
'Triggers should prevent unauthorized field updates'
|
||||
);
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Always Test Both Positive and Negative Cases
|
||||
- Verify authorized users CAN access data
|
||||
- Verify unauthorized users CANNOT access data
|
||||
|
||||
### 2. Test All CRUD Operations
|
||||
- CREATE: Can users insert the records they should?
|
||||
- READ: Can users only see their authorized data?
|
||||
- UPDATE: Can users only modify records they own?
|
||||
- DELETE: Can users only remove their own records?
|
||||
|
||||
### 3. Use Descriptive Test Names
|
||||
```sql
|
||||
select is(
|
||||
actual_result,
|
||||
expected_result,
|
||||
'Team members should be able to read team posts but not modify other teams data'
|
||||
);
|
||||
```
|
||||
|
||||
### 4. Test Permission Boundaries
|
||||
- Test the minimum permission level that grants access
|
||||
- Test that one level below is denied
|
||||
- Test that users with higher permissions can also access
|
||||
|
||||
### 5. Clean Up After Tests
|
||||
Always use transactions that rollback:
|
||||
```sql
|
||||
begin;
|
||||
-- Your tests here
|
||||
rollback; -- This cleans up all test data
|
||||
```
|
||||
|
||||
## Common Anti-Patterns to Avoid
|
||||
|
||||
❌ **Don't test only happy paths**
|
||||
```sql
|
||||
-- Bad: Only testing that authorized access works
|
||||
select isnt_empty($$ select * from posts $$, 'User can see posts');
|
||||
```
|
||||
|
||||
✅ **Test both authorized and unauthorized access**
|
||||
```sql
|
||||
-- Good: Test both positive and negative cases
|
||||
select makerkit.authenticate_as('owner');
|
||||
select isnt_empty($$ select * from posts where user_id = auth.uid() $$, 'Owner can see own posts');
|
||||
|
||||
select makerkit.authenticate_as('stranger');
|
||||
select is_empty($$ select * from posts where user_id != auth.uid() $$, 'Stranger cannot see others posts');
|
||||
```
|
||||
|
||||
❌ **Don't forget to test cross-account scenarios**
|
||||
```sql
|
||||
-- Bad: Only testing within same account
|
||||
select lives_ok($$ insert into team_docs (title) values ('Doc') $$, 'Can create doc');
|
||||
```
|
||||
|
||||
✅ **Test cross-account isolation**
|
||||
```sql
|
||||
-- Good: Test that team A cannot access team B data
|
||||
select makerkit.authenticate_as('team_a_member');
|
||||
insert into team_docs (title, team_id) values ('Secret', team_a_id);
|
||||
|
||||
select makerkit.authenticate_as('team_b_member');
|
||||
select is_empty($$ select * from team_docs where title = 'Secret' $$, 'Team B cannot see Team A docs');
|
||||
```
|
||||
|
||||
## Testing Silent RLS Failures
|
||||
|
||||
**Critical Understanding**: RLS policies often fail **silently**. They don't throw errors - they just filter out data or prevent operations. This makes testing RLS policies tricky because you need to verify what **didn't** happen, not just what did.
|
||||
|
||||
### Why RLS Failures Are Silent
|
||||
|
||||
```sql
|
||||
-- RLS policies work by:
|
||||
-- 1. INSERT/UPDATE: If the policy check fails, the operation is ignored (no error)
|
||||
-- 2. SELECT: If the policy fails, rows are filtered out (no error)
|
||||
-- 3. DELETE: If the policy fails, nothing is deleted (no error)
|
||||
```
|
||||
|
||||
### Testing Silent SELECT Filtering
|
||||
|
||||
When RLS policies prevent users from seeing data, queries return empty results instead of errors:
|
||||
|
||||
```sql
|
||||
-- Setup: Create posts for different users
|
||||
select makerkit.authenticate_as('user_a');
|
||||
insert into posts (title, content, user_id)
|
||||
values ('User A Post', 'Content A', auth.uid());
|
||||
|
||||
select makerkit.authenticate_as('user_b');
|
||||
insert into posts (title, content, user_id)
|
||||
values ('User B Post', 'Content B', auth.uid());
|
||||
|
||||
-- Test: User A cannot see User B's posts (silent filtering)
|
||||
select makerkit.authenticate_as('user_a');
|
||||
select is_empty(
|
||||
$$ select * from posts where title = 'User B Post' $$,
|
||||
'User A cannot see User B posts due to RLS filtering'
|
||||
);
|
||||
|
||||
-- Test: User A can still see their own posts
|
||||
select isnt_empty(
|
||||
$$ select * from posts where title = 'User A Post' $$,
|
||||
'User A can see their own posts'
|
||||
);
|
||||
|
||||
-- Critical: Verify the post actually exists by switching context
|
||||
select makerkit.authenticate_as('user_b');
|
||||
select isnt_empty(
|
||||
$$ select * from posts where title = 'User B Post' $$,
|
||||
'User B post actually exists (not a test data issue)'
|
||||
);
|
||||
```
|
||||
|
||||
### Testing Silent UPDATE/DELETE Prevention
|
||||
|
||||
RLS policies can silently prevent modifications without throwing errors:
|
||||
|
||||
```sql
|
||||
-- Setup: User A creates a post
|
||||
select makerkit.authenticate_as('user_a');
|
||||
insert into posts (title, content, user_id)
|
||||
values ('Original Title', 'Original Content', auth.uid())
|
||||
returning id;
|
||||
|
||||
-- Store the post ID for testing
|
||||
\set post_id (select id from posts where title = 'Original Title')
|
||||
|
||||
-- Test: User B attempts to modify User A's post (silently fails)
|
||||
select makerkit.authenticate_as('user_b');
|
||||
update posts set title = 'Hacked Title' where id = :post_id;
|
||||
|
||||
-- Verify the update was silently ignored
|
||||
select makerkit.authenticate_as('user_a');
|
||||
select is(
|
||||
(select title from posts where id = :post_id),
|
||||
'Original Title',
|
||||
'User B update attempt was silently ignored by RLS'
|
||||
);
|
||||
|
||||
-- Test: User B attempts to delete User A's post (silently fails)
|
||||
select makerkit.authenticate_as('user_b');
|
||||
delete from posts where id = :post_id;
|
||||
|
||||
-- Verify the delete was silently ignored
|
||||
select makerkit.authenticate_as('user_a');
|
||||
select isnt_empty(
|
||||
$$ select * from posts where title = 'Original Title' $$,
|
||||
'User B delete attempt was silently ignored by RLS'
|
||||
);
|
||||
```
|
||||
|
||||
### Testing Silent INSERT Prevention
|
||||
|
||||
INSERT operations can also fail silently with restrictive RLS policies:
|
||||
|
||||
```sql
|
||||
-- Test: Non-admin tries to insert into admin_settings table
|
||||
select makerkit.authenticate_as('regular_user');
|
||||
|
||||
-- Attempt to insert (may succeed but be silently filtered on read)
|
||||
insert into admin_settings (key, value) values ('test_key', 'test_value');
|
||||
|
||||
-- Critical: Don't just check for errors - verify the data isn't there
|
||||
select is_empty(
|
||||
$$ select * from admin_settings where key = 'test_key' $$,
|
||||
'Regular user cannot insert admin settings (silent prevention)'
|
||||
);
|
||||
|
||||
-- Verify an admin can actually insert this data
|
||||
set local role postgres;
|
||||
insert into admin_settings (key, value) values ('admin_key', 'admin_value');
|
||||
|
||||
select makerkit.set_super_admin();
|
||||
select isnt_empty(
|
||||
$$ select * from admin_settings where key = 'admin_key' $$,
|
||||
'Admins can insert admin settings (confirms table works)'
|
||||
);
|
||||
```
|
||||
|
||||
### Testing Row-Level Filtering with Counts
|
||||
|
||||
Use count comparisons to detect silent filtering:
|
||||
|
||||
```sql
|
||||
-- Setup: Create team data
|
||||
select makerkit.authenticate_as('team_owner');
|
||||
insert into team_documents (title, team_id) values
|
||||
('Doc 1', (select id from accounts where slug = 'team-a')),
|
||||
('Doc 2', (select id from accounts where slug = 'team-a')),
|
||||
('Doc 3', (select id from accounts where slug = 'team-a'));
|
||||
|
||||
-- Test: Team member sees all team docs
|
||||
select makerkit.authenticate_as('team_member_a');
|
||||
select is(
|
||||
(select count(*) from team_documents where team_id = (select id from accounts where slug = 'team-a')),
|
||||
3::bigint,
|
||||
'Team member can see all team documents'
|
||||
);
|
||||
|
||||
-- Test: Non-member sees no team docs (silent filtering)
|
||||
select makerkit.authenticate_as('external_user');
|
||||
select is(
|
||||
(select count(*) from team_documents where team_id = (select id from accounts where slug = 'team-a')),
|
||||
0::bigint,
|
||||
'External user cannot see any team documents due to RLS filtering'
|
||||
);
|
||||
```
|
||||
|
||||
### Testing Partial Data Exposure
|
||||
|
||||
Sometimes RLS policies expose some fields but not others:
|
||||
|
||||
```sql
|
||||
-- Test: Public can see user profiles but not sensitive data
|
||||
select tests.create_supabase_user('public_user', 'public@example.com');
|
||||
|
||||
-- Create user profile with sensitive data
|
||||
select makerkit.authenticate_as('profile_owner');
|
||||
insert into user_profiles (user_id, name, email, phone, ssn) values
|
||||
(auth.uid(), 'John Doe', 'john@example.com', '555-1234', '123-45-6789');
|
||||
|
||||
-- Test: Public can see basic info but not sensitive fields
|
||||
select makerkit.authenticate_as('public_user');
|
||||
|
||||
select is(
|
||||
(select name from user_profiles where user_id = tests.get_supabase_uid('profile_owner')),
|
||||
'John Doe',
|
||||
'Public can see user name'
|
||||
);
|
||||
|
||||
-- Critical: Test that sensitive fields are silently filtered
|
||||
select is(
|
||||
(select ssn from user_profiles where user_id = tests.get_supabase_uid('profile_owner')),
|
||||
null,
|
||||
'Public cannot see SSN (silently filtered by RLS)'
|
||||
);
|
||||
|
||||
select is(
|
||||
(select phone from user_profiles where user_id = tests.get_supabase_uid('profile_owner')),
|
||||
null,
|
||||
'Public cannot see phone number (silently filtered by RLS)'
|
||||
);
|
||||
```
|
||||
|
||||
### Testing Cross-Account Data Isolation
|
||||
|
||||
Verify users cannot access other accounts' data:
|
||||
|
||||
```sql
|
||||
-- Setup: Create data for multiple teams
|
||||
select makerkit.authenticate_as('team_a_owner');
|
||||
insert into billing_info (team_id, subscription_id) values
|
||||
((select id from accounts where slug = 'team-a'), 'sub_123');
|
||||
|
||||
select makerkit.authenticate_as('team_b_owner');
|
||||
insert into billing_info (team_id, subscription_id) values
|
||||
((select id from accounts where slug = 'team-b'), 'sub_456');
|
||||
|
||||
-- Test: Team A members cannot see Team B billing (silent filtering)
|
||||
select makerkit.authenticate_as('team_a_member');
|
||||
select is_empty(
|
||||
$$ select * from billing_info where subscription_id = 'sub_456' $$,
|
||||
'Team A members cannot see Team B billing info'
|
||||
);
|
||||
|
||||
-- Test: Team A members can see their own billing
|
||||
select isnt_empty(
|
||||
$$ select * from billing_info where subscription_id = 'sub_123' $$,
|
||||
'Team A members can see their own billing info'
|
||||
);
|
||||
|
||||
-- Verify both billing records actually exist
|
||||
set local role postgres;
|
||||
select is(
|
||||
(select count(*) from billing_info),
|
||||
2::bigint,
|
||||
'Both billing records exist in database (not a test data issue)'
|
||||
);
|
||||
```
|
||||
|
||||
### Testing Permission Boundary Edge Cases
|
||||
|
||||
Test the exact boundaries where permissions change:
|
||||
|
||||
```sql
|
||||
-- Setup users with different permission levels
|
||||
select makerkit.authenticate_as('admin_user');
|
||||
select makerkit.authenticate_as('editor_user');
|
||||
select makerkit.authenticate_as('viewer_user');
|
||||
|
||||
-- Test: Admins can see all data
|
||||
select makerkit.authenticate_as('admin_user');
|
||||
select isnt_empty(
|
||||
$$ select * from sensitive_documents $$,
|
||||
'Admins can see sensitive documents'
|
||||
);
|
||||
|
||||
-- Test: Editors cannot see sensitive docs (silent filtering)
|
||||
select makerkit.authenticate_as('editor_user');
|
||||
select is_empty(
|
||||
$$ select * from sensitive_documents $$,
|
||||
'Editors cannot see sensitive documents due to RLS'
|
||||
);
|
||||
|
||||
-- Test: Viewers cannot see sensitive docs (silent filtering)
|
||||
select makerkit.authenticate_as('viewer_user');
|
||||
select is_empty(
|
||||
$$ select * from sensitive_documents $$,
|
||||
'Viewers cannot see sensitive documents due to RLS'
|
||||
);
|
||||
```
|
||||
|
||||
### Testing Multi-Condition RLS Policies
|
||||
|
||||
When RLS policies have multiple conditions, test each condition:
|
||||
|
||||
```sql
|
||||
-- Policy example: Users can only see posts if they are:
|
||||
-- 1. The author, OR
|
||||
-- 2. A team member of the author's team, AND
|
||||
-- 3. The post is published
|
||||
|
||||
-- Test condition 1: Author can see unpublished posts
|
||||
select makerkit.authenticate_as('author');
|
||||
insert into posts (title, published, user_id) values
|
||||
('Draft Post', false, auth.uid());
|
||||
|
||||
select isnt_empty(
|
||||
$$ select * from posts where title = 'Draft Post' $$,
|
||||
'Authors can see their own unpublished posts'
|
||||
);
|
||||
|
||||
-- Test condition 2: Team members cannot see unpublished posts (silent filtering)
|
||||
select makerkit.authenticate_as('team_member');
|
||||
select is_empty(
|
||||
$$ select * from posts where title = 'Draft Post' $$,
|
||||
'Team members cannot see unpublished posts from teammates'
|
||||
);
|
||||
|
||||
-- Test condition 3: Team members can see published posts
|
||||
select makerkit.authenticate_as('author');
|
||||
update posts set published = true where title = 'Draft Post';
|
||||
|
||||
select makerkit.authenticate_as('team_member');
|
||||
select isnt_empty(
|
||||
$$ select * from posts where title = 'Draft Post' $$,
|
||||
'Team members can see published posts from teammates'
|
||||
);
|
||||
|
||||
-- Test condition boundary: Non-team members cannot see any posts
|
||||
select makerkit.authenticate_as('external_user');
|
||||
select is_empty(
|
||||
$$ select * from posts where title = 'Draft Post' $$,
|
||||
'External users cannot see any posts (even published ones)'
|
||||
);
|
||||
```
|
||||
|
||||
### Common Silent Failure Patterns to Test
|
||||
|
||||
#### 1. The "Empty Result" Pattern
|
||||
```sql
|
||||
-- Always test that restricted queries return empty results, not errors
|
||||
select is_empty(
|
||||
$$ select * from restricted_table where condition = true $$,
|
||||
'Unauthorized users see empty results, not errors'
|
||||
);
|
||||
```
|
||||
|
||||
#### 2. The "No-Effect" Pattern
|
||||
```sql
|
||||
-- Test that unauthorized modifications have no effect
|
||||
update restricted_table set field = 'hacked' where id = target_id;
|
||||
select is(
|
||||
(select field from restricted_table where id = target_id),
|
||||
'original_value',
|
||||
'Unauthorized updates are silently ignored'
|
||||
);
|
||||
```
|
||||
|
||||
#### 3. The "Partial Visibility" Pattern
|
||||
```sql
|
||||
-- Test that only authorized fields are visible
|
||||
select is(
|
||||
(select public_field from mixed_table where id = target_id),
|
||||
'visible_value',
|
||||
'Public fields are visible'
|
||||
);
|
||||
|
||||
select is(
|
||||
(select private_field from mixed_table where id = target_id),
|
||||
null,
|
||||
'Private fields are silently filtered out'
|
||||
);
|
||||
```
|
||||
|
||||
#### 4. The "Context Switch" Verification Pattern
|
||||
```sql
|
||||
-- Always verify data exists by switching to authorized context
|
||||
select makerkit.authenticate_as('unauthorized_user');
|
||||
select is_empty(
|
||||
$$ select * from protected_data $$,
|
||||
'Unauthorized user sees no data'
|
||||
);
|
||||
|
||||
-- Switch to authorized user to prove data exists
|
||||
select makerkit.authenticate_as('authorized_user');
|
||||
select isnt_empty(
|
||||
$$ select * from protected_data $$,
|
||||
'Data actually exists (confirms RLS filtering, not missing data)'
|
||||
);
|
||||
```
|
||||
|
||||
### Best Practices for Silent Failure Testing
|
||||
|
||||
#### ✅ Do: Test Both Positive and Negative Cases
|
||||
```sql
|
||||
-- Test that authorized users CAN access data
|
||||
select makerkit.authenticate_as('authorized_user');
|
||||
select isnt_empty($$ select * from protected_data $$, 'Authorized access works');
|
||||
|
||||
-- Test that unauthorized users CANNOT access data (silent filtering)
|
||||
select makerkit.authenticate_as('unauthorized_user');
|
||||
select is_empty($$ select * from protected_data $$, 'Unauthorized access silently filtered');
|
||||
```
|
||||
|
||||
#### ✅ Do: Verify Data Exists in Different Context
|
||||
```sql
|
||||
-- Don't just test that unauthorized users see nothing
|
||||
-- Verify the data actually exists by checking as an authorized user
|
||||
select makerkit.authenticate_as('data_owner');
|
||||
select isnt_empty($$ select * from my_data $$, 'Data exists');
|
||||
|
||||
select makerkit.authenticate_as('unauthorized_user');
|
||||
select is_empty($$ select * from my_data $$, 'But unauthorized user cannot see it');
|
||||
```
|
||||
|
||||
#### ✅ Do: Test Modification Boundaries
|
||||
```sql
|
||||
-- Test that unauthorized modifications are ignored
|
||||
update sensitive_table set value = 'hacked';
|
||||
select is(
|
||||
(select value from sensitive_table),
|
||||
'original_value',
|
||||
'Unauthorized updates silently ignored'
|
||||
);
|
||||
```
|
||||
|
||||
#### ❌ Don't: Expect Errors from RLS Violations
|
||||
```sql
|
||||
-- Bad: RLS violations usually don't throw errors
|
||||
select throws_ok(
|
||||
$$ select * from protected_data $$,
|
||||
'permission denied'
|
||||
);
|
||||
|
||||
-- Good: RLS violations return empty results
|
||||
select is_empty(
|
||||
$$ select * from protected_data $$,
|
||||
'Unauthorized users see no data due to RLS filtering'
|
||||
);
|
||||
```
|
||||
|
||||
#### ❌ Don't: Test Only Happy Paths
|
||||
```sql
|
||||
-- Bad: Only testing authorized access
|
||||
select isnt_empty($$ select * from my_data $$, 'I can see my data');
|
||||
|
||||
-- Good: Test both authorized and unauthorized access
|
||||
select makerkit.authenticate_as('owner');
|
||||
select isnt_empty($$ select * from my_data $$, 'Owner can see data');
|
||||
|
||||
select makerkit.authenticate_as('stranger');
|
||||
select is_empty($$ select * from my_data $$, 'Stranger cannot see data');
|
||||
```
|
||||
|
||||
Remember: **RLS is designed to be invisible to attackers**. Your tests must verify this invisibility by checking for empty results and unchanged data, not for error messages.
|
||||
|
||||
## Running Tests
|
||||
|
||||
To run your database tests:
|
||||
|
||||
```bash
|
||||
# Start Supabase locally
|
||||
pnpm supabase:web:start
|
||||
|
||||
# Run all database tests
|
||||
pnpm supabase:web:test
|
||||
|
||||
# Run specific test file
|
||||
pnpm supabase test ./tests/database/your-test.test.sql
|
||||
```
|
||||
|
||||
Your tests will help ensure your application is secure against common database vulnerabilities and that your RLS policies work as expected.
|
||||
Reference in New Issue
Block a user