
Testing Solidity Access Control with Role-Based Permissions
Why role-based access control deserves dedicated tests
Many teams test “happy paths” well and assume access control is covered because a function has onlyOwner or onlyRole. That assumption is risky. Permission logic often changes over time as contracts evolve:
- a new role is added for operations or governance
- an admin role is transferred to a multisig
- a function is exposed through a new proxy or module
- a privileged action is split into multiple steps
If tests only confirm that a function works for the deployer, they do not prove that the contract rejects unauthorized accounts or that role administration behaves correctly. Good access control tests should answer four questions:
- Who can call each privileged function?
- Who cannot call it?
- Can roles be granted and revoked safely?
- Are admin relationships configured as intended?
A simple role-based contract to test
The example below uses OpenZeppelin’s AccessControl. It models a token vault with two roles:
DEFAULT_ADMIN_ROLE: can grant and revoke rolesWITHDRAWER_ROLE: can withdraw tokens from the vault
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import "@openzeppelin/contracts/access/AccessControl.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
contract TokenVault is AccessControl {
bytes32 public constant WITHDRAWER_ROLE = keccak256("WITHDRAWER_ROLE");
constructor(address admin) {
_grantRole(DEFAULT_ADMIN_ROLE, admin);
}
function deposit(IERC20 token, uint256 amount) external {
require(token.transferFrom(msg.sender, address(this), amount), "TRANSFER_FAILED");
}
function withdraw(IERC20 token, address to, uint256 amount) external onlyRole(WITHDRAWER_ROLE) {
require(token.transfer(to, amount), "TRANSFER_FAILED");
}
}This contract is intentionally small, but it contains the core access-control behavior you should test in real systems.
What to test for role-based permissions
A strong test suite should cover the following categories.
| Test area | What it proves | Typical failure mode |
|---|---|---|
| Authorized access | The correct role can execute the function | Role not checked, wrong modifier, broken admin setup |
| Unauthorized access | Non-members are rejected | Missing modifier, incorrect role hash |
| Role granting | Admin can assign roles | Wrong admin role, inaccessible setup path |
| Role revocation | Admin can remove roles | Stale privileges remain active |
| Role admin relationships | Each role is governed by the intended admin | Misconfigured admin hierarchy |
| Initialization | Initial roles are assigned correctly | Deployer or multisig not granted expected permissions |
Testing unauthorized callers first
A useful habit is to write the rejection test before the success test. This makes the permission boundary explicit and reduces the chance that a permissive implementation slips through.
Here is a Foundry-style example, but the same logic applies in Hardhat, Truffle, or Remix tests.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import "forge-std/Test.sol";
import "../src/TokenVault.sol";
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
contract MockToken is ERC20 {
constructor() ERC20("Mock", "MOCK") {
_mint(msg.sender, 1_000_000 ether);
}
}
contract TokenVaultTest is Test {
TokenVault vault;
MockToken token;
address admin = address(0xA11CE);
address user = address(0xB0B);
function setUp() public {
vm.prank(admin);
vault = new TokenVault(admin);
token = new MockToken();
}
function testWithdrawRevertsForNonRoleMember() public {
vm.prank(user);
vm.expectRevert(
abi.encodeWithSelector(
bytes4(keccak256("AccessControlUnauthorizedAccount(address,bytes32)")),
user,
vault.WITHDRAWER_ROLE()
)
);
vault.withdraw(token, user, 1 ether);
}
}Why this test matters
This test verifies that the function is not accidentally callable by any address. It also confirms the contract uses the expected role identifier. If a developer later changes the role constant or removes the modifier, the test fails immediately.
Testing successful access with the right role
Once rejection is covered, verify that the intended role can perform the action. This should include both the permission check and the side effect.
function testWithdrawWorksForWithdrawer() public {
address withdrawer = address(0xC0FFEE);
vm.prank(admin);
vault.grantRole(vault.WITHDRAWER_ROLE(), withdrawer);
// Fund the vault
token.transfer(address(vault), 10 ether);
vm.prank(withdrawer);
vault.withdraw(token, user, 3 ether);
assertEq(token.balanceOf(user), 3 ether);
}This test proves more than just “the call does not revert.” It confirms that:
- the role was granted by an authorized admin
- the role holder can call the function
- the token transfer actually happened
That combination is important because some permission bugs only appear when the function performs state changes.
Testing role administration
Role-based systems are only as safe as their admin hierarchy. A common mistake is assuming that DEFAULT_ADMIN_ROLE is the only admin relationship that matters. In practice, you should verify who can grant and revoke each role.
OpenZeppelin roles use getRoleAdmin(role) to define the admin role for a given permission. If you create custom roles, test that the admin mapping is what you expect.
function testDefaultAdminCanGrantWithdrawerRole() public {
address operator = address(0xD00D);
vm.prank(admin);
vault.grantRole(vault.WITHDRAWER_ROLE(), operator);
assertTrue(vault.hasRole(vault.WITHDRAWER_ROLE(), operator));
}
function testNonAdminCannotGrantRole() public {
address attacker = address(0xBAD);
vm.prank(attacker);
vm.expectRevert(
abi.encodeWithSelector(
bytes4(keccak256("AccessControlUnauthorizedAccount(address,bytes32)")),
attacker,
vault.DEFAULT_ADMIN_ROLE()
)
);
vault.grantRole(vault.WITHDRAWER_ROLE(), attacker);
}What to check in admin tests
- only the expected admin can grant the role
- only the expected admin can revoke the role
- role membership changes are reflected by
hasRole - role admin relationships do not change unexpectedly during upgrades or refactors
Testing revocation and privilege removal
Revocation tests are essential because many real-world incidents involve stale permissions. A user may have been granted access during deployment, migration, or incident response and later forgotten.
function testRevokedRoleCannotWithdraw() public {
address operator = address(0xDADA);
vm.startPrank(admin);
vault.grantRole(vault.WITHDRAWER_ROLE(), operator);
vault.revokeRole(vault.WITHDRAWER_ROLE(), operator);
vm.stopPrank();
vm.prank(operator);
vm.expectRevert(
abi.encodeWithSelector(
bytes4(keccak256("AccessControlUnauthorizedAccount(address,bytes32)")),
operator,
vault.WITHDRAWER_ROLE()
)
);
vault.withdraw(token, user, 1 ether);
}This test is particularly valuable when permissions are managed by governance or a multisig. It ensures that removing access actually removes the ability to act, not just the on-chain record.
Testing role setup during deployment
Access control bugs often happen at initialization, not during steady-state execution. For example:
- the wrong address receives admin rights
- the deployer keeps privileges that should be transferred
- a multisig is not assigned the expected role
- a role is accidentally left unassigned
Deployment tests should assert the initial state explicitly.
function testConstructorAssignsAdminRole() public {
assertTrue(vault.hasRole(vault.DEFAULT_ADMIN_ROLE(), admin));
assertFalse(vault.hasRole(vault.DEFAULT_ADMIN_ROLE(), user));
}If your contract uses an initializer instead of a constructor, test both:
- the initializer can only be called once
- the initial admin is set correctly
- no privileged function is callable before initialization completes
Common access-control test patterns
1. Use distinct addresses for each actor
Do not reuse the deployer for every test. Use separate accounts for admin, operator, and attacker roles. This makes permission boundaries visible and prevents accidental privilege leakage from the test setup.
2. Assert both revert and state
A revert test alone is not enough if the function has multiple execution paths. Always verify the relevant state after a successful call, such as balances, flags, or emitted events.
3. Test role hashes indirectly and directly
When using keccak256("ROLE_NAME"), test the constant through the public getter or the contract interface. This catches typos and refactors that silently change the role identifier.
4. Cover negative paths for every privileged function
If a contract has five privileged functions, each one should have at least one unauthorized-call test. Do not assume one generic access-control test covers them all.
5. Keep role tests close to the contract behavior
Role tests should live near the feature they protect. If a function changes from onlyOwner to onlyRole, update the test file in the same commit. This reduces the chance of mismatched assumptions.
Testing custom access control logic
Not every project uses AccessControl. Some contracts implement custom permission checks, such as:
- a mapping of approved operators
- a whitelist of addresses
- a two-step admin transfer
- a timelocked guardian role
The testing approach is the same: identify the authority source, verify the allowed path, and prove that unauthorized accounts fail.
For example, if a contract uses mapping(address => bool) public isOperator, test:
- the deployer or admin can add operators
- operators can execute the intended function
- non-operators revert
- removing an operator immediately blocks access
The main difference is that you must inspect the contract’s own state instead of relying on hasRole.
Practical debugging tips for failing access-control tests
When a permission test fails, the cause is usually one of these:
- the test used the wrong caller context
- the role was never granted in setup
- the expected revert selector is outdated
- the contract uses a different admin role than the test assumes
- the function is protected by multiple checks, and a different one fails first
A few debugging practices help isolate the issue quickly:
- print or inspect
msg.senderin the failing path - verify role membership before calling the function
- confirm the exact revert type, especially with custom errors
- check whether the test framework preserves caller context across helper functions
If you are using cheat codes or impersonation utilities, make sure the prank or impersonation scope includes the actual call, not just the setup step.
A concise checklist for access-control test coverage
Before merging a contract that uses roles, confirm the following:
- every privileged function has an unauthorized-call test
- every privileged function has at least one successful-call test
- role grants and revocations are tested
- admin relationships are verified
- deployment or initialization assigns the correct roles
- tests use separate actors for admin, operator, and attacker
- state changes are asserted, not just revert behavior
Conclusion
Role-based permissions are one of the most effective ways to structure smart-contract authorization, but they are only trustworthy when tested thoroughly. The goal is not just to prove that the happy path works; it is to prove that every privileged action is available only to the intended accounts and that role administration behaves predictably.
If you build access-control tests around unauthorized access, successful execution, role management, and initialization, you will catch most permission bugs before deployment. That discipline pays off especially well in contracts that manage funds, upgrades, or governance.
