RBAC Testing with Automated Test Suites
Role-Based Access Control (RBAC) is the authorization layer that ensures users can only access what they're permitted to. Manual testing of RBAC is insufficient — authorization bugs are subtle, easy to miss, and have severe consequences. Automated RBAC tests verify every permission boundary systematically.
This guide covers implementing automated RBAC tests: unit testing permission logic, integration testing authorization middleware, and E2E testing access controls from the user's perspective.
What RBAC Tests Must Cover
Positive tests (access granted): Users with the required role CAN perform the action. Don't only test negative cases.
Negative tests (access denied): Users without the required role CANNOT perform the action. These must include:
- No role at all (anonymous users)
- A lower-privilege role (user trying to access admin-only endpoint)
- A different role (editor trying to perform admin actions)
Role escalation: A user cannot grant themselves higher privileges than they currently have.
Resource ownership: Some actions are allowed for the resource owner regardless of role (e.g., editing your own profile).
Boundary conditions: Edge cases in role hierarchies, combined permissions, and temporal access.
Defining Your Permission Model
Start with a clear permission model that tests can reference:
// src/auth/permissions.ts
export const ROLES = {
ANONYMOUS: 'anonymous',
USER: 'user',
EDITOR: 'editor',
ADMIN: 'admin',
SUPER_ADMIN: 'super_admin',
} as const;
export type Role = typeof ROLES[keyof typeof ROLES];
export const PERMISSIONS = {
// Content
'content:read': [ROLES.ANONYMOUS, ROLES.USER, ROLES.EDITOR, ROLES.ADMIN, ROLES.SUPER_ADMIN],
'content:create': [ROLES.EDITOR, ROLES.ADMIN, ROLES.SUPER_ADMIN],
'content:update': [ROLES.EDITOR, ROLES.ADMIN, ROLES.SUPER_ADMIN],
'content:delete': [ROLES.ADMIN, ROLES.SUPER_ADMIN],
// Users
'users:list': [ROLES.ADMIN, ROLES.SUPER_ADMIN],
'users:create': [ROLES.ADMIN, ROLES.SUPER_ADMIN],
'users:delete': [ROLES.SUPER_ADMIN],
'users:update-role': [ROLES.SUPER_ADMIN],
// Billing
'billing:view': [ROLES.ADMIN, ROLES.SUPER_ADMIN],
'billing:modify': [ROLES.SUPER_ADMIN],
} as const;
export type Permission = keyof typeof PERMISSIONS;
export function hasPermission(role: Role, permission: Permission): boolean {
return (PERMISSIONS[permission] as readonly string[]).includes(role);
}Unit Testing Permission Logic
// tests/auth/permissions.test.ts
import { describe, it, expect } from 'vitest';
import { hasPermission, ROLES, PERMISSIONS } from '~/auth/permissions';
describe('hasPermission', () => {
describe('content:read', () => {
it.each(Object.values(ROLES))('allows all roles including %s', (role) => {
expect(hasPermission(role, 'content:read')).toBe(true);
});
});
describe('content:delete', () => {
it('allows admin', () => {
expect(hasPermission(ROLES.ADMIN, 'content:delete')).toBe(true);
});
it('allows super_admin', () => {
expect(hasPermission(ROLES.SUPER_ADMIN, 'content:delete')).toBe(true);
});
it.each([ROLES.ANONYMOUS, ROLES.USER, ROLES.EDITOR])('denies %s', (role) => {
expect(hasPermission(role, 'content:delete')).toBe(false);
});
});
describe('users:update-role', () => {
it('only allows super_admin', () => {
const roles = Object.values(ROLES);
const allowedRoles = roles.filter((role) => hasPermission(role, 'users:update-role'));
expect(allowedRoles).toEqual([ROLES.SUPER_ADMIN]);
});
});
// Completeness: every permission has at least one allowed role
describe('permission completeness', () => {
it('every permission allows at least one role', () => {
const allRoles = Object.values(ROLES);
const permissions = Object.keys(PERMISSIONS) as (keyof typeof PERMISSIONS)[];
for (const permission of permissions) {
const allowedRoles = allRoles.filter((role) => hasPermission(role, permission));
expect(allowedRoles.length).toBeGreaterThan(0);
}
});
});
});Integration Testing Authorization Middleware
// src/middleware/requirePermission.ts
import { Request, Response, NextFunction } from 'express';
import { hasPermission, type Permission } from '../auth/permissions';
export function requirePermission(permission: Permission) {
return (req: Request, res: Response, next: NextFunction) => {
const userRole = req.user?.role;
if (!userRole) {
return res.status(401).json({ error: 'Authentication required' });
}
if (!hasPermission(userRole, permission)) {
return res.status(403).json({
error: 'Forbidden',
required: permission,
userRole,
});
}
next();
};
}// tests/middleware/requirePermission.test.ts
import { describe, it, expect } from 'vitest';
import request from 'supertest';
import express from 'express';
import { requirePermission } from '~/middleware/requirePermission';
import { ROLES } from '~/auth/permissions';
function createTestApp(permission: string, userRole?: string) {
const app = express();
// Simulate auth middleware
app.use((req, res, next) => {
if (userRole) {
req.user = { id: 'test-user', role: userRole };
}
next();
});
app.get('/test', requirePermission(permission as any), (req, res) => {
res.json({ access: 'granted' });
});
return app;
}
describe('requirePermission middleware', () => {
describe('content:delete permission', () => {
it('returns 200 for admin role', async () => {
const app = createTestApp('content:delete', ROLES.ADMIN);
const res = await request(app).get('/test');
expect(res.status).toBe(200);
});
it('returns 403 for user role', async () => {
const app = createTestApp('content:delete', ROLES.USER);
const res = await request(app).get('/test');
expect(res.status).toBe(403);
expect(res.body.error).toBe('Forbidden');
expect(res.body.required).toBe('content:delete');
});
it('returns 401 when not authenticated', async () => {
const app = createTestApp('content:delete', undefined);
const res = await request(app).get('/test');
expect(res.status).toBe(401);
});
});
// Matrix test: all roles against all permissions
describe('permission matrix', () => {
const testCases = [
{ permission: 'content:create', role: ROLES.EDITOR, expected: 200 },
{ permission: 'content:create', role: ROLES.USER, expected: 403 },
{ permission: 'users:list', role: ROLES.ADMIN, expected: 200 },
{ permission: 'users:list', role: ROLES.EDITOR, expected: 403 },
{ permission: 'users:delete', role: ROLES.ADMIN, expected: 403 },
{ permission: 'users:delete', role: ROLES.SUPER_ADMIN, expected: 200 },
{ permission: 'billing:modify', role: ROLES.ADMIN, expected: 403 },
{ permission: 'billing:modify', role: ROLES.SUPER_ADMIN, expected: 200 },
];
it.each(testCases)(
'$role attempting $permission should get $expected',
async ({ permission, role, expected }) => {
const app = createTestApp(permission, role);
const res = await request(app).get('/test');
expect(res.status).toBe(expected);
}
);
});
});Testing Role Escalation Prevention
// tests/api/role-escalation.test.ts
import request from 'supertest';
import app from '~/app';
describe('Role escalation prevention', () => {
let userToken: string;
let adminToken: string;
beforeAll(async () => {
userToken = await getAuthToken({ role: ROLES.USER });
adminToken = await getAuthToken({ role: ROLES.ADMIN });
});
it('user cannot assign admin role to themselves', async () => {
const res = await request(app)
.patch('/api/users/me/role')
.set('Authorization', `Bearer ${userToken}`)
.send({ role: ROLES.ADMIN });
expect(res.status).toBe(403);
});
it('admin cannot assign super_admin role', async () => {
const res = await request(app)
.patch('/api/users/target-user/role')
.set('Authorization', `Bearer ${adminToken}`)
.send({ role: ROLES.SUPER_ADMIN });
expect(res.status).toBe(403);
});
it('user cannot access admin endpoints by role manipulation', async () => {
// Attempt to send a modified JWT with a spoofed role
const modifiedToken = userToken.split('.').map((part, i) => {
if (i === 1) {
const payload = JSON.parse(Buffer.from(part, 'base64').toString());
payload.role = ROLES.ADMIN;
return Buffer.from(JSON.stringify(payload)).toString('base64');
}
return part;
}).join('.');
const res = await request(app)
.get('/api/admin/users')
.set('Authorization', `Bearer ${modifiedToken}`);
// JWT signature invalid — should be rejected
expect(res.status).toBe(401);
});
});E2E Testing with Playwright
Test RBAC from the user's perspective in the browser:
// e2e/rbac.spec.ts
import { test, expect } from '@playwright/test';
// Define auth states for each role
const authStates = {
anonymous: null,
user: '.auth/user.json',
editor: '.auth/editor.json',
admin: '.auth/admin.json',
};
// Setup: save auth state for each role
const setup = test.extend({});
setup('setup user auth', async ({ page }) => {
await page.goto('/login');
await page.fill('[name="email"]', 'user@test.com');
await page.fill('[name="password"]', 'testpassword');
await page.click('[type="submit"]');
await page.context().storageState({ path: '.auth/user.json' });
});
// RBAC tests for admin panel
test.describe('Admin panel access', () => {
test('admin can access admin panel', async ({ browser }) => {
const context = await browser.newContext({ storageState: '.auth/admin.json' });
const page = await context.newPage();
await page.goto('/admin');
await expect(page).toHaveURL('/admin');
await expect(page.locator('h1')).toContainText('Admin');
await context.close();
});
test('user is redirected from admin panel', async ({ browser }) => {
const context = await browser.newContext({ storageState: '.auth/user.json' });
const page = await context.newPage();
await page.goto('/admin');
// Should redirect to 403 or home
await expect(page).not.toHaveURL('/admin');
await context.close();
});
test('anonymous user redirected to login', async ({ browser }) => {
const context = await browser.newContext(); // No auth state
const page = await context.newPage();
await page.goto('/admin');
await expect(page).toHaveURL('/login');
await context.close();
});
});
// UI elements visible to correct roles
test.describe('Role-based UI elements', () => {
test('admin sees delete buttons, user does not', async ({ browser }) => {
// Admin view
const adminContext = await browser.newContext({ storageState: '.auth/admin.json' });
const adminPage = await adminContext.newPage();
await adminPage.goto('/content');
await expect(adminPage.locator('[data-testid="delete-btn"]')).toBeVisible();
// User view
const userContext = await browser.newContext({ storageState: '.auth/user.json' });
const userPage = await userContext.newPage();
await userPage.goto('/content');
await expect(userPage.locator('[data-testid="delete-btn"]')).not.toBeVisible();
await adminContext.close();
await userContext.close();
});
});Testing RBAC API Matrix Systematically
Generate systematic matrix tests from your permission model:
// tests/rbac-matrix.test.ts
import { ROLES, PERMISSIONS, hasPermission } from '~/auth/permissions';
// Map permissions to API endpoints
const API_ENDPOINTS: Record<string, { method: string; path: string }> = {
'content:read': { method: 'GET', path: '/api/content' },
'content:create': { method: 'POST', path: '/api/content' },
'content:delete': { method: 'DELETE', path: '/api/content/1' },
'users:list': { method: 'GET', path: '/api/users' },
'users:delete': { method: 'DELETE', path: '/api/users/1' },
'billing:view': { method: 'GET', path: '/api/billing' },
};
describe('RBAC API Matrix', () => {
for (const [permission, endpoint] of Object.entries(API_ENDPOINTS)) {
for (const role of Object.values(ROLES)) {
const shouldAllow = hasPermission(role as any, permission as any);
const expectedStatus = shouldAllow ? 200 : role === ROLES.ANONYMOUS ? 401 : 403;
it(`${role} ${endpoint.method} ${endpoint.path} → ${expectedStatus}`, async () => {
const token = role !== ROLES.ANONYMOUS ? await getAuthToken({ role }) : null;
const req = request(app)[endpoint.method.toLowerCase()](endpoint.path);
if (token) req.set('Authorization', `Bearer ${token}`);
const res = await req;
expect(res.status).toBe(expectedStatus);
});
}
}
});Summary
Automated RBAC testing requires:
- Test the permission model directly — unit test
hasPermission()for all role/permission combinations - Test middleware in isolation — verify 200, 401, and 403 responses for each permission
- Test role escalation prevention — users must not be able to grant themselves higher privileges
- E2E test UI visibility — confirm role-appropriate UI elements appear/disappear correctly
- Generate matrix tests — systematic coverage of all role × endpoint combinations
RBAC bugs don't fail loudly — they silently grant access to unauthorized users or deny access to authorized ones. Only systematic automated testing covers all the combinations a manual tester would miss.