Testing RBAC in SaaS Applications with Playwright and Jest
Role-based access control (RBAC) bugs are subtle and expensive. A viewer who can delete records, an admin whose permissions don't cascade to subresources, or a billing role that can access user PII — these bugs are invisible until someone exploits them. This guide shows how to build a systematic RBAC test suite using Playwright for E2E tests and Jest for API-level tests.
Defining the Permission Matrix
Before writing tests, document your permission matrix explicitly. This becomes the spec your tests verify:
// tests/rbac/permissionMatrix.ts
export type Role = 'owner' | 'admin' | 'member' | 'viewer' | 'billing'
export type Action = 'read' | 'create' | 'update' | 'delete' | 'invite' | 'billing'
export type Resource = 'projects' | 'members' | 'settings' | 'billing' | 'api-keys'
export const permissionMatrix: Record<Resource, Record<Action, Role[]>> = {
projects: {
read: ['owner', 'admin', 'member', 'viewer'],
create: ['owner', 'admin', 'member'],
update: ['owner', 'admin', 'member'],
delete: ['owner', 'admin'],
invite: [],
billing: [],
},
members: {
read: ['owner', 'admin'],
create: [],
update: ['owner', 'admin'],
delete: ['owner', 'admin'],
invite: ['owner', 'admin'],
billing: [],
},
settings: {
read: ['owner', 'admin'],
create: [],
update: ['owner', 'admin'],
delete: ['owner'],
invite: [],
billing: [],
},
billing: {
read: ['owner', 'billing'],
create: [],
update: ['owner', 'billing'],
delete: [],
invite: [],
billing: ['owner', 'billing'],
},
'api-keys': {
read: ['owner', 'admin'],
create: ['owner', 'admin'],
update: ['owner', 'admin'],
delete: ['owner', 'admin'],
invite: [],
billing: [],
},
}
export function canDo(role: Role, resource: Resource, action: Action): boolean {
return permissionMatrix[resource][action].includes(role)
}API-Level RBAC Tests with Jest
Generate tests automatically from the permission matrix:
// tests/rbac/api.rbac.test.ts
import supertest from 'supertest'
import app from '../../src/app'
import { permissionMatrix, canDo, Role, Resource, Action } from './permissionMatrix'
import { createTestTenant, createTestUser, getAuthToken } from '../factories'
const allRoles: Role[] = ['owner', 'admin', 'member', 'viewer', 'billing']
const endpointMap: Record<Resource, Record<string, { method: string; path: string; body?: any }>> = {
projects: {
read: { method: 'GET', path: '/api/projects' },
create: { method: 'POST', path: '/api/projects', body: { name: 'Test Project' } },
update: { method: 'PATCH', path: '/api/projects/{{projectId}}', body: { name: 'Updated' } },
delete: { method: 'DELETE', path: '/api/projects/{{projectId}}' },
},
members: {
read: { method: 'GET', path: '/api/members' },
invite: { method: 'POST', path: '/api/members/invite', body: { email: 'new@example.com', role: 'viewer' } },
delete: { method: 'DELETE', path: '/api/members/{{memberId}}' },
},
billing: {
read: { method: 'GET', path: '/api/billing' },
update: { method: 'POST', path: '/api/billing/update-plan', body: { planId: 'pro' } },
},
}
describe('RBAC: API permission matrix', () => {
let tenant: any
let tokens: Record<Role, string>
let projectId: string
let memberId: string
beforeAll(async () => {
tenant = await createTestTenant()
tokens = {} as Record<Role, string>
for (const role of allRoles) {
const user = await createTestUser({ tenantId: tenant.id, role })
tokens[role] = await getAuthToken(user)
}
const project = await createTestProject({ tenantId: tenant.id })
projectId = project.id
const member = await createTestUser({ tenantId: tenant.id, role: 'viewer' })
memberId = member.id
})
for (const [resource, actions] of Object.entries(endpointMap)) {
for (const [action, endpoint] of Object.entries(actions)) {
for (const role of allRoles) {
const shouldAllow = canDo(role as Role, resource as Resource, action as Action)
const testName = `${role} ${shouldAllow ? 'CAN' : 'CANNOT'} ${action} ${resource}`
it(testName, async () => {
const path = endpoint.path
.replace('{{projectId}}', projectId)
.replace('{{memberId}}', memberId)
const req = (supertest(app) as any)[endpoint.method.toLowerCase()](path)
.set('Authorization', `Bearer ${tokens[role as Role]}`)
if (endpoint.body) req.send(endpoint.body)
const response = await req
if (shouldAllow) {
expect([200, 201, 204]).toContain(response.status)
} else {
expect([403, 404]).toContain(response.status)
}
})
}
}
}
})UI RBAC Tests with Playwright
Test that the UI shows/hides controls based on role:
// tests/e2e/rbac.spec.ts
import { test, expect, Page } from '@playwright/test'
async function loginAs(page: Page, role: string) {
const credentials: Record<string, { email: string; password: string }> = {
owner: { email: 'owner@example.com', password: process.env.OWNER_PASSWORD! },
viewer: { email: 'viewer@example.com', password: process.env.VIEWER_PASSWORD! },
billing: { email: 'billing@example.com', password: process.env.BILLING_PASSWORD! },
}
const creds = credentials[role]
await page.goto('/login')
await page.fill('[name=email]', creds.email)
await page.fill('[name=password]', creds.password)
await page.click('button[type=submit]')
await page.waitForURL('**/dashboard')
}
test.describe('RBAC: UI element visibility', () => {
test('owner sees all admin controls', async ({ page }) => {
await loginAs(page, 'owner')
await page.goto('/settings')
await expect(page.locator('[data-testid=danger-zone]')).toBeVisible()
await expect(page.locator('[data-testid=delete-workspace]')).toBeVisible()
await expect(page.locator('[data-testid=transfer-ownership]')).toBeVisible()
})
test('viewer does not see create or delete buttons', async ({ page }) => {
await loginAs(page, 'viewer')
await page.goto('/projects')
await expect(page.locator('[data-testid=create-project]')).not.toBeVisible()
await expect(page.locator('[data-testid=delete-project]')).not.toBeVisible()
})
test('viewer cannot navigate to settings', async ({ page }) => {
await loginAs(page, 'viewer')
await page.goto('/settings')
// Should redirect or show access denied
await expect(page).toHaveURL(/\/(dashboard|access-denied)/)
})
test('billing role sees billing page but not member management', async ({ page }) => {
await loginAs(page, 'billing')
await page.goto('/billing')
await expect(page.locator('[data-testid=billing-overview]')).toBeVisible()
await page.goto('/members')
await expect(page).toHaveURL(/\/(dashboard|access-denied)/)
})
})Testing Permission Boundaries at the Edge
Permissions often fail at boundaries — the last admin trying to remove themselves, or downgrading the only owner:
describe('Permission boundary edge cases', () => {
it('prevents the last owner from being removed', async () => {
const response = await supertest(app)
.delete(`/api/members/${ownerUser.id}`)
.set('Authorization', `Bearer ${adminToken}`)
expect(response.status).toBe(400)
expect(response.body.error).toMatch(/last owner/i)
})
it('prevents an owner from downgrading their own role', async () => {
const response = await supertest(app)
.patch(`/api/members/${ownerUser.id}`)
.set('Authorization', `Bearer ${ownerToken}`)
.send({ role: 'member' })
expect(response.status).toBe(400)
expect(response.body.error).toMatch(/cannot downgrade/i)
})
it('prevents role escalation via direct API call', async () => {
// A member trying to make themselves an admin
const response = await supertest(app)
.patch(`/api/members/${memberUser.id}`)
.set('Authorization', `Bearer ${memberToken}`)
.send({ role: 'admin' })
expect([403, 404]).toContain(response.status)
})
it('enforces inherited permissions on nested resources', async () => {
// A viewer on the project cannot create items within it
const response = await supertest(app)
.post(`/api/projects/${projectId}/items`)
.set('Authorization', `Bearer ${viewerToken}`)
.send({ name: 'New Item' })
expect([403, 404]).toContain(response.status)
})
})Testing Role Assignment
describe('Role assignment', () => {
it('owner can assign any role to a member', async () => {
const member = await createTestUser({ tenantId: tenant.id, role: 'viewer' })
const response = await supertest(app)
.patch(`/api/members/${member.id}`)
.set('Authorization', `Bearer ${ownerToken}`)
.send({ role: 'admin' })
expect(response.status).toBe(200)
expect(response.body.role).toBe('admin')
})
it('admin cannot assign owner role', async () => {
const member = await createTestUser({ tenantId: tenant.id, role: 'viewer' })
const response = await supertest(app)
.patch(`/api/members/${member.id}`)
.set('Authorization', `Bearer ${adminToken}`)
.send({ role: 'owner' })
expect(response.status).toBe(403)
})
})Testing JWT Claims
Verify that role information in JWTs matches what the database says:
import jwt from 'jsonwebtoken'
describe('JWT role claims', () => {
it('includes correct role in access token', async () => {
const loginResponse = await supertest(app)
.post('/api/auth/login')
.send({ email: 'viewer@example.com', password: process.env.VIEWER_PASSWORD })
const { accessToken } = loginResponse.body
const decoded = jwt.decode(accessToken) as any
expect(decoded.role).toBe('viewer')
expect(decoded.tenantId).toBe(tenant.id)
})
it('newly assigned role is reflected in fresh token', async () => {
// Change role
await supertest(app)
.patch(`/api/members/${viewerUser.id}`)
.set('Authorization', `Bearer ${ownerToken}`)
.send({ role: 'admin' })
// Re-login to get a fresh token
const loginResponse = await supertest(app)
.post('/api/auth/login')
.send({ email: 'viewer@example.com', password: process.env.VIEWER_PASSWORD })
const decoded = jwt.decode(loginResponse.body.accessToken) as any
expect(decoded.role).toBe('admin')
})
})What Automated Tests Miss
Permission matrix tests cover logic but won't catch:
- Stale JWT tokens granting access after a role downgrade
- API caching returning forbidden data from a previous authorized request
- Race conditions in concurrent role changes
- Third-party integrations that bypass your RBAC middleware
HelpMeTest runs scheduled tests across multiple user personas — owner, member, viewer — verifying that the right controls appear and disappear as expected in a real browser. Start with 10 free tests.
Summary
Systematic RBAC testing:
- Define the matrix first —
permissionMatrix[resource][action] = allowedRoles[] - Generate API tests from the matrix — every role × resource × action combination
- Test the UI — visibility of controls and navigation access per role
- Test edge cases — last owner, role escalation, nested resource inheritance
- Test JWT claims — freshness after role changes