assertRequiresPermission( EditUser::class, 'post', UsersPermissions::EDIT, ); } public function test_controller_get_requires_users_edit_permission_globally(): void { // GET is gated too — otherwise a non-admin could view the // admin edit form (info leak) even without being able to POST. $this->assertRequiresPermission( EditUser::class, 'get', UsersPermissions::EDIT, ); } public function test_service_edit_user_requires_users_edit_permission_globally(): void { // Service-layer surface — any caller (JSON-RPC, plugins, // service-to-service) also passes through PermissionEnforcer // because the attribute is on the method, not the controller. $this->assertRequiresPermission( UsersService::class, 'editUser', UsersPermissions::EDIT, ); } public function test_service_update_user_requires_users_edit_permission_globally(): void { // updateUser is the JSON-RPC entry point — wraps editUser + // project reconciliation. Its attribute is what secures the // RPC path (RPC bypasses the controller gate, per the // RequiresPermission docblock). $this->assertRequiresPermission( UsersService::class, 'updateUser', UsersPermissions::EDIT, ); } // ─── Default-grant hierarchy — who has users.edit ───────────────── public function test_users_edit_is_granted_to_admin_and_owner_only(): void { // The other half of the bypass guarantee: the attribute above // is only meaningful if `users.edit` isn't handed out to a // lower role by default. Owner + admin get it; manager gets // only users.create; editor/commenter/readonly get no users.*. $catalog = [new Permission(UsersPermissions::EDIT, 'Edit users', false)]; $this->assertContains( UsersPermissions::EDIT, DefaultRolePermissions::grantsFor('admin', $catalog), 'admin must retain users.edit — the primary gate' ); $this->assertContains( UsersPermissions::EDIT, DefaultRolePermissions::grantsFor('owner', $catalog), 'owner must retain users.edit — inherits admin grants' ); // Everything below admin must NOT have it. If a future default // hands users.edit to manager or below, this test fails and // the reviewer's bypass concern re-materialises silently. foreach (['manager', 'editor', 'commenter', 'readonly'] as $role) { $this->assertNotContains( UsersPermissions::EDIT, DefaultRolePermissions::grantsFor($role, $catalog), sprintf('%s must NOT have users.edit by default', $role) ); } } // ─── Helpers ────────────────────────────────────────────────────── private function assertRequiresPermission(string $class, string $method, string $permission): void { $reflection = new ReflectionMethod($class, $method); $attributes = $reflection->getAttributes(RequiresPermission::class); $this->assertCount( 1, $attributes, sprintf('%s::%s must declare exactly one #[RequiresPermission] attribute', $class, $method) ); $attr = $attributes[0]->newInstance(); $this->assertSame( $permission, $attr->permission, sprintf('%s::%s must require %s', $class, $method, $permission) ); $this->assertTrue( $attr->global, sprintf('%s::%s must be global-scoped (users.* are company-wide, not project-scoped)', $class, $method) ); } }