diff --git a/security/Group.php b/security/Group.php index c45823d51..f9c661efd 100755 --- a/security/Group.php +++ b/security/Group.php @@ -115,7 +115,7 @@ class Group extends DataObject { $config->getComponentByType('GridFieldAddExistingAutocompleter') ->setResultsFormat('$Title ($Email)')->setSearchFields(array('FirstName', 'Surname', 'Email')); $config->getComponentByType('GridFieldDetailForm') - ->setValidator(new Member_Validator()) + ->setValidator(Member_Validator::create()) ->setItemEditFormCallback(function($form, $component) use($group) { $record = $form->getRecord(); $groupsField = $form->Fields()->dataFieldByName('DirectGroups'); diff --git a/security/Member.php b/security/Member.php index e0195153a..ab8f4b3e7 100644 --- a/security/Member.php +++ b/security/Member.php @@ -766,6 +766,7 @@ class Member extends DataObject implements TemplateGlobalProvider { */ public function getValidator() { $validator = Injector::inst()->create('Member_Validator'); + $validator->setForMember($this); $this->extend('updateValidator', $validator); return $validator; @@ -1801,21 +1802,39 @@ class Member_ForgotPasswordEmail extends Email { * Member Validator * * Custom validation for the Member object can be achieved either through an - * {@link DataExtension} on the Member object or, by specifying a subclass of + * {@link DataExtension} on the Member_Validator object or, by specifying a subclass of * {@link Member_Validator} through the {@link Injector} API. - * + * The Validator can also be modified by adding an Extension to Member and implement the + * updateValidator hook. * {@see Member::getValidator()} * + * Additional required fields can also be set via config API, eg. + * + * Member_Validator: + * customRequired: + * - Surname + * + * * @package framework * @subpackage security */ -class Member_Validator extends RequiredFields { - +class Member_Validator extends RequiredFields +{ + /** + * Fields that are required by this validator + * @config + * @var array + */ protected $customRequired = array( 'FirstName', 'Email' ); + /** + * Determine what member this validator is meant for + * @var Member + */ + protected $forMember = null; /** * Constructor @@ -1829,7 +1848,33 @@ class Member_Validator extends RequiredFields { $required = array_merge($required, $this->customRequired); - parent::__construct($required); + // check for config API values and merge them in + $config = $this->config()->customRequired; + if(is_array($config)){ + $required = array_merge($required, $config); + } + + parent::__construct(array_unique($required)); + } + + /** + * Get the member this validator applies to. + * @return Member + */ + public function getForMember() + { + return $this->forMember; + } + + /** + * Set the Member this validator applies to. + * @param Member $value + * @return $this + */ + public function setForMember(Member $value) + { + $this->forMember = $value; + return $this; } /** @@ -1842,47 +1887,54 @@ class Member_Validator extends RequiredFields { * @return bool Returns TRUE if the submitted data is valid, otherwise * FALSE. */ - public function php($data) { + public function php($data) + { $valid = parent::php($data); - $identifierField = Member::config()->unique_identifier_field; - $member = DataObject::get_one('Member', array( - "\"$identifierField\"" => $data[$identifierField] - )); + $identifierField = (string)Member::config()->unique_identifier_field; - // if we are in a complex table field popup, use ctf[childID], else use ID - if(isset($_REQUEST['ctf']['childID'])) { - $id = $_REQUEST['ctf']['childID']; - } elseif(isset($_REQUEST['ID'])) { - $id = $_REQUEST['ID']; - } else { - $id = null; - } - - if($id && is_object($member) && $member->ID != $id) { - $uniqueField = $this->form->Fields()->dataFieldByName($identifierField); - $this->validationError( - $uniqueField->id(), - _t( - 'Member.VALIDATIONMEMBEREXISTS', - 'A member already exists with the same %s', - array('identifier' => strtolower($identifierField)) - ), - 'required' - ); - $valid = false; - } - - // Execute the validators on the extensions - if($this->extension_instances) { - foreach($this->extension_instances as $extension) { - if(method_exists($extension, 'hasMethod') && $extension->hasMethod('updatePHP')) { - $valid &= $extension->updatePHP($data, $this->form); + // Only validate identifier field if it's actually set. This could be the case if + // somebody removes `Email` from the list of required fields. + if(isset($data[$identifierField])){ + $id = isset($data['ID']) ? (int)$data['ID'] : 0; + if(!$id && ($ctrl = $this->form->getController())){ + // get the record when within GridField (Member editing page in CMS) + if($ctrl instanceof GridFieldDetailForm_ItemRequest && $record = $ctrl->getRecord()){ + $id = $record->ID; } } + + // If there's no ID passed via controller or form-data, use the assigned member (if available) + if(!$id && ($member = $this->getForMember())){ + $id = $member->exists() ? $member->ID : 0; + } + + // set the found ID to the data array, so that extensions can also use it + $data['ID'] = $id; + + $members = Member::get()->filter($identifierField, $data[$identifierField]); + if($id) { + $members = $members->exclude('ID', $id); + } + + if($members->count() > 0) { + $this->validationError( + $identifierField, + _t( + 'Member.VALIDATIONMEMBEREXISTS', + 'A member already exists with the same {identifier}', + array('identifier' => Member::singleton()->fieldLabel($identifierField)) + ), + 'required' + ); + $valid = false; + } } - return $valid; - } + // Execute the validators on the extensions + $results = $this->extend('updatePHP', $data, $this->form); + $results[] = $valid; + return min($results); + } } diff --git a/tests/security/MemberTest.php b/tests/security/MemberTest.php index 4a8e66b4c..474dfd89a 100644 --- a/tests/security/MemberTest.php +++ b/tests/security/MemberTest.php @@ -678,7 +678,7 @@ class MemberTest extends FunctionalTest { $newAdminGroup = new Group(array('Title' => 'newadmin')); $newAdminGroup->write(); Permission::grant($newAdminGroup->ID, 'ADMIN'); - + // Test staff member can't be added to admin groups $this->assertFalse($staffMember->inGroup($newAdminGroup)); $staffMember->Groups()->setByIDList(array($newAdminGroup->ID)); @@ -842,7 +842,135 @@ class MemberTest extends FunctionalTest { } } - public function testCustomMemberValidator() { + public function testMemberValidator() + { + // clear custom requirements for this test + Config::inst()->update('Member_Validator', 'customRequired', null); + $memberA = $this->objFromFixture('Member', 'admin'); + $memberB = $this->objFromFixture('Member', 'test'); + + // create a blank form + $form = new MemberTest_ValidatorForm(); + + $validator = new Member_Validator(); + $validator->setForm($form); + + // Simulate creation of a new member via form, but use an existing member identifier + $fail = $validator->php(array( + 'FirstName' => 'Test', + 'Email' => $memberA->Email + )); + + $this->assertFalse( + $fail, + 'Member_Validator must fail when trying to create new Member with existing Email.' + ); + + // populate the form with values from another member + $form->loadDataFrom($memberB); + + // Assign the validator to an existing member + // (this is basically the same as passing the member ID with the form data) + $validator->setForMember($memberB); + + // Simulate update of a member via form and use an existing member Email + $fail = $validator->php(array( + 'FirstName' => 'Test', + 'Email' => $memberA->Email + )); + + // Simulate update to a new Email address + $pass1 = $validator->php(array( + 'FirstName' => 'Test', + 'Email' => 'membervalidatortest@testing.com' + )); + + // Pass in the same Email address that the member already has. Ensure that case is valid + $pass2 = $validator->php(array( + 'FirstName' => 'Test', + 'Surname' => 'User', + 'Email' => $memberB->Email + )); + + $this->assertFalse( + $fail, + 'Member_Validator must fail when trying to update existing member with existing Email.' + ); + + $this->assertTrue( + $pass1, + 'Member_Validator must pass when Email is updated to a value that\'s not in use.' + ); + + $this->assertTrue( + $pass2, + 'Member_Validator must pass when Member updates his own Email to the already existing value.' + ); + } + + public function testMemberValidatorWithExtensions() + { + // clear custom requirements for this test + Config::inst()->update('Member_Validator', 'customRequired', null); + + // create a blank form + $form = new MemberTest_ValidatorForm(); + + // Test extensions + Member_Validator::add_extension('MemberTest_MemberValidator_SurnameMustMatchFirstNameExtension'); + $validator = new Member_Validator(); + $validator->setForm($form); + + // This test should fail, since the extension enforces FirstName == Surname + $fail = $validator->php(array( + 'FirstName' => 'Test', + 'Surname' => 'User', + 'Email' => 'test-member-validator-extension@testing.com' + )); + + $pass = $validator->php(array( + 'FirstName' => 'Test', + 'Surname' => 'Test', + 'Email' => 'test-member-validator-extension@testing.com' + )); + + $this->assertFalse( + $fail, + 'Member_Validator must fail because of added extension.' + ); + + $this->assertTrue( + $pass, + 'Member_Validator must succeed, since it meets all requirements.' + ); + + // Add another extension that always fails. This ensures that all extensions are considered in the validation + Member_Validator::add_extension('MemberTest_MemberValidator_AlwaysFailsExtension'); + $validator = new Member_Validator(); + $validator->setForm($form); + + // Even though the data is valid, This test should still fail, since one extension always returns false + $fail = $validator->php(array( + 'FirstName' => 'Test', + 'Surname' => 'Test', + 'Email' => 'test-member-validator-extension@testing.com' + )); + + $this->assertFalse( + $fail, + 'Member_Validator must fail because of added extensions.' + ); + + // Remove added extensions + Member_Validator::remove_extension('MemberTest_MemberValidator_AlwaysFailsExtension'); + Member_Validator::remove_extension('MemberTest_MemberValidator_SurnameMustMatchFirstNameExtension'); + } + + public function testCustomMemberValidator() + { + // clear custom requirements for this test + Config::inst()->update('Member_Validator', 'customRequired', null); + $member = $this->objFromFixture('Member', 'admin'); $form = new MemberTest_ValidatorForm(); @@ -861,7 +989,7 @@ class MemberTest extends FunctionalTest { 'Surname' => '' )); - $this->assertTrue($pass, 'Validator requires on FirstName and Email'); + $this->assertTrue($pass, 'Validator requires a FirstName and Email'); $this->assertFalse($fail, 'Missing FirstName'); $ext = new MemberTest_ValidatorExtension(); @@ -919,6 +1047,30 @@ class MemberTest_ValidatorExtension extends DataExtension implements TestOnly { } } +/** + * Extension that adds additional validation criteria + * @package framework + * @subpackage tests + */ +class MemberTest_MemberValidator_SurnameMustMatchFirstNameExtension extends DataExtension implements TestOnly +{ + public function updatePHP($data, $form) { + return $data['FirstName'] == $data['Surname']; + } +} + +/** + * Extension that adds additional validation criteria + * @package framework + * @subpackage tests + */ +class MemberTest_MemberValidator_AlwaysFailsExtension extends DataExtension implements TestOnly +{ + public function updatePHP($data, $form) { + return false; + } +} + /** * @package framework * @subpackage tests