diff --git a/contracts/foundry.toml b/contracts/foundry.toml index 83af784..a81397e 100644 --- a/contracts/foundry.toml +++ b/contracts/foundry.toml @@ -7,6 +7,15 @@ evm_version = "paris" # Autonity doesn't support PUSH0 (Shanghai) optimizer = true optimizer_runs = 10000 +gas_reports = ["KeyRAAccessControl"] + +[fuzz] +runs = 1000 + +[invariant] +runs = 256 +depth = 64 + [rpc_endpoints] local = "http://127.0.0.1:8545" diff --git a/contracts/test/AccessList.invariant.t.sol b/contracts/test/AccessList.invariant.t.sol new file mode 100644 index 0000000..4f3be38 --- /dev/null +++ b/contracts/test/AccessList.invariant.t.sol @@ -0,0 +1,124 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Test} from "forge-std/Test.sol"; +import {KeyRAAccessControl} from "../src/AccessList.sol"; + +/// @notice Handler contract that drives invariant testing by calling +/// admin and access management functions with bounded random inputs. +/// Catches only expected errors — unexpected reverts propagate as failures. +contract KeyRAAccessControlHandler is Test { + KeyRAAccessControl public acl; + + // Track successful operations for observability + uint256 public adminsAdded; + uint256 public adminsRemoved; + uint256 public accessGranted; + uint256 public accessRevoked; + + constructor(KeyRAAccessControl _acl) { + acl = _acl; + } + + function addAdmin(uint256 seed) external { + address account = _boundAddr(seed); + try acl.addAdmin(account) { + adminsAdded++; + } catch (bytes memory reason) { + _expectKnownError(reason); + } + } + + function removeAdmin(uint256 seed) external { + address account = _boundAddr(seed); + try acl.removeAdmin(account) { + adminsRemoved++; + } catch (bytes memory reason) { + _expectKnownError(reason); + } + } + + function grantAccess(uint256 seed) external { + address account = _boundAddr(seed); + try acl.grantAccess(account) { + accessGranted++; + } catch (bytes memory reason) { + _expectKnownError(reason); + } + } + + function revokeAccess(uint256 seed) external { + address account = _boundAddr(seed); + try acl.revokeAccess(account) { + accessRevoked++; + } catch (bytes memory reason) { + _expectKnownError(reason); + } + } + + function _boundAddr(uint256 seed) internal pure returns (address) { + return address(uint160(bound(seed, 1, type(uint160).max))); + } + + /// @dev Only allow known/expected revert reasons. Unknown reverts fail the test. + function _expectKnownError(bytes memory reason) internal pure { + bytes4 selector; + if (reason.length >= 4) { + assembly { + selector := mload(add(reason, 32)) + } + } + + if ( + selector == KeyRAAccessControl.AlreadyAdmin.selector + || selector == KeyRAAccessControl.NotAnAdmin.selector + || selector == KeyRAAccessControl.CannotRemoveLastAdmin.selector + || selector == KeyRAAccessControl.AlreadyHasAccess.selector + || selector == KeyRAAccessControl.NoAccess.selector + || selector == KeyRAAccessControl.ZeroAddress.selector + ) { + return; // expected — ignore + } + + // Unexpected revert — propagate original revert data as test failure + assembly { + revert(add(reason, 32), mload(reason)) + } + } +} + +/// @notice Invariant tests for KeyRAAccessControl. +/// Verifies properties that must hold regardless of operation sequence. +contract KeyRAAccessControlInvariantTest is Test { + KeyRAAccessControl public acl; + KeyRAAccessControlHandler public handler; + + function setUp() public { + acl = new KeyRAAccessControl(address(this)); + handler = new KeyRAAccessControlHandler(acl); + + // Handler acts as admin so its calls go through onlyAdmin + acl.addAdmin(address(handler)); + + targetContract(address(handler)); + } + + /// @notice Admin count must never reach zero — the contract guard prevents it. + function invariant_adminCountNeverZero() public view { + assertGt(acl.adminCount(), 0); + } + + /// @notice Operations are actually being exercised — not all silently reverting. + /// Uses a call counter to skip the check on the initial setUp() invocation. + uint256 private _invariantCalls; + + function invariant_operationsExercised() public { + _invariantCalls++; + if (_invariantCalls <= 1) return; // skip initial setUp() check + + uint256 total = + handler.adminsAdded() + handler.adminsRemoved() + handler.accessGranted() + + handler.accessRevoked(); + assertGt(total, 0, "No operations succeeded - handler may be misconfigured"); + } +} diff --git a/contracts/test/AccessList.t.sol b/contracts/test/AccessList.t.sol index e8b3b5e..9f6ec7f 100644 --- a/contracts/test/AccessList.t.sol +++ b/contracts/test/AccessList.t.sol @@ -200,4 +200,220 @@ contract KeyRAAccessControlTest is Test { vm.expectRevert(KeyRAAccessControl.ZeroAddress.selector); acl.grantAccess(address(0)); } + + // Self-referential operation tests + + function test_removeAdmin_selfRemoval() public { + acl.addAdmin(user1); + // admin removes themselves — should succeed since adminCount > 1 + acl.removeAdmin(admin); + assertFalse(acl.isAdmin(admin)); + assertEq(acl.adminCount(), 1); + } + + function test_grantAccess_toAdmin() public { + // admin and access are orthogonal — admin can grant access to themselves + acl.grantAccess(admin); + assertTrue(acl.hasAccess(admin)); + assertTrue(acl.isAdmin(admin)); + } + + function test_revokeAccess_fromAdmin() public { + acl.grantAccess(admin); + acl.revokeAccess(admin); + assertFalse(acl.hasAccess(admin)); + assertTrue(acl.isAdmin(admin)); // admin role unaffected + } + + // State transition cycle tests + + function test_grantAccess_afterRevoke_succeeds() public { + acl.grantAccess(user1); + acl.revokeAccess(user1); + assertFalse(acl.hasAccess(user1)); + + // Re-grant should succeed + acl.grantAccess(user1); + assertTrue(acl.hasAccess(user1)); + } + + function test_addAdmin_afterRemoval_succeeds() public { + acl.addAdmin(user1); + acl.removeAdmin(user1); + assertFalse(acl.isAdmin(user1)); + + // Re-add should succeed + acl.addAdmin(user1); + assertTrue(acl.isAdmin(user1)); + assertEq(acl.adminCount(), 2); + } + + function test_removeAdmin_doesNotRevokeAccess() public { + acl.addAdmin(user1); + acl.grantAccess(user1); + + acl.removeAdmin(user1); + + assertFalse(acl.isAdmin(user1)); + assertTrue(acl.hasAccess(user1)); // access and admin are independent + } + + // Removed admin cannot act tests + + function test_removedAdmin_cannotGrantAccess() public { + acl.addAdmin(user1); + acl.removeAdmin(user1); + + vm.prank(user1); + vm.expectRevert(KeyRAAccessControl.NotAdmin.selector); + acl.grantAccess(user2); + } + + function test_removedAdmin_cannotAddAdmin() public { + acl.addAdmin(user1); + acl.removeAdmin(user1); + + vm.prank(user1); + vm.expectRevert(KeyRAAccessControl.NotAdmin.selector); + acl.addAdmin(user2); + } + + // Multi-admin complex scenario tests + + function test_removeAdmin_threeAdmins_canRemoveTwo() public { + address user3 = makeAddr("user3"); + acl.addAdmin(user1); + acl.addAdmin(user3); + assertEq(acl.adminCount(), 3); + + acl.removeAdmin(user1); + acl.removeAdmin(user3); + assertEq(acl.adminCount(), 1); + + // Last admin cannot be removed + vm.expectRevert(KeyRAAccessControl.CannotRemoveLastAdmin.selector); + acl.removeAdmin(admin); + } + + function test_newAdmin_removesOriginalAdmin() public { + acl.addAdmin(user1); + + // user1 removes the original admin who added them + vm.prank(user1); + acl.removeAdmin(admin); + + assertFalse(acl.isAdmin(admin)); + assertEq(acl.adminCount(), 1); + + // user1 is now the sole admin and cannot remove themselves + vm.prank(user1); + vm.expectRevert(KeyRAAccessControl.CannotRemoveLastAdmin.selector); + acl.removeAdmin(user1); + } + + function test_adminA_grants_adminB_revokes_sameUser() public { + acl.addAdmin(user1); + + // admin grants access to user2 + acl.grantAccess(user2); + + // user1 (different admin) revokes user2's access + vm.prank(user1); + acl.revokeAccess(user2); + + assertFalse(acl.hasAccess(user2)); + } + + // View function dedicated tests + + function test_adminCount_afterMultipleAddRemove() public { + assertEq(acl.adminCount(), 1); + acl.addAdmin(user1); + assertEq(acl.adminCount(), 2); + acl.addAdmin(user2); + assertEq(acl.adminCount(), 3); + acl.removeAdmin(user1); + assertEq(acl.adminCount(), 2); + acl.removeAdmin(user2); + assertEq(acl.adminCount(), 1); + } + + // Full lifecycle integration test + + function test_fullLifecycle() public { + // 1. Initial admin grants access to user1 + acl.grantAccess(user1); + assertTrue(acl.hasAccess(user1)); + + // 2. Initial admin adds user1 as admin + acl.addAdmin(user1); + + // 3. user1 (now admin) grants access to user2 + vm.prank(user1); + acl.grantAccess(user2); + assertTrue(acl.hasAccess(user2)); + + // 4. user1 revokes user2's access + vm.prank(user1); + acl.revokeAccess(user2); + assertFalse(acl.hasAccess(user2)); + + // 5. Original admin removes user1 as admin + acl.removeAdmin(user1); + + // 6. user1 still has access but cannot admin + assertTrue(acl.hasAccess(user1)); + vm.prank(user1); + vm.expectRevert(KeyRAAccessControl.NotAdmin.selector); + acl.grantAccess(user2); + } + + // Fuzz tests — access control enforcement + + function testFuzz_grantAccess_revertsIfNotAdmin(address caller) public { + vm.assume(caller != admin); + vm.assume(caller != address(0)); + vm.prank(caller); + vm.expectRevert(KeyRAAccessControl.NotAdmin.selector); + acl.grantAccess(user1); + } + + function testFuzz_addAdmin_revertsIfNotAdmin(address caller) public { + vm.assume(caller != admin); + vm.assume(caller != address(0)); + vm.prank(caller); + vm.expectRevert(KeyRAAccessControl.NotAdmin.selector); + acl.addAdmin(user1); + } + + function testFuzz_removeAdmin_revertsIfNotAdmin(address caller) public { + vm.assume(caller != admin); + vm.assume(caller != address(0)); + vm.prank(caller); + vm.expectRevert(KeyRAAccessControl.NotAdmin.selector); + acl.removeAdmin(admin); + } + + function testFuzz_revokeAccess_revertsIfNotAdmin(address caller) public { + acl.grantAccess(user1); + vm.assume(caller != admin); + vm.assume(caller != address(0)); + vm.prank(caller); + vm.expectRevert(KeyRAAccessControl.NotAdmin.selector); + acl.revokeAccess(user1); + } + + function testFuzz_grantAccess_thenHasAccess(address account) public { + vm.assume(account != address(0)); + vm.assume(!acl.hasAccess(account)); + acl.grantAccess(account); + assertTrue(acl.hasAccess(account)); + } + + function testFuzz_addAdmin_thenIsAdmin(address account) public { + vm.assume(account != admin); + vm.assume(account != address(0)); + acl.addAdmin(account); + assertTrue(acl.isAdmin(account)); + } }