Testing RBAC in SaaS Applications with Playwright and Jest

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 firstpermissionMatrix[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

Read more