Why role-based access control deserves dedicated tests

Many contracts start with a simple onlyOwner pattern and later evolve into more granular permissions. For example, a token contract may need separate roles for minting, pausing, upgrading, and managing fees. Once multiple roles exist, testing becomes more important because the failure modes are subtle:

  • A privileged function may accidentally be left public.
  • A role admin may be able to grant permissions too broadly.
  • Revoked accounts may still retain access through another path.
  • A contract may assume a role exists without checking initialization.

Role-based tests should answer three questions:

  1. Who can call this function?
  2. Who can assign or revoke this role?
  3. What happens when permissions change over time?

A good test suite checks both positive paths and negative paths. Positive tests confirm authorized users can act. Negative tests confirm everyone else cannot.


A practical role model with OpenZeppelin

The most common implementation uses AccessControl from OpenZeppelin. It provides:

  • named roles using bytes32 identifiers
  • role admins for delegation
  • grantRole, revokeRole, and renounceRole
  • standardized revert behavior for unauthorized calls

Here is a compact example contract:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import "@openzeppelin/contracts/access/AccessControl.sol";

contract Treasury is AccessControl {
    bytes32 public constant MANAGER_ROLE = keccak256("MANAGER_ROLE");
    bytes32 public constant PAUSER_ROLE = keccak256("PAUSER_ROLE");

    uint256 public balance;
    bool public paused;

    constructor(address admin) {
        _grantRole(DEFAULT_ADMIN_ROLE, admin);
        _grantRole(MANAGER_ROLE, admin);
    }

    function deposit() external payable {
        require(!paused, "paused");
        balance += msg.value;
    }

    function setPaused(bool value) external onlyRole(PAUSER_ROLE) {
        paused = value;
    }

    function sweep(address payable to, uint256 amount) external onlyRole(MANAGER_ROLE) {
        require(amount <= address(this).balance, "insufficient");
        to.transfer(amount);
    }
}

This contract has two roles with different responsibilities. The admin can manage roles, the manager can sweep funds, and the pauser can stop deposits. That separation is exactly what your tests should validate.


What to test for role-based permissions

A strong access-control test suite usually covers the following cases:

Test areaWhat to verifyWhy it matters
Default role setupInitial admin and operational roles are assigned correctlyPrevents deployment misconfiguration
Authorized accessA role holder can call protected functionsConfirms legitimate workflows work
Unauthorized accessNon-role accounts are rejectedPrevents privilege escalation
Role administrationOnly the correct admin can grant or revoke rolesProtects the permission model
Revocation behaviorRemoved accounts lose access immediatelyEnsures changes take effect
Self-renunciationA role holder can renounce their own roleUseful for operational security
Cross-role isolationOne role does not imply another roleAvoids accidental over-privilege

Do not limit yourself to a single “reverts for non-admin” test. Real systems often fail at the edges: after a role is revoked, after ownership changes, or after a contract is upgraded.


Writing focused tests for authorization boundaries

The most important pattern is to test each privileged function from at least two perspectives:

  • a valid role holder
  • an unauthorized account

Below is an example using Foundry-style tests. The same logic applies in Hardhat or Truffle.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import "forge-std/Test.sol";
import "../src/Treasury.sol";

contract TreasuryTest is Test {
    Treasury treasury;

    address admin = address(0xA11CE);
    address manager = address(0xB0B);
    address pauser = address(0xC0DE);
    address attacker = address(0xDEAD);

    function setUp() public {
        treasury = new Treasury(admin);

        vm.prank(admin);
        treasury.grantRole(treasury.MANAGER_ROLE(), manager);

        vm.prank(admin);
        treasury.grantRole(treasury.PAUSER_ROLE(), pauser);
    }

    function testManagerCanSweep() public {
        vm.deal(address(treasury), 5 ether);

        vm.prank(manager);
        treasury.sweep(payable(attacker), 1 ether);

        assertEq(attacker.balance, 1 ether);
    }

    function testNonManagerCannotSweep() public {
        vm.deal(address(treasury), 5 ether);

        vm.prank(attacker);
        vm.expectRevert();
        treasury.sweep(payable(attacker), 1 ether);
    }

    function testPauserCanPause() public {
        vm.prank(pauser);
        treasury.setPaused(true);

        assertTrue(treasury.paused());
    }

    function testNonPauserCannotPause() public {
        vm.prank(attacker);
        vm.expectRevert();
        treasury.setPaused(true);
    }
}

Why this structure works

Each test isolates one permission boundary. That makes failures easy to interpret. If testNonManagerCannotSweep fails, you know the issue is with sweep authorization, not with pausing or role setup.

A common mistake is to write only one “happy path” test for each role. That confirms the role works, but not that the rest of the world is blocked.


Testing role assignment and revocation

Authorization is not static. Roles are granted, revoked, and sometimes renounced. Your tests should verify that permissions change immediately and correctly.

Granting roles

Only the role admin should be able to grant a role. If your contract uses the default admin role, test that the admin can grant and that non-admins cannot.

function testAdminCanGrantManagerRole() public {
    address newManager = address(0x1234);

    vm.prank(admin);
    treasury.grantRole(treasury.MANAGER_ROLE(), newManager);

    assertTrue(treasury.hasRole(treasury.MANAGER_ROLE(), newManager));
}

function testNonAdminCannotGrantManagerRole() public {
    address newManager = address(0x1234);

    vm.prank(attacker);
    vm.expectRevert();
    treasury.grantRole(treasury.MANAGER_ROLE(), newManager);
}

Revoking roles

Revocation should remove access immediately. This is especially important for emergency response and offboarding.

function testRevokedManagerCannotSweep() public {
    vm.deal(address(treasury), 5 ether);

    vm.prank(admin);
    treasury.revokeRole(treasury.MANAGER_ROLE(), manager);

    vm.prank(manager);
    vm.expectRevert();
    treasury.sweep(payable(attacker), 1 ether);
}

Renouncing roles

Renouncing is self-service and should only work for the caller’s own role. This is useful when an operator wants to reduce exposure.

function testManagerCanRenounceOwnRole() public {
    vm.prank(manager);
    treasury.renounceRole(treasury.MANAGER_ROLE(), manager);

    assertFalse(treasury.hasRole(treasury.MANAGER_ROLE(), manager));
}

A useful best practice is to test role changes in the same sequence they occur in production: deploy, grant, use, revoke, verify denial.


Testing role admin hierarchies

OpenZeppelin AccessControl supports separate admin roles for each permission. This is powerful, but it creates another class of bugs: the wrong account may be able to manage a role.

If you define a custom admin role, test the hierarchy explicitly. For example, if PAUSER_ROLE is administered by MANAGER_ROLE, then a pauser should not be able to grant more pausers unless they also hold manager privileges.

A test matrix can help:

ActorCan grant MANAGER_ROLECan grant PAUSER_ROLECan call sweepCan call setPaused
DEFAULT_ADMIN_ROLEYesYesNoNo
MANAGER_ROLENo | unless configuredYes | if adminYesNo
PAUSER_ROLENoNo | unless configuredNoYes
External accountNoNoNoNo

This kind of matrix is especially useful when permissions are split across operational teams. It makes the intended policy visible and testable.


Avoiding brittle tests for revert behavior

For access control, the exact revert string is often less important than the fact that unauthorized access fails. If you use OpenZeppelin, onlyRole typically reverts with a standardized error. You can assert on the revert type if your framework supports it, but do not overfit tests to implementation details unless the revert shape is part of your contract’s public behavior.

A practical approach is:

  • use broad revert assertions for authorization failures
  • use precise revert checks for business logic failures, such as insufficient or paused

This keeps tests stable if the access-control library changes its internal revert message format.


Testing cross-function privilege separation

A common design mistake is to assume one role implies another. For example, a manager may be allowed to move funds, but not pause the system. Your tests should prove that separation.

Create a test for each function-role pair that matters. If a role should not have access, assert denial even if the role is highly privileged in another area.

function testManagerCannotPauseUnlessGranted() public {
    vm.prank(manager);
    vm.expectRevert();
    treasury.setPaused(true);
}

This is especially important in governance contracts, vaults, and protocol admin modules. A role that can change one parameter should not silently gain the ability to change all parameters.


Best practices for maintainable access-control tests

1. Centralize role setup

Create helper functions in your test suite for common setup steps. This reduces duplication and makes the intended permission model easier to read.

2. Use descriptive account labels

When possible, name addresses by function rather than by identity: admin, manager, pauser, attacker. This makes the test intent obvious.

3. Test the full lifecycle

Do not stop at deployment. Include grant, use, revoke, and renounce flows.

4. Cover every privileged entry point

If a function changes funds, configuration, or upgrade state, it needs an authorization test.

5. Keep roles minimal

The fewer permissions each role has, the easier it is to test and audit. If a role starts accumulating unrelated powers, split it.

6. Verify initialization

If roles are assigned in the constructor or initializer, test that they are present immediately after deployment and absent for everyone else.


Common mistakes to catch early

  • Forgetting to test negative cases: a function that “works” for the admin may still be public.
  • Assuming admin implies operator: admin rights and operational rights should be tested separately.
  • Not testing revocation: stale permissions are a frequent source of incidents.
  • Using one account for everything: this hides authorization bugs.
  • Ignoring role admin configuration: the ability to manage roles is as sensitive as the role itself.

A good access-control suite is less about code coverage and more about policy coverage. It should prove that the contract enforces the intended security model under realistic account behavior.


A simple checklist before shipping

Before deploying a contract with role-based permissions, confirm:

  • every privileged function has an authorization test
  • each role is granted only to intended accounts
  • unauthorized accounts are rejected
  • role admins are tested separately from role holders
  • revocation and renunciation remove access immediately
  • initialization assigns the expected baseline permissions

If any of these are missing, the contract may be functionally correct but operationally unsafe.

Learn more with useful resources