Previously: In How to Test Row-Level Security (RLS) in Postgres, we tested basic RLS policies with single users. Now let's test more complex scenarios with multiple users and malicious attempts.
Real-world security testing requires simulating multiple users attempting various operations—some legitimate, some malicious. You need to verify that users can't access or modify each other's data, even when actively trying to circumvent policies.
In this lesson, you'll test complex multi-user scenarios by switching between users within the same test. You'll also master an essential advanced technique: using savepoints to handle Postgres's aborted transaction behavior when testing operations that should fail.
Prerequisites
See Prerequisites. Requires: Complete How to Test Row-Level Security (RLS) in Postgres.
Table of Contents
- Testing Multi-User Data Isolation
- Testing Cross-User Modification Attempts
- Testing User Switching Within a Test
- Testing Batch Operations
- Advanced: Handling Exceptions and Aborted Transactions
Testing Multi-User Data Isolation
Let's test that multiple users can only see their own data.
Create __tests__/multi-user.test.ts:
Testing Cross-User Modification Attempts
Now test that users cannot modify each other's data:
Testing User Switching Within a Test
Test complex scenarios where users switch roles mid-test:
Testing Batch Operations
Test that batch operations respect RLS for each row:
Advanced: Handling Exceptions and Aborted Transactions
This is the most important advanced concept when testing Postgres with pgsql-test. Understanding Postgres's transaction abort behavior is critical for writing reliable tests.
The Problem: When testing security policies, you often need to verify that certain operations fail—users shouldn't be able to insert data for other users, access restricted tables, or violate constraints. But naively testing exceptions breaks your test flow.
Malicious Insert Attempts
Let's test that users can't insert data claiming to be owned by other users. Here's how you might initially write this test:
This test will fail! After the INSERT throws an error, any subsequent query on the same connection fails with:
Why does this happen? When Postgres encounters an error inside a transaction, it aborts the entire transaction. The connection remains in this aborted state, rejecting all further commands until you explicitly end the transaction.
This is fundamental Postgres behavior: once a transaction encounters an error, it's poisoned. You can't run more queries—you can only rollback or commit (which becomes a rollback for aborted transactions).
The Solution: Savepoints
To test exceptions and continue querying afterward, create a savepoint before the failing operation, then roll back to it:
The savepoint captures a safe restore point. When the INSERT fails, rolling back to the savepoint clears the error state, allowing subsequent queries to succeed.
This pattern is essential whenever you test operations that should fail. Without it, you can't verify the database state after a failed operation—and that verification is often the most important part of a security test.
Anonymous vs Authenticated Access
Now let's apply the savepoint pattern to test anonymous users. Anonymous users should have very limited access compared to authenticated users.
When testing operations that should throw errors, we need to use savepoints:
Key Takeaways
- Savepoint pattern - Essential for testing operations that should fail; prevents aborted transaction errors
- Postgres transaction behavior - Errors abort the entire transaction until explicitly rolled back
- Exception testing workflow - Create savepoint, test failure, rollback to savepoint, verify state
- Switch users within tests to simulate complex multi-user scenarios
- Test malicious attempts - Users trying to modify other users' data
- Verify batch operations respect RLS for each individual row
- Test rapid context switching to ensure isolation between users
- Anonymous vs authenticated - Different permission levels
- Use
pgfor setup only - All test assertions usedbwithsetContext() - RLS policies should silently filter rather than error for queries
What's Next
You've tested complex multi-user scenarios with RLS policies. In the next lesson, we'll explore seeding test databases with realistic data for more comprehensive testing scenarios.
