pgsql-test-rls

Testing RLS Policies with pgsql-test

Safety Notice

This listing is imported from skills.sh public index metadata. Review upstream SKILL.md and repository scripts before running.

Copy this and send it to your AI assistant to learn

Install skill "pgsql-test-rls" with this command: npx skills add constructive-io/constructive-skills/constructive-io-constructive-skills-pgsql-test-rls

Testing RLS Policies with pgsql-test

Test Row-Level Security policies by simulating different users and roles. Verify your security policies work correctly with isolated, transactional tests.

When to Apply

Use this skill when:

  • Testing RLS policies for multi-tenant applications

  • Verifying user isolation (users only see their own data)

  • Testing role-based access (anonymous, authenticated, admin)

  • Validating INSERT/UPDATE/DELETE policies

Setup

Install pgsql-test:

pnpm add -D pgsql-test

Configure Jest/Vitest with the test database.

Core Concepts

Two Database Clients

pgsql-test provides two clients:

Client Purpose

pg

Superuser client for setup/teardown (bypasses RLS)

db

User client for testing with RLS enforcement

Test Isolation

Each test runs in a transaction with savepoints:

  • beforeEach() starts a savepoint

  • afterEach() rolls back to savepoint

  • Tests are completely isolated

Basic RLS Test Structure

import { getConnections, PgTestClient } from 'pgsql-test';

let pg: PgTestClient; let db: PgTestClient; let teardown: () => Promise<void>;

beforeAll(async () => { ({ pg, db, teardown } = await getConnections()); });

afterAll(async () => { await teardown(); });

beforeEach(async () => { await pg.beforeEach(); await db.beforeEach(); });

afterEach(async () => { await db.afterEach(); await pg.afterEach(); });

Setting User Context

Use setContext() to simulate different users:

// Simulate authenticated user db.setContext({ role: 'authenticated', 'request.jwt.claim.sub': userId });

// Simulate anonymous user db.setContext({ role: 'anonymous' });

// Simulate admin db.setContext({ role: 'administrator', 'request.jwt.claim.sub': adminId });

Testing SELECT Policies

Verify users only see their own data:

it('users only see their own records', async () => { // Setup: Insert data as superuser await pg.query( INSERT INTO app.posts (id, title, owner_id) VALUES ('post-1', 'User 1 Post', $1), ('post-2', 'User 2 Post', $2) , [user1Id, user2Id]);

// Test: User 1 queries db.setContext({ role: 'authenticated', 'request.jwt.claim.sub': user1Id });

const result = await db.query('SELECT * FROM app.posts');

expect(result.rows).toHaveLength(1); expect(result.rows[0].title).toBe('User 1 Post'); });

Testing INSERT Policies

Verify users can only insert their own data:

it('user can insert own record', async () => { db.setContext({ role: 'authenticated', 'request.jwt.claim.sub': userId });

const result = await db.one( INSERT INTO app.posts (title, owner_id) VALUES ('My Post', $1) RETURNING id, title, owner_id , [userId]);

expect(result.title).toBe('My Post'); expect(result.owner_id).toBe(userId); });

it('user cannot insert for another user', async () => { db.setContext({ role: 'authenticated', 'request.jwt.claim.sub': user1Id });

// Use savepoint pattern for expected failures const point = 'insert_other_user'; await db.savepoint(point);

await expect( db.query( INSERT INTO app.posts (title, owner_id) VALUES ('Hacked Post', $1) , [user2Id]) ).rejects.toThrow(/permission denied|violates row-level security/);

await db.rollback(point); });

Testing UPDATE Policies

it('user can update own record', async () => { // Setup await pg.query( INSERT INTO app.posts (id, title, owner_id) VALUES ('post-1', 'Original', $1) , [userId]);

// Test db.setContext({ role: 'authenticated', 'request.jwt.claim.sub': userId });

const result = await db.one( UPDATE app.posts SET title = 'Updated' WHERE id = 'post-1' RETURNING title );

expect(result.title).toBe('Updated'); });

it('user cannot update another user record', async () => { await pg.query( INSERT INTO app.posts (id, title, owner_id) VALUES ('post-1', 'Original', $1) , [user2Id]);

db.setContext({ role: 'authenticated', 'request.jwt.claim.sub': user1Id });

// Update returns no rows (RLS filters it out) const result = await db.query( UPDATE app.posts SET title = 'Hacked' WHERE id = 'post-1' RETURNING id );

expect(result.rows).toHaveLength(0); });

Testing DELETE Policies

it('user can delete own record', async () => { await pg.query( INSERT INTO app.posts (id, title, owner_id) VALUES ('post-1', 'To Delete', $1) , [userId]);

db.setContext({ role: 'authenticated', 'request.jwt.claim.sub': userId });

await db.query(DELETE FROM app.posts WHERE id = 'post-1');

// Verify as superuser const result = await pg.query( SELECT * FROM app.posts WHERE id = 'post-1' ); expect(result.rows).toHaveLength(0); });

Testing Anonymous Access

it('anonymous users have read-only access', async () => { await pg.query( INSERT INTO app.public_posts (id, title) VALUES ('post-1', 'Public Post') );

db.setContext({ role: 'anonymous' });

// Can read public data const result = await db.query('SELECT * FROM app.public_posts'); expect(result.rows).toHaveLength(1);

// Cannot modify const point = 'anon_insert'; await db.savepoint(point); await expect( db.query(INSERT INTO app.public_posts (title) VALUES ('Hacked')) ).rejects.toThrow(/permission denied/); await db.rollback(point); });

Multi-User Scenarios

Test interactions between multiple users:

describe('multi-user isolation', () => { const alice = '550e8400-e29b-41d4-a716-446655440001'; const bob = '550e8400-e29b-41d4-a716-446655440002';

beforeEach(async () => { // Seed data for both users await pg.query( INSERT INTO app.posts (title, owner_id) VALUES ('Alice Post 1', $1), ('Alice Post 2', $1), ('Bob Post 1', $2) , [alice, bob]); });

it('alice sees only her posts', async () => { db.setContext({ role: 'authenticated', 'request.jwt.claim.sub': alice });

const result = await db.query('SELECT title FROM app.posts ORDER BY title');
expect(result.rows).toHaveLength(2);
expect(result.rows.map(r => r.title)).toEqual(['Alice Post 1', 'Alice Post 2']);

});

it('bob sees only his posts', async () => { db.setContext({ role: 'authenticated', 'request.jwt.claim.sub': bob });

const result = await db.query('SELECT title FROM app.posts');
expect(result.rows).toHaveLength(1);
expect(result.rows[0].title).toBe('Bob Post 1');

}); });

Handling Expected Failures

When testing operations that should fail, use the savepoint pattern to avoid "current transaction is aborted" errors:

it('rejects unauthorized access', async () => { db.setContext({ role: 'anonymous' });

const point = 'unauthorized_access'; await db.savepoint(point);

await expect( db.query('INSERT INTO app.private_data (secret) VALUES ($1)', ['hack']) ).rejects.toThrow(/permission denied/);

await db.rollback(point);

// Can continue using db connection const result = await db.query('SELECT 1 as ok'); expect(result.rows[0].ok).toBe(1); });

Watch Mode

Run tests in watch mode for rapid feedback:

pnpm test:watch

References

  • Related skill: pgsql-test-exceptions for handling aborted transactions

  • Related skill: pgsql-test-seeding for seeding test data

  • Related skill: pgpm-testing for general test setup

Source Transparency

This detail page is rendered from real SKILL.md content. Trust labels are metadata-based hints, not a safety guarantee.

Related Skills

Related by shared tags or category signals.

General

planning-blueprinting

No summary provided by upstream source.

Repository SourceNeeds Review
General

drizzle-orm

No summary provided by upstream source.

Repository SourceNeeds Review
General

pgsql-parser-testing

No summary provided by upstream source.

Repository SourceNeeds Review