'*', ]; public static function setUpBeforeClass(): void { parent::setUpBeforeClass(); //Setting the locale has to happen in the constructor (using the setUp and tearDown methods doesn't work) //This is because the test relies on the yaml file being interpreted according to a particular date format //and this setup occurs before the setUp method is run i18n::config()->set('default_locale', 'en_US'); // Set the default authenticator to use for these tests Injector::inst()->load([ Security::class => [ 'properties' => [ 'Authenticators' => [ 'default' => '%$' . MemberAuthenticator::class, ], ], ], ]); } /** * @skipUpgrade */ protected function setUp(): void { parent::setUp(); Member::config()->set('unique_identifier_field', 'Email'); PasswordValidator::singleton() ->setMinLength(0) ->setTestNames([]); i18n::set_locale('en_US'); } public function testPasswordEncryptionUpdatedOnChangedPassword() { Config::modify()->set(Security::class, 'password_encryption_algorithm', 'none'); $member = Member::create(); $member->Password = 'password'; $member->write(); $this->assertEquals('password', $member->Password); $this->assertEquals('none', $member->PasswordEncryption); Config::modify()->set(Security::class, 'password_encryption_algorithm', 'blowfish'); $member->Password = 'newpassword'; $member->write(); $this->assertNotEquals('password', $member->Password); $this->assertNotEquals('newpassword', $member->Password); $this->assertEquals('blowfish', $member->PasswordEncryption); } public function testWriteDoesntMergeNewRecordWithExistingMember() { $this->expectException(ValidationException::class); $m1 = new Member(); $m1->Email = 'member@test.com'; $m1->write(); $m2 = new Member(); $m2->Email = 'member@test.com'; $m2->write(); } public function testWriteDoesntMergeExistingMemberOnIdentifierChange() { $this->expectException(ValidationException::class); $m1 = new Member(); $m1->Email = 'member@test.com'; $m1->write(); $m2 = new Member(); $m2->Email = 'member_new@test.com'; $m2->write(); $m2->Email = 'member@test.com'; $m2->write(); } public function testDefaultPasswordEncryptionOnMember() { $memberWithPassword = new Member(); $memberWithPassword->Password = 'mypassword'; $memberWithPassword->write(); $this->assertEquals( Security::config()->get('password_encryption_algorithm'), $memberWithPassword->PasswordEncryption, 'Password encryption is set for new member records on first write (with setting "Password")' ); $memberNoPassword = new Member(); $memberNoPassword->write(); $this->assertEquals( Security::config()->get('password_encryption_algorithm'), $memberNoPassword->PasswordEncryption, 'Password encryption is not set for new member records on first write, when not setting a "Password")' ); } public function testKeepsEncryptionOnEmptyPasswords() { $member = new Member(); $member->Password = 'mypassword'; $member->PasswordEncryption = 'sha1_v2.4'; $member->write(); $member->Password = ''; $member->write(); $this->assertEquals( Security::config()->get('password_encryption_algorithm'), $member->PasswordEncryption ); $auth = new MemberAuthenticator(); $result = $auth->checkPassword($member, ''); $this->assertTrue($result->isValid()); } public function testSetPassword() { /** @var Member $member */ $member = $this->objFromFixture(Member::class, 'test'); $member->Password = "test1"; $member->write(); $auth = new MemberAuthenticator(); $result = $auth->checkPassword($member, 'test1'); $this->assertTrue($result->isValid()); } /** * Test that password changes are logged properly */ public function testPasswordChangeLogging() { /** @var Member $member */ $member = $this->objFromFixture(Member::class, 'test'); $this->assertNotNull($member); $member->Password = "test1"; $member->write(); $member->Password = "test2"; $member->write(); $member->Password = "test3"; $member->write(); $passwords = DataObject::get(MemberPassword::class, "\"MemberID\" = $member->ID", "\"Created\" DESC, \"ID\" DESC") ->getIterator(); $this->assertNotNull($passwords); $passwords->rewind(); $this->assertTrue($passwords->current()->checkPassword('test3'), "Password test3 not found in MemberRecord"); $passwords->next(); $this->assertTrue($passwords->current()->checkPassword('test2'), "Password test2 not found in MemberRecord"); $passwords->next(); $this->assertTrue($passwords->current()->checkPassword('test1'), "Password test1 not found in MemberRecord"); $passwords->next(); $this->assertInstanceOf('SilverStripe\\ORM\\DataObject', $passwords->current()); $this->assertTrue( $passwords->current()->checkPassword('1nitialPassword'), "Password 1nitialPassword not found in MemberRecord" ); //check we don't retain orphaned records when a member is deleted $member->delete(); $passwords = MemberPassword::get()->filter('MemberID', $member->OldID); $this->assertCount(0, $passwords); } /** * Test that changed passwords will send an email */ public function testChangedPasswordEmaling() { Member::config()->update('notify_password_change', true); $this->clearEmails(); /** @var Member $member */ $member = $this->objFromFixture(Member::class, 'test'); $this->assertNotNull($member); $valid = $member->changePassword('32asDF##$$%%'); $this->assertTrue($valid->isValid()); $this->assertEmailSent( 'testuser@example.com', null, 'Your password has been changed', '/testuser@example\.com/' ); } /** * Test that triggering "forgotPassword" sends an Email with a reset link */ public function testForgotPasswordEmaling() { $this->clearEmails(); $this->autoFollowRedirection = false; /** @var Member $member */ $member = $this->objFromFixture(Member::class, 'test'); $this->assertNotNull($member); // Initiate a password-reset $response = $this->post('Security/lostpassword/LostPasswordForm', ['Email' => $member->Email]); $this->assertEquals($response->getStatusCode(), 302); // We should get redirected to Security/passwordsent $this->assertStringContainsString( 'Security/lostpassword/passwordsent', urldecode($response->getHeader('Location')) ); // Check existance of reset link $this->assertEmailSent( "testuser@example.com", null, 'Your password reset link', '/Security\/changepassword\?m=' . $member->ID . '&t=[^"]+/' ); } /** * Test that passwords validate against NZ e-government guidelines * - don't allow the use of the last 6 passwords * - require at least 3 of lowercase, uppercase, digits and punctuation * - at least 7 characters long */ public function testValidatePassword() { /** * @var Member $member */ $member = $this->objFromFixture(Member::class, 'test'); $this->assertNotNull($member); Member::set_password_validator(new MemberTest\TestPasswordValidator()); // BAD PASSWORDS $result = $member->changePassword('shorty'); $this->assertFalse($result->isValid()); $this->assertArrayHasKey("TOO_SHORT", $result->getMessages()); $result = $member->changePassword('longone'); $this->assertArrayNotHasKey("TOO_SHORT", $result->getMessages()); $this->assertArrayHasKey("LOW_CHARACTER_STRENGTH", $result->getMessages()); $this->assertFalse($result->isValid()); $result = $member->changePassword('w1thNumb3rs'); $this->assertArrayNotHasKey("LOW_CHARACTER_STRENGTH", $result->getMessages()); $this->assertTrue($result->isValid()); // Clear out the MemberPassword table to ensure that the system functions properly in that situation DB::query("DELETE FROM \"MemberPassword\""); // GOOD PASSWORDS $result = $member->changePassword('withSym###Ls'); $this->assertArrayNotHasKey("LOW_CHARACTER_STRENGTH", $result->getMessages()); $this->assertTrue($result->isValid()); $result = $member->changePassword('withSym###Ls2'); $this->assertTrue($result->isValid()); $result = $member->changePassword('withSym###Ls3'); $this->assertTrue($result->isValid()); $result = $member->changePassword('withSym###Ls4'); $this->assertTrue($result->isValid()); $result = $member->changePassword('withSym###Ls5'); $this->assertTrue($result->isValid()); $result = $member->changePassword('withSym###Ls6'); $this->assertTrue($result->isValid()); $result = $member->changePassword('withSym###Ls7'); $this->assertTrue($result->isValid()); // CAN'T USE PASSWORDS 2-7, but I can use pasword 1 $result = $member->changePassword('withSym###Ls2'); $this->assertFalse($result->isValid()); $this->assertArrayHasKey("PREVIOUS_PASSWORD", $result->getMessages()); $result = $member->changePassword('withSym###Ls5'); $this->assertFalse($result->isValid()); $this->assertArrayHasKey("PREVIOUS_PASSWORD", $result->getMessages()); $result = $member->changePassword('withSym###Ls7'); $this->assertFalse($result->isValid()); $this->assertArrayHasKey("PREVIOUS_PASSWORD", $result->getMessages()); $result = $member->changePassword('withSym###Ls'); $this->assertTrue($result->isValid()); // HAVING DONE THAT, PASSWORD 2 is now available from the list $result = $member->changePassword('withSym###Ls2'); $this->assertTrue($result->isValid()); $result = $member->changePassword('withSym###Ls3'); $this->assertTrue($result->isValid()); $result = $member->changePassword('withSym###Ls4'); $this->assertTrue($result->isValid()); Member::set_password_validator(null); } /** * Test that the PasswordExpiry date is set when passwords are changed */ public function testPasswordExpirySetting() { Member::config()->set('password_expiry_days', 90); /** @var Member $member */ $member = $this->objFromFixture(Member::class, 'test'); $this->assertNotNull($member); $valid = $member->changePassword("Xx?1234234"); $this->assertTrue($valid->isValid()); $expiryDate = date('Y-m-d', time() + 90*86400); $this->assertEquals($expiryDate, $member->PasswordExpiry); Member::config()->set('password_expiry_days', null); $valid = $member->changePassword("Xx?1234235"); $this->assertTrue($valid->isValid()); $this->assertNull($member->PasswordExpiry); } public function testIsPasswordExpired() { /** @var Member $member */ $member = $this->objFromFixture(Member::class, 'test'); $this->assertNotNull($member); $this->assertFalse($member->isPasswordExpired()); $member = $this->objFromFixture(Member::class, 'noexpiry'); $member->PasswordExpiry = null; $this->assertFalse($member->isPasswordExpired()); $member = $this->objFromFixture(Member::class, 'expiredpassword'); $this->assertTrue($member->isPasswordExpired()); // Check the boundary conditions // If PasswordExpiry == today, then it's expired $member->PasswordExpiry = date('Y-m-d'); $this->assertTrue($member->isPasswordExpired()); // If PasswordExpiry == tomorrow, then it's not $member->PasswordExpiry = date('Y-m-d', time() + 86400); $this->assertFalse($member->isPasswordExpired()); } public function testInGroups() { /** @var Member $staffmember */ $staffmember = $this->objFromFixture(Member::class, 'staffmember'); /** @var Member $ceomember */ $ceomember = $this->objFromFixture(Member::class, 'ceomember'); $staffgroup = $this->objFromFixture(Group::class, 'staffgroup'); $managementgroup = $this->objFromFixture(Group::class, 'managementgroup'); $ceogroup = $this->objFromFixture(Group::class, 'ceogroup'); $this->assertTrue( $staffmember->inGroups([$staffgroup, $managementgroup]), 'inGroups() succeeds if a membership is detected on one of many passed groups' ); $this->assertFalse( $staffmember->inGroups([$ceogroup, $managementgroup]), 'inGroups() fails if a membership is detected on none of the passed groups' ); $this->assertFalse( $ceomember->inGroups([$staffgroup, $managementgroup], true), 'inGroups() fails if no direct membership is detected on any of the passed groups (in strict mode)' ); } /** * Assertions to check that Member_GroupSet is functionally equivalent to ManyManyList */ public function testRemoveGroups() { $staffmember = $this->objFromFixture(Member::class, 'staffmember'); $staffgroup = $this->objFromFixture(Group::class, 'staffgroup'); $managementgroup = $this->objFromFixture(Group::class, 'managementgroup'); $this->assertTrue( $staffmember->inGroups([$staffgroup, $managementgroup]), 'inGroups() succeeds if a membership is detected on one of many passed groups' ); $staffmember->Groups()->remove($managementgroup); $this->assertFalse( $staffmember->inGroup($managementgroup), 'member was not removed from group using ->Groups()->remove()' ); $staffmember->Groups()->removeAll(); $this->assertCount( 0, $staffmember->Groups(), 'member was not removed from all groups using ->Groups()->removeAll()' ); } public function testAddToGroupByCode() { /** @var Member $grouplessMember */ $grouplessMember = $this->objFromFixture(Member::class, 'grouplessmember'); /** @var Group $memberlessGroup */ $memberlessGroup = $this->objFromFixture(Group::class, 'memberlessgroup'); $this->assertFalse($grouplessMember->Groups()->exists()); $this->assertFalse($memberlessGroup->Members()->exists()); $grouplessMember->addToGroupByCode('memberless'); $this->assertEquals($memberlessGroup->Members()->count(), 1); $this->assertEquals($grouplessMember->Groups()->count(), 1); $grouplessMember->addToGroupByCode('somegroupthatwouldneverexist', 'New Group'); $this->assertEquals($grouplessMember->Groups()->count(), 2); /** @var Group $group */ $group = DataObject::get_one( Group::class, [ '"Group"."Code"' => 'somegroupthatwouldneverexist' ] ); $this->assertNotNull($group); $this->assertEquals($group->Code, 'somegroupthatwouldneverexist'); $this->assertEquals($group->Title, 'New Group'); } public function testRemoveFromGroupByCode() { /** @var Member $grouplessMember */ $grouplessMember = $this->objFromFixture(Member::class, 'grouplessmember'); /** @var Group $memberlessGroup */ $memberlessGroup = $this->objFromFixture(Group::class, 'memberlessgroup'); $this->assertFalse($grouplessMember->Groups()->exists()); $this->assertFalse($memberlessGroup->Members()->exists()); $grouplessMember->addToGroupByCode('memberless'); $this->assertEquals($memberlessGroup->Members()->count(), 1); $this->assertEquals($grouplessMember->Groups()->count(), 1); $grouplessMember->addToGroupByCode('somegroupthatwouldneverexist', 'New Group'); $this->assertEquals($grouplessMember->Groups()->count(), 2); /** @var Group $group */ $group = DataObject::get_one(Group::class, "\"Code\" = 'somegroupthatwouldneverexist'"); $this->assertNotNull($group); $this->assertEquals($group->Code, 'somegroupthatwouldneverexist'); $this->assertEquals($group->Title, 'New Group'); $grouplessMember->removeFromGroupByCode('memberless'); $this->assertEquals($memberlessGroup->Members()->count(), 0); $this->assertEquals($grouplessMember->Groups()->count(), 1); $grouplessMember->removeFromGroupByCode('somegroupthatwouldneverexist'); $this->assertEquals($grouplessMember->Groups()->count(), 0); } public function testInGroup() { /** @var Member $staffmember */ $staffmember = $this->objFromFixture(Member::class, 'staffmember'); /** @var Member $managementmember */ $managementmember = $this->objFromFixture(Member::class, 'managementmember'); /** @var Member $accountingmember */ $accountingmember = $this->objFromFixture(Member::class, 'accountingmember'); /** @var Member $ceomember */ $ceomember = $this->objFromFixture(Member::class, 'ceomember'); /** @var Group $staffgroup */ $staffgroup = $this->objFromFixture(Group::class, 'staffgroup'); /** @var Group $managementgroup */ $managementgroup = $this->objFromFixture(Group::class, 'managementgroup'); /** @var Group $ceogroup */ $ceogroup = $this->objFromFixture(Group::class, 'ceogroup'); $this->assertTrue( $staffmember->inGroup($staffgroup), 'Direct group membership is detected' ); $this->assertTrue( $managementmember->inGroup($staffgroup), 'Users of child group are members of a direct parent group (if not in strict mode)' ); $this->assertTrue( $accountingmember->inGroup($staffgroup), 'Users of child group are members of a direct parent group (if not in strict mode)' ); $this->assertTrue( $ceomember->inGroup($staffgroup), 'Users of indirect grandchild group are members of a parent group (if not in strict mode)' ); $this->assertTrue( $ceomember->inGroup($ceogroup, true), 'Direct group membership is dected (if in strict mode)' ); $this->assertFalse( $ceomember->inGroup($staffgroup, true), 'Users of child group are not members of a direct parent group (if in strict mode)' ); $this->assertFalse( $staffmember->inGroup($managementgroup), 'Users of parent group are not members of a direct child group' ); $this->assertFalse( $staffmember->inGroup($ceogroup), 'Users of parent group are not members of an indirect grandchild group' ); $this->assertFalse( $accountingmember->inGroup($managementgroup), 'Users of group are not members of any siblings' ); $this->assertFalse( $staffmember->inGroup('does-not-exist'), 'Non-existant group returns false' ); } /** * Tests that the user is able to view their own record, and in turn, they can * edit and delete their own record too. */ public function testCanManipulateOwnRecord() { $member = $this->objFromFixture(Member::class, 'test'); $member2 = $this->objFromFixture(Member::class, 'staffmember'); /* Not logged in, you can't view, delete or edit the record */ $this->assertFalse($member->canView()); $this->assertFalse($member->canDelete()); $this->assertFalse($member->canEdit()); /* Logged in users can edit their own record */ $this->logInAs($member); $this->assertTrue($member->canView()); $this->assertFalse($member->canDelete()); $this->assertTrue($member->canEdit()); /* Other uses cannot view, delete or edit others records */ $this->logInAs($member2); $this->assertFalse($member->canView()); $this->assertFalse($member->canDelete()); $this->assertFalse($member->canEdit()); $this->logOut(); } public function testAuthorisedMembersCanManipulateOthersRecords() { $member = $this->objFromFixture(Member::class, 'test'); $member2 = $this->objFromFixture(Member::class, 'staffmember'); /* Group members with SecurityAdmin permissions can manipulate other records */ $this->logInAs($member); $this->assertTrue($member2->canView()); $this->assertTrue($member2->canDelete()); $this->assertTrue($member2->canEdit()); $this->logOut(); } public function testExtendedCan() { $member = $this->objFromFixture(Member::class, 'test'); /* Normal behaviour is that you can't view a member unless canView() on an extension returns true */ $this->assertFalse($member->canView()); $this->assertFalse($member->canDelete()); $this->assertFalse($member->canEdit()); /* Apply a extension that allows viewing in any case (most likely the case for member profiles) */ Member::add_extension(MemberTest\ViewingAllowedExtension::class); $member2 = $this->objFromFixture(Member::class, 'staffmember'); $this->assertTrue($member2->canView()); $this->assertFalse($member2->canDelete()); $this->assertFalse($member2->canEdit()); /* Apply a extension that denies viewing of the Member */ Member::remove_extension(MemberTest\ViewingAllowedExtension::class); Member::add_extension(MemberTest\ViewingDeniedExtension::class); $member3 = $this->objFromFixture(Member::class, 'managementmember'); $this->assertFalse($member3->canView()); $this->assertFalse($member3->canDelete()); $this->assertFalse($member3->canEdit()); /* Apply a extension that allows viewing and editing but denies deletion */ Member::remove_extension(MemberTest\ViewingDeniedExtension::class); Member::add_extension(MemberTest\EditingAllowedDeletingDeniedExtension::class); $member4 = $this->objFromFixture(Member::class, 'accountingmember'); $this->assertTrue($member4->canView()); $this->assertFalse($member4->canDelete()); $this->assertTrue($member4->canEdit()); Member::remove_extension(MemberTest\EditingAllowedDeletingDeniedExtension::class); } /** * Tests for {@link Member::getName()} and {@link Member::setName()} */ public function testName() { /** @var Member $member */ $member = $this->objFromFixture(Member::class, 'test'); $member->setName('Test Some User'); $this->assertEquals('Test Some User', $member->getName()); $this->assertEquals('Test Some', $member->FirstName); $this->assertEquals('User', $member->Surname); $member->setName('Test'); $this->assertEquals('Test', $member->getName()); $member->FirstName = 'Test'; $member->Surname = ''; $this->assertEquals('Test', $member->FirstName); $this->assertEquals('', $member->Surname); $this->assertEquals('Test', $member->getName()); } public function testMembersWithSecurityAdminAccessCantEditAdminsUnlessTheyreAdminsThemselves() { $adminMember = $this->objFromFixture(Member::class, 'admin'); $otherAdminMember = $this->objFromFixture(Member::class, 'other-admin'); $securityAdminMember = $this->objFromFixture(Member::class, 'test'); $ceoMember = $this->objFromFixture(Member::class, 'ceomember'); // Careful: Don't read as english language. // More precisely this should read canBeEditedBy() $this->assertTrue($adminMember->canEdit($adminMember), 'Admins can edit themselves'); $this->assertTrue($otherAdminMember->canEdit($adminMember), 'Admins can edit other admins'); $this->assertTrue($securityAdminMember->canEdit($adminMember), 'Admins can edit other members'); $this->assertTrue($securityAdminMember->canEdit($securityAdminMember), 'Security-Admins can edit themselves'); $this->assertFalse($adminMember->canEdit($securityAdminMember), 'Security-Admins can not edit other admins'); $this->assertTrue($ceoMember->canEdit($securityAdminMember), 'Security-Admins can edit other members'); } public function testOnChangeGroups() { /** @var Group $staffGroup */ $staffGroup = $this->objFromFixture(Group::class, 'staffgroup'); /** @var Member $staffMember */ $staffMember = $this->objFromFixture(Member::class, 'staffmember'); /** @var Member $adminMember */ $adminMember = $this->objFromFixture(Member::class, 'admin'); // Construct admin and non-admin gruops $newAdminGroup = new Group(['Title' => 'newadmin']); $newAdminGroup->write(); Permission::grant($newAdminGroup->ID, 'ADMIN'); $newOtherGroup = new Group(['Title' => 'othergroup']); $newOtherGroup->write(); $this->assertTrue( $staffMember->onChangeGroups([$staffGroup->ID]), 'Adding existing non-admin group relation is allowed for non-admin members' ); $this->assertTrue( $staffMember->onChangeGroups([$newOtherGroup->ID]), 'Adding new non-admin group relation is allowed for non-admin members' ); $this->assertFalse( $staffMember->onChangeGroups([$newAdminGroup->ID]), 'Adding new admin group relation is not allowed for non-admin members' ); $this->logInAs($adminMember); $this->assertTrue( $staffMember->onChangeGroups([$newAdminGroup->ID]), 'Adding new admin group relation is allowed for normal users, when granter is logged in as admin' ); $this->logOut(); $this->assertTrue( $adminMember->onChangeGroups([$newAdminGroup->ID]), 'Adding new admin group relation is allowed for admin members' ); } /** * Ensure DirectGroups listbox disallows admin-promotion */ public function testAllowedGroupsListbox() { /** @var Group $adminGroup */ $adminGroup = $this->objFromFixture(Group::class, 'admingroup'); /** @var Member $staffMember */ $staffMember = $this->objFromFixture(Member::class, 'staffmember'); /** @var Member $adminMember */ $adminMember = $this->objFromFixture(Member::class, 'admin'); // Ensure you can see the DirectGroups box $this->logInWithPermission('EDIT_PERMISSIONS'); // Non-admin member field contains non-admin groups /** @var ListboxField $staffListbox */ $staffListbox = $staffMember->getCMSFields()->dataFieldByName('DirectGroups'); $this->assertArrayNotHasKey($adminGroup->ID, $staffListbox->getSource()); // admin member field contains admin group /** @var ListboxField $adminListbox */ $adminListbox = $adminMember->getCMSFields()->dataFieldByName('DirectGroups'); $this->assertArrayHasKey($adminGroup->ID, $adminListbox->getSource()); // If logged in as admin, staff listbox has admin group $this->logInWithPermission('ADMIN'); $staffListbox = $staffMember->getCMSFields()->dataFieldByName('DirectGroups'); $this->assertArrayHasKey($adminGroup->ID, $staffListbox->getSource()); } /** * Test Member_GroupSet::add */ public function testOnChangeGroupsByAdd() { /** @var Member $staffMember */ $staffMember = $this->objFromFixture(Member::class, 'staffmember'); /** @var Member $adminMember */ $adminMember = $this->objFromFixture(Member::class, 'admin'); // Setup new admin group $newAdminGroup = new Group(['Title' => 'newadmin']); $newAdminGroup->write(); Permission::grant($newAdminGroup->ID, 'ADMIN'); // Setup non-admin group $newOtherGroup = new Group(['Title' => 'othergroup']); $newOtherGroup->write(); // Test staff can be added to other group $this->assertFalse($staffMember->inGroup($newOtherGroup)); $staffMember->Groups()->add($newOtherGroup); $this->assertTrue( $staffMember->inGroup($newOtherGroup), 'Adding new non-admin group relation is allowed for non-admin members' ); // Test staff member can't be added to admin groups $this->assertFalse($staffMember->inGroup($newAdminGroup)); $staffMember->Groups()->add($newAdminGroup); $this->assertFalse( $staffMember->inGroup($newAdminGroup), 'Adding new admin group relation is not allowed for non-admin members' ); // Test staff member can be added to admin group by admins $this->logInAs($adminMember); $staffMember->Groups()->add($newAdminGroup); $this->assertTrue( $staffMember->inGroup($newAdminGroup), 'Adding new admin group relation is allowed for normal users, when granter is logged in as admin' ); // Test staff member can be added if they are already admin $this->logOut(); $this->assertFalse($adminMember->inGroup($newAdminGroup)); $adminMember->Groups()->add($newAdminGroup); $this->assertTrue( $adminMember->inGroup($newAdminGroup), 'Adding new admin group relation is allowed for admin members' ); } /** * Test Member_GroupSet::add */ public function testOnChangeGroupsBySetIDList() { /** @var Member $staffMember */ $staffMember = $this->objFromFixture(Member::class, 'staffmember'); // Setup new admin group $newAdminGroup = new Group(['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([$newAdminGroup->ID]); $this->assertFalse( $staffMember->inGroup($newAdminGroup), 'Adding new admin group relation is not allowed for non-admin members' ); } /** * Test that extensions using updateCMSFields() are applied correctly */ public function testUpdateCMSFields() { Member::add_extension(FieldsExtension::class); $member = Member::singleton(); $fields = $member->getCMSFields(); /** * @skipUpgrade */ $this->assertNotNull($fields->dataFieldByName('Email'), 'Scaffolded fields are retained'); $this->assertNull($fields->dataFieldByName('Salt'), 'Field modifications run correctly'); $this->assertNotNull($fields->dataFieldByName('TestMemberField'), 'Extension is applied correctly'); Member::remove_extension(FieldsExtension::class); } /** * Test that all members are returned */ public function testMap_in_groupsReturnsAll() { $members = Member::map_in_groups(); $this->assertEquals(13, $members->count(), 'There are 12 members in the mock plus a fake admin'); } /** * Test that only admin members are returned */ public function testMap_in_groupsReturnsAdmins() { $adminID = $this->objFromFixture(Group::class, 'admingroup')->ID; $members = Member::map_in_groups($adminID)->toArray(); $admin = $this->objFromFixture(Member::class, 'admin'); $otherAdmin = $this->objFromFixture(Member::class, 'other-admin'); $this->assertTrue( in_array($admin->getTitle(), $members), $admin->getTitle() . ' should be in the returned list.' ); $this->assertTrue( in_array($otherAdmin->getTitle(), $members), $otherAdmin->getTitle() . ' should be in the returned list.' ); $this->assertEquals(2, count($members), 'There should be 2 members from the admin group'); } /** * Add the given array of member extensions as class names. * This is useful for re-adding extensions after being removed * in a test case to produce an unbiased test. * * @param array $extensions * @return array The added extensions */ protected function addExtensions($extensions) { if ($extensions) { foreach ($extensions as $extension) { Member::add_extension($extension); } } return $extensions; } /** * Remove given extensions from Member. This is useful for * removing extensions that could produce a biased * test result, as some extensions applied by project * code or modules can do this. * * @param array $extensions * @return array The removed extensions */ protected function removeExtensions($extensions) { if ($extensions) { foreach ($extensions as $extension) { Member::remove_extension($extension); } } return $extensions; } public function testGenerateAutologinTokenAndStoreHash() { $m = new Member(); $m->write(); $token = $m->generateAutologinTokenAndStoreHash(); $this->assertEquals($m->encryptWithUserSettings($token), $m->AutoLoginHash, 'Stores the token as a hash.'); } public function testValidateAutoLoginToken() { $enc = new PasswordEncryptor_Blowfish(); $m1 = new Member(); $m1->write(); $m1Token = $m1->generateAutologinTokenAndStoreHash(); $m2 = new Member(); $m2->write(); $m2->generateAutologinTokenAndStoreHash(); $this->assertTrue($m1->validateAutoLoginToken($m1Token), 'Passes token validity test against matching member.'); $this->assertFalse($m2->validateAutoLoginToken($m1Token), 'Fails token validity test against other member.'); } public function testRememberMeHashGeneration() { /** @var Member $m1 */ $m1 = $this->objFromFixture(Member::class, 'grouplessmember'); Injector::inst()->get(IdentityStore::class)->logIn($m1, true); $hashes = RememberLoginHash::get()->filter('MemberID', $m1->ID); $this->assertEquals($hashes->count(), 1); /** @var RememberLoginHash $firstHash */ $firstHash = $hashes->first(); $this->assertNotNull($firstHash->DeviceID); $this->assertNotNull($firstHash->Hash); } public function testRememberMeHashAutologin() { /** @var Member $m1 */ $m1 = $this->objFromFixture(Member::class, 'noexpiry'); Injector::inst()->get(IdentityStore::class)->logIn($m1, true); /** @var RememberLoginHash $firstHash */ $firstHash = RememberLoginHash::get()->filter('MemberID', $m1->ID)->first(); $this->assertNotNull($firstHash); // re-generates the hash so we can get the token $firstHash->Hash = $firstHash->getNewHash($m1); $token = $firstHash->getToken(); $firstHash->write(); $response = $this->get( 'Security/login', $this->session(), null, [ 'alc_enc' => $m1->ID . ':' . $token, 'alc_device' => $firstHash->DeviceID ] ); $message = Convert::raw2xml( _t( 'SilverStripe\\Security\\Member.LOGGEDINAS', "You're logged in as {name}.", ['name' => $m1->FirstName] ) ); $this->assertStringContainsString($message, $response->getBody()); $this->logOut(); // A wrong token or a wrong device ID should not let us autologin $response = $this->get( 'Security/login', $this->session(), null, [ 'alc_enc' => $m1->ID . ':asdfasd' . str_rot13($token), 'alc_device' => $firstHash->DeviceID ] ); $this->assertStringNotContainsString($message, $response->getBody()); $response = $this->get( 'Security/login', $this->session(), null, [ 'alc_enc' => $m1->ID . ':' . $token, 'alc_device' => str_rot13($firstHash->DeviceID) ] ); $this->assertStringNotContainsString($message, $response->getBody()); // Re-logging (ie 'alc_enc' has expired), and not checking the "Remember Me" option // should remove all previous hashes for this device $response = $this->post( 'Security/login/default/LoginForm', [ 'Email' => $m1->Email, 'Password' => '1nitialPassword', 'action_doLogin' => 'action_doLogin' ], null, $this->session(), null, [ 'alc_device' => $firstHash->DeviceID ] ); $this->assertStringContainsString($message, $response->getBody()); $this->assertEquals(RememberLoginHash::get()->filter('MemberID', $m1->ID)->count(), 0); } public function testExpiredRememberMeHashAutologin() { /** @var Member $m1 */ $m1 = $this->objFromFixture(Member::class, 'noexpiry'); Injector::inst()->get(IdentityStore::class)->logIn($m1, true); /** @var RememberLoginHash $firstHash */ $firstHash = RememberLoginHash::get()->filter('MemberID', $m1->ID)->first(); $this->assertNotNull($firstHash); // re-generates the hash so we can get the token $firstHash->Hash = $firstHash->getNewHash($m1); $token = $firstHash->getToken(); $firstHash->ExpiryDate = '2000-01-01 00:00:00'; $firstHash->write(); DBDatetime::set_mock_now('1999-12-31 23:59:59'); $response = $this->get( 'Security/login', $this->session(), null, [ 'alc_enc' => $m1->ID . ':' . $token, 'alc_device' => $firstHash->DeviceID ] ); $message = Convert::raw2xml( _t( 'SilverStripe\\Security\\Member.LOGGEDINAS', "You're logged in as {name}.", ['name' => $m1->FirstName] ) ); $this->assertStringContainsString($message, $response->getBody()); $this->logOut(); // re-generates the hash so we can get the token $firstHash->Hash = $firstHash->getNewHash($m1); $token = $firstHash->getToken(); $firstHash->ExpiryDate = '2000-01-01 00:00:00'; $firstHash->write(); DBDatetime::set_mock_now('2000-01-01 00:00:01'); $response = $this->get( 'Security/login', $this->session(), null, [ 'alc_enc' => $m1->ID . ':' . $token, 'alc_device' => $firstHash->DeviceID ] ); $this->assertStringNotContainsString($message, $response->getBody()); $this->logOut(); DBDatetime::clear_mock_now(); } public function testRememberMeMultipleDevices() { /** @var Member $m1 */ $m1 = $this->objFromFixture(Member::class, 'noexpiry'); // First device Injector::inst()->get(IdentityStore::class)->logIn($m1, true); Cookie::set('alc_device', null); // Second device Injector::inst()->get(IdentityStore::class)->logIn($m1, true); // Hash of first device /** @var RememberLoginHash $firstHash */ $firstHash = RememberLoginHash::get()->filter('MemberID', $m1->ID)->first(); $this->assertNotNull($firstHash); // Hash of second device /** @var RememberLoginHash $secondHash */ $secondHash = RememberLoginHash::get()->filter('MemberID', $m1->ID)->last(); $this->assertNotNull($secondHash); // DeviceIDs are different $this->assertNotEquals($firstHash->DeviceID, $secondHash->DeviceID); // re-generates the hashes so we can get the tokens $firstHash->Hash = $firstHash->getNewHash($m1); $firstToken = $firstHash->getToken(); $firstHash->write(); $secondHash->Hash = $secondHash->getNewHash($m1); $secondToken = $secondHash->getToken(); $secondHash->write(); // Accessing the login page should show the user's name straight away $response = $this->get( 'Security/login', $this->session(), null, [ 'alc_enc' => $m1->ID . ':' . $firstToken, 'alc_device' => $firstHash->DeviceID ] ); $message = Convert::raw2xml( _t( 'SilverStripe\\Security\\Member.LOGGEDINAS', "You're logged in as {name}.", ['name' => $m1->FirstName] ) ); $this->assertStringContainsString($message, $response->getBody()); // Test that removing session but not cookie keeps user /** @var SessionAuthenticationHandler $sessionHandler */ $sessionHandler = Injector::inst()->get(SessionAuthenticationHandler::class); $sessionHandler->logOut(); Security::setCurrentUser(null); // Accessing the login page from the second device $response = $this->get( 'Security/login', $this->session(), null, [ 'alc_enc' => $m1->ID . ':' . $secondToken, 'alc_device' => $secondHash->DeviceID ] ); $this->assertStringContainsString($message, $response->getBody()); // Logging out from the second device - only one device being logged out RememberLoginHash::config()->update('logout_across_devices', false); $this->get( 'Security/logout', $this->session(), null, [ 'alc_enc' => $m1->ID . ':' . $secondToken, 'alc_device' => $secondHash->DeviceID ] ); $this->assertEquals( 1, RememberLoginHash::get()->filter(['MemberID'=>$m1->ID, 'DeviceID'=>$firstHash->DeviceID])->count() ); // If session-manager module is installed then logout_across_devices is modified so skip if (!class_exists(LoginSession::class)) { // Logging out from any device when all login hashes should be removed RememberLoginHash::config()->update('logout_across_devices', true); Injector::inst()->get(IdentityStore::class)->logIn($m1, true); $this->get('Security/logout', $this->session()); $this->assertEquals( 0, RememberLoginHash::get()->filter('MemberID', $m1->ID)->count() ); } } public function testCanDelete() { $admin1 = $this->objFromFixture(Member::class, 'admin'); $admin2 = $this->objFromFixture(Member::class, 'other-admin'); $member1 = $this->objFromFixture(Member::class, 'grouplessmember'); $member2 = $this->objFromFixture(Member::class, 'noformatmember'); $this->assertTrue( $admin1->canDelete($admin2), 'Admins can delete other admins' ); $this->assertTrue( $member1->canDelete($admin2), 'Admins can delete non-admins' ); $this->assertFalse( $admin1->canDelete($admin1), 'Admins can not delete themselves' ); $this->assertFalse( $member1->canDelete($member2), 'Non-admins can not delete other non-admins' ); $this->assertFalse( $member1->canDelete($member1), 'Non-admins can not delete themselves' ); } public function testFailedLoginCount() { $maxFailedLoginsAllowed = 3; //set up the config variables to enable login lockouts Member::config()->update('lock_out_after_incorrect_logins', $maxFailedLoginsAllowed); /** @var Member $member */ $member = $this->objFromFixture(Member::class, 'test'); $failedLoginCount = $member->FailedLoginCount; for ($i = 1; $i < $maxFailedLoginsAllowed; ++$i) { $member->registerFailedLogin(); $this->assertEquals( ++$failedLoginCount, $member->FailedLoginCount, 'Failed to increment $member->FailedLoginCount' ); $this->assertTrue( $member->canLogin(), "Member has been locked out too early" ); } } public function testMemberValidator() { // clear custom requirements for this test Member_Validator::config()->update('customRequired', null); /** @var Member $memberA */ $memberA = $this->objFromFixture(Member::class, 'admin'); /** @var Member $memberB */ $memberB = $this->objFromFixture(Member::class, '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( [ '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( [ 'FirstName' => 'Test', 'Email' => $memberA->Email ] ); // Simulate update to a new Email address $pass1 = $validator->php( [ '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( [ '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 Member_Validator::config()->update('customRequired', null); // create a blank form $form = new MemberTest\ValidatorForm(); // Test extensions Member_Validator::add_extension(MemberTest\SurnameMustMatchFirstNameExtension::class); $validator = new Member_Validator(); $validator->setForm($form); // This test should fail, since the extension enforces FirstName == Surname $fail = $validator->php( [ 'FirstName' => 'Test', 'Surname' => 'User', 'Email' => 'test-member-validator-extension@testing.com' ] ); $pass = $validator->php( [ '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\AlwaysFailExtension::class); $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( [ '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\AlwaysFailExtension::class); Member_Validator::remove_extension(MemberTest\SurnameMustMatchFirstNameExtension::class); } public function testCustomMemberValidator() { // clear custom requirements for this test Member_Validator::config()->update('customRequired', null); $member = $this->objFromFixture(Member::class, 'admin'); $form = new MemberTest\ValidatorForm(); $form->loadDataFrom($member); $validator = new Member_Validator(); $validator->setForm($form); $pass = $validator->php( [ 'FirstName' => 'Borris', 'Email' => 'borris@silverstripe.com' ] ); $fail = $validator->php( [ 'Email' => 'borris@silverstripe.com', 'Surname' => '' ] ); $this->assertTrue($pass, 'Validator requires a FirstName and Email'); $this->assertFalse($fail, 'Missing FirstName'); $ext = new MemberTest\ValidatorExtension(); $ext->updateValidator($validator); $pass = $validator->php( [ 'FirstName' => 'Borris', 'Email' => 'borris@silverstripe.com' ] ); $fail = $validator->php( [ 'Email' => 'borris@silverstripe.com' ] ); $this->assertFalse($pass, 'Missing surname'); $this->assertFalse($fail, 'Missing surname value'); $fail = $validator->php( [ 'Email' => 'borris@silverstripe.com', 'Surname' => 'Silverman' ] ); $this->assertTrue($fail, 'Passes with email and surname now (no firstname)'); } public function testCurrentUser() { $this->assertNull(Security::getCurrentUser()); $adminMember = $this->objFromFixture(Member::class, 'admin'); $this->logInAs($adminMember); $userFromSession = Security::getCurrentUser(); $this->assertEquals($adminMember->ID, $userFromSession->ID); } /** * @covers \SilverStripe\Security\Member::actAs() */ public function testActAsUserPermissions() { $this->assertNull(Security::getCurrentUser()); /** @var Member $adminMember */ $adminMember = $this->objFromFixture(Member::class, 'admin'); // Check acting as admin when not logged in $checkAdmin = Member::actAs($adminMember, function () { return Permission::check('ADMIN'); }); $this->assertTrue($checkAdmin); // Check nesting $checkAdmin = Member::actAs($adminMember, function () { return Member::actAs(null, function () { return Permission::check('ADMIN'); }); }); $this->assertFalse($checkAdmin); // Check logging in as non-admin user $this->logInWithPermission('TEST_PERMISSION'); $hasPerm = Member::actAs(null, function () { return Permission::check('TEST_PERMISSION'); }); $this->assertFalse($hasPerm); // Check permissions can be promoted $checkAdmin = Member::actAs($adminMember, function () { return Permission::check('ADMIN'); }); $this->assertTrue($checkAdmin); } /** * @covers \SilverStripe\Security\Member::actAs() */ public function testActAsUser() { $this->assertNull(Security::getCurrentUser()); /** @var Member $adminMember */ $adminMember = $this->objFromFixture(Member::class, 'admin'); $member = Member::actAs($adminMember, function () { return Security::getCurrentUser(); }); $this->assertEquals($adminMember->ID, $member->ID); // Check nesting $member = Member::actAs($adminMember, function () { return Member::actAs(null, function () { return Security::getCurrentUser(); }); }); $this->assertEmpty($member); } public function testChangePasswordWithExtensionsThatModifyValidationResult() { // Default behaviour /** @var Member $member */ $member = $this->objFromFixture(Member::class, 'admin'); $result = $member->changePassword('my-secret-new-password'); $this->assertInstanceOf(ValidationResult::class, $result); $this->assertTrue($result->isValid()); // With an extension added Member::add_extension(MemberTest\ExtendedChangePasswordExtension::class); $member = $this->objFromFixture(Member::class, 'admin'); $result = $member->changePassword('my-second-secret-password'); $this->assertInstanceOf(ValidationResult::class, $result); $this->assertFalse($result->isValid()); } public function testNewMembersReceiveTheDefaultLocale() { // Set a different current locale to the default i18n::set_locale('de_DE'); $newMember = Member::create(); $newMember->update([ 'FirstName' => 'Leslie', 'Surname' => 'Longly', 'Email' => 'longly.leslie@example.com', ]); $newMember->write(); $this->assertSame('en_US', $newMember->Locale, 'New members receive the default locale'); } public function testChangePasswordOnlyValidatesPlaintext() { // This validator requires passwords to be 17 characters long Member::set_password_validator(new MemberTest\VerySpecificPasswordValidator()); // This algorithm will never return a 17 character hash Security::config()->set('password_encryption_algorithm', 'blowfish'); /** @var Member $member */ $member = $this->objFromFixture(Member::class, 'test'); $result = $member->changePassword('Password123456789'); // 17 characters long $this->assertTrue($result->isValid()); } public function testGetLastName() { $member = new Member(); $member->Surname = 'Johnson'; $this->assertSame('Johnson', $member->getLastName(), 'getLastName should proxy to Surname'); } public function testEmailIsTrimmed() { $member = new Member(); $member->Email = " trimmed@test.com\r\n"; $member->write(); $this->assertNotNull(Member::get()->find('Email', 'trimmed@test.com')); } public function testChangePasswordToBlankIsValidated() { Member::set_password_validator(new PasswordValidator()); // override setup() function which setMinLength(0) PasswordValidator::singleton()->setMinLength(8); // 'test' member has a password defined in yml $member = $this->objFromFixture(Member::class, 'test'); $result = $member->changePassword(''); $this->assertFalse($result->isValid()); } }