Passed
Push — 4 ( c18a5f...debf1a )
by Garion
08:38 queued 11s
created

testChangePasswordOnlyValidatesPlaintext()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 12
Code Lines 5

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 5
nc 1
nop 0
dl 0
loc 12
rs 10
c 0
b 0
f 0
1
<?php
2
3
namespace SilverStripe\Security\Tests;
4
5
use SilverStripe\Control\Cookie;
6
use SilverStripe\Core\Config\Config;
7
use SilverStripe\Core\Convert;
8
use SilverStripe\Core\Injector\Injector;
9
use SilverStripe\Dev\FunctionalTest;
10
use SilverStripe\Forms\ListboxField;
11
use SilverStripe\i18n\i18n;
12
use SilverStripe\ORM\DataObject;
13
use SilverStripe\ORM\DB;
14
use SilverStripe\ORM\FieldType\DBDatetime;
15
use SilverStripe\ORM\ValidationException;
16
use SilverStripe\ORM\ValidationResult;
17
use SilverStripe\Security\Group;
18
use SilverStripe\Security\IdentityStore;
19
use SilverStripe\Security\Member;
20
use SilverStripe\Security\Member_Validator;
21
use SilverStripe\Security\MemberAuthenticator\MemberAuthenticator;
22
use SilverStripe\Security\MemberAuthenticator\SessionAuthenticationHandler;
23
use SilverStripe\Security\MemberPassword;
24
use SilverStripe\Security\PasswordEncryptor_Blowfish;
25
use SilverStripe\Security\PasswordValidator;
26
use SilverStripe\Security\Permission;
27
use SilverStripe\Security\RememberLoginHash;
28
use SilverStripe\Security\Security;
29
use SilverStripe\Security\Tests\MemberTest\FieldsExtension;
30
31
class MemberTest extends FunctionalTest
32
{
33
    protected static $fixture_file = 'MemberTest.yml';
34
35
    protected $orig = [];
36
37
    protected static $illegal_extensions = [
38
        Member::class => '*',
39
    ];
40
41
    public static function setUpBeforeClass()
42
    {
43
        parent::setUpBeforeClass();
44
45
        //Setting the locale has to happen in the constructor (using the setUp and tearDown methods doesn't work)
46
        //This is because the test relies on the yaml file being interpreted according to a particular date format
47
        //and this setup occurs before the setUp method is run
48
        i18n::config()->set('default_locale', 'en_US');
49
50
        // Set the default authenticator to use for these tests
51
        Injector::inst()->load([
52
            Security::class => [
53
                'properties' => [
54
                    'Authenticators' => [
55
                        'default' => '%$' . MemberAuthenticator::class,
56
                    ],
57
                ],
58
            ],
59
        ]);
60
    }
61
62
    /**
63
     * @skipUpgrade
64
     */
65
    protected function setUp()
66
    {
67
        parent::setUp();
68
69
        Member::config()->set('unique_identifier_field', 'Email');
70
71
        PasswordValidator::singleton()
72
            ->setMinLength(0)
73
            ->setTestNames([]);
74
75
        i18n::set_locale('en_US');
76
    }
77
78
    public function testPasswordEncryptionUpdatedOnChangedPassword()
79
    {
80
        Config::modify()->set(Security::class, 'password_encryption_algorithm', 'none');
81
        $member = Member::create();
82
        $member->Password = 'password';
83
        $member->write();
84
        $this->assertEquals('password', $member->Password);
85
        $this->assertEquals('none', $member->PasswordEncryption);
86
        Config::modify()->set(Security::class, 'password_encryption_algorithm', 'blowfish');
87
        $member->Password = 'newpassword';
88
        $member->write();
89
        $this->assertNotEquals('password', $member->Password);
90
        $this->assertNotEquals('newpassword', $member->Password);
91
        $this->assertEquals('blowfish', $member->PasswordEncryption);
92
    }
93
94
    public function testWriteDoesntMergeNewRecordWithExistingMember()
95
    {
96
        $this->expectException(ValidationException::class);
97
        $m1 = new Member();
98
        $m1->Email = '[email protected]';
99
        $m1->write();
100
101
        $m2 = new Member();
102
        $m2->Email = '[email protected]';
103
        $m2->write();
104
    }
105
106
    /**
107
     * @expectedException \SilverStripe\ORM\ValidationException
108
     */
109
    public function testWriteDoesntMergeExistingMemberOnIdentifierChange()
110
    {
111
        $m1 = new Member();
112
        $m1->Email = '[email protected]';
113
        $m1->write();
114
115
        $m2 = new Member();
116
        $m2->Email = '[email protected]';
117
        $m2->write();
118
119
        $m2->Email = '[email protected]';
120
        $m2->write();
121
    }
122
123
    public function testDefaultPasswordEncryptionOnMember()
124
    {
125
        $memberWithPassword = new Member();
126
        $memberWithPassword->Password = 'mypassword';
127
        $memberWithPassword->write();
128
        $this->assertEquals(
129
            Security::config()->get('password_encryption_algorithm'),
130
            $memberWithPassword->PasswordEncryption,
131
            'Password encryption is set for new member records on first write (with setting "Password")'
132
        );
133
134
        $memberNoPassword = new Member();
135
        $memberNoPassword->write();
136
        $this->assertEquals(
137
            Security::config()->get('password_encryption_algorithm'),
138
            $memberNoPassword->PasswordEncryption,
139
            'Password encryption is not set for new member records on first write, when not setting a "Password")'
140
        );
141
    }
142
143
    public function testKeepsEncryptionOnEmptyPasswords()
144
    {
145
        $member = new Member();
146
        $member->Password = 'mypassword';
147
        $member->PasswordEncryption = 'sha1_v2.4';
148
        $member->write();
149
150
        $member->Password = '';
151
        $member->write();
152
153
        $this->assertEquals(
154
            Security::config()->get('password_encryption_algorithm'),
155
            $member->PasswordEncryption
156
        );
157
        $auth = new MemberAuthenticator();
158
        $result = $auth->checkPassword($member, '');
159
        $this->assertTrue($result->isValid());
160
    }
161
162
    public function testSetPassword()
163
    {
164
        /** @var Member $member */
165
        $member = $this->objFromFixture(Member::class, 'test');
166
        $member->Password = "test1";
167
        $member->write();
168
        $auth = new MemberAuthenticator();
169
        $result = $auth->checkPassword($member, 'test1');
170
        $this->assertTrue($result->isValid());
171
    }
172
173
    /**
174
     * Test that password changes are logged properly
175
     */
176
    public function testPasswordChangeLogging()
177
    {
178
        /** @var Member $member */
179
        $member = $this->objFromFixture(Member::class, 'test');
180
        $this->assertNotNull($member);
181
        $member->Password = "test1";
182
        $member->write();
183
184
        $member->Password = "test2";
185
        $member->write();
186
187
        $member->Password = "test3";
188
        $member->write();
189
190
        $passwords = DataObject::get(MemberPassword::class, "\"MemberID\" = $member->ID", "\"Created\" DESC, \"ID\" DESC")
191
            ->getIterator();
192
        $this->assertNotNull($passwords);
193
        $passwords->rewind();
194
        $this->assertTrue($passwords->current()->checkPassword('test3'), "Password test3 not found in MemberRecord");
195
196
        $passwords->next();
197
        $this->assertTrue($passwords->current()->checkPassword('test2'), "Password test2 not found in MemberRecord");
198
199
        $passwords->next();
200
        $this->assertTrue($passwords->current()->checkPassword('test1'), "Password test1 not found in MemberRecord");
201
202
        $passwords->next();
203
        $this->assertInstanceOf('SilverStripe\\ORM\\DataObject', $passwords->current());
204
        $this->assertTrue(
205
            $passwords->current()->checkPassword('1nitialPassword'),
206
            "Password 1nitialPassword not found in MemberRecord"
207
        );
208
209
        //check we don't retain orphaned records when a member is deleted
210
        $member->delete();
211
212
        $passwords = MemberPassword::get()->filter('MemberID', $member->OldID);
213
214
        $this->assertCount(0, $passwords);
215
    }
216
217
    /**
218
     * Test that changed passwords will send an email
219
     */
220
    public function testChangedPasswordEmaling()
221
    {
222
        Member::config()->update('notify_password_change', true);
223
224
        $this->clearEmails();
225
226
        /** @var Member $member */
227
        $member = $this->objFromFixture(Member::class, 'test');
228
        $this->assertNotNull($member);
229
        $valid = $member->changePassword('32asDF##$$%%');
230
        $this->assertTrue($valid->isValid());
231
232
        $this->assertEmailSent(
233
            '[email protected]',
234
            null,
235
            'Your password has been changed',
236
            '/testuser@example\.com/'
237
        );
238
    }
239
240
    /**
241
     * Test that triggering "forgotPassword" sends an Email with a reset link
242
        */
243
    public function testForgotPasswordEmaling()
244
    {
245
        $this->clearEmails();
246
        $this->autoFollowRedirection = false;
247
248
        /** @var Member $member */
249
        $member = $this->objFromFixture(Member::class, 'test');
250
        $this->assertNotNull($member);
251
252
        // Initiate a password-reset
253
        $response = $this->post('Security/lostpassword/LostPasswordForm', ['Email' => $member->Email]);
254
255
        $this->assertEquals($response->getStatusCode(), 302);
256
257
        // We should get redirected to Security/passwordsent
258
        $this->assertContains(
259
            'Security/lostpassword/passwordsent',
260
            urldecode($response->getHeader('Location'))
261
        );
262
263
        // Check existance of reset link
264
        $this->assertEmailSent(
265
            "[email protected]",
266
            null,
267
            'Your password reset link',
268
            '/Security\/changepassword\?m=' . $member->ID . '&amp;t=[^"]+/'
269
        );
270
    }
271
272
    /**
273
     * Test that passwords validate against NZ e-government guidelines
274
     *  - don't allow the use of the last 6 passwords
275
     *  - require at least 3 of lowercase, uppercase, digits and punctuation
276
     *  - at least 7 characters long
277
     */
278
    public function testValidatePassword()
279
    {
280
        /**
281
         * @var Member $member
282
         */
283
        $member = $this->objFromFixture(Member::class, 'test');
284
        $this->assertNotNull($member);
285
286
        Member::set_password_validator(new MemberTest\TestPasswordValidator());
287
288
        // BAD PASSWORDS
289
290
        $result = $member->changePassword('shorty');
291
        $this->assertFalse($result->isValid());
292
        $this->assertArrayHasKey("TOO_SHORT", $result->getMessages());
293
294
        $result = $member->changePassword('longone');
295
        $this->assertArrayNotHasKey("TOO_SHORT", $result->getMessages());
296
        $this->assertArrayHasKey("LOW_CHARACTER_STRENGTH", $result->getMessages());
297
        $this->assertFalse($result->isValid());
298
299
        $result = $member->changePassword('w1thNumb3rs');
300
        $this->assertArrayNotHasKey("LOW_CHARACTER_STRENGTH", $result->getMessages());
301
        $this->assertTrue($result->isValid());
302
303
        // Clear out the MemberPassword table to ensure that the system functions properly in that situation
304
        DB::query("DELETE FROM \"MemberPassword\"");
305
306
        // GOOD PASSWORDS
307
308
        $result = $member->changePassword('withSym###Ls');
309
        $this->assertArrayNotHasKey("LOW_CHARACTER_STRENGTH", $result->getMessages());
310
        $this->assertTrue($result->isValid());
311
312
        $result = $member->changePassword('withSym###Ls2');
313
        $this->assertTrue($result->isValid());
314
315
        $result = $member->changePassword('withSym###Ls3');
316
        $this->assertTrue($result->isValid());
317
318
        $result = $member->changePassword('withSym###Ls4');
319
        $this->assertTrue($result->isValid());
320
321
        $result = $member->changePassword('withSym###Ls5');
322
        $this->assertTrue($result->isValid());
323
324
        $result = $member->changePassword('withSym###Ls6');
325
        $this->assertTrue($result->isValid());
326
327
        $result = $member->changePassword('withSym###Ls7');
328
        $this->assertTrue($result->isValid());
329
330
        // CAN'T USE PASSWORDS 2-7, but I can use pasword 1
331
332
        $result = $member->changePassword('withSym###Ls2');
333
        $this->assertFalse($result->isValid());
334
        $this->assertArrayHasKey("PREVIOUS_PASSWORD", $result->getMessages());
335
336
        $result = $member->changePassword('withSym###Ls5');
337
        $this->assertFalse($result->isValid());
338
        $this->assertArrayHasKey("PREVIOUS_PASSWORD", $result->getMessages());
339
340
        $result = $member->changePassword('withSym###Ls7');
341
        $this->assertFalse($result->isValid());
342
        $this->assertArrayHasKey("PREVIOUS_PASSWORD", $result->getMessages());
343
344
        $result = $member->changePassword('withSym###Ls');
345
        $this->assertTrue($result->isValid());
346
347
        // HAVING DONE THAT, PASSWORD 2 is now available from the list
348
349
        $result = $member->changePassword('withSym###Ls2');
350
        $this->assertTrue($result->isValid());
351
352
        $result = $member->changePassword('withSym###Ls3');
353
        $this->assertTrue($result->isValid());
354
355
        $result = $member->changePassword('withSym###Ls4');
356
        $this->assertTrue($result->isValid());
357
358
        Member::set_password_validator(null);
359
    }
360
361
    /**
362
     * Test that the PasswordExpiry date is set when passwords are changed
363
     */
364
    public function testPasswordExpirySetting()
365
    {
366
        Member::config()->set('password_expiry_days', 90);
367
368
        /** @var Member $member */
369
        $member = $this->objFromFixture(Member::class, 'test');
370
        $this->assertNotNull($member);
371
        $valid = $member->changePassword("Xx?1234234");
372
        $this->assertTrue($valid->isValid());
373
374
        $expiryDate = date('Y-m-d', time() + 90*86400);
375
        $this->assertEquals($expiryDate, $member->PasswordExpiry);
376
377
        Member::config()->set('password_expiry_days', null);
378
        $valid = $member->changePassword("Xx?1234235");
379
        $this->assertTrue($valid->isValid());
380
381
        $this->assertNull($member->PasswordExpiry);
382
    }
383
384
    public function testIsPasswordExpired()
385
    {
386
        /** @var Member $member */
387
        $member = $this->objFromFixture(Member::class, 'test');
388
        $this->assertNotNull($member);
389
        $this->assertFalse($member->isPasswordExpired());
390
391
        $member = $this->objFromFixture(Member::class, 'noexpiry');
392
        $member->PasswordExpiry = null;
0 ignored issues
show
Bug Best Practice introduced by
The property PasswordExpiry does not exist on SilverStripe\ORM\DataObject. Since you implemented __set, consider adding a @property annotation.
Loading history...
393
        $this->assertFalse($member->isPasswordExpired());
0 ignored issues
show
Bug introduced by
The method isPasswordExpired() does not exist on SilverStripe\ORM\DataObject. Since you implemented __call, consider adding a @method annotation. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

393
        $this->assertFalse($member->/** @scrutinizer ignore-call */ isPasswordExpired());
Loading history...
394
395
        $member = $this->objFromFixture(Member::class, 'expiredpassword');
396
        $this->assertTrue($member->isPasswordExpired());
397
398
        // Check the boundary conditions
399
        // If PasswordExpiry == today, then it's expired
400
        $member->PasswordExpiry = date('Y-m-d');
401
        $this->assertTrue($member->isPasswordExpired());
402
403
        // If PasswordExpiry == tomorrow, then it's not
404
        $member->PasswordExpiry = date('Y-m-d', time() + 86400);
405
        $this->assertFalse($member->isPasswordExpired());
406
    }
407
408
    public function testInGroups()
409
    {
410
        /** @var Member $staffmember */
411
        $staffmember = $this->objFromFixture(Member::class, 'staffmember');
412
        /** @var Member $ceomember */
413
        $ceomember = $this->objFromFixture(Member::class, 'ceomember');
414
415
        $staffgroup = $this->objFromFixture(Group::class, 'staffgroup');
416
        $managementgroup = $this->objFromFixture(Group::class, 'managementgroup');
417
        $ceogroup = $this->objFromFixture(Group::class, 'ceogroup');
418
419
        $this->assertTrue(
420
            $staffmember->inGroups([$staffgroup, $managementgroup]),
421
            'inGroups() succeeds if a membership is detected on one of many passed groups'
422
        );
423
        $this->assertFalse(
424
            $staffmember->inGroups([$ceogroup, $managementgroup]),
425
            'inGroups() fails if a membership is detected on none of the passed groups'
426
        );
427
        $this->assertFalse(
428
            $ceomember->inGroups([$staffgroup, $managementgroup], true),
429
            'inGroups() fails if no direct membership is detected on any of the passed groups (in strict mode)'
430
        );
431
    }
432
433
    /**
434
     * Assertions to check that Member_GroupSet is functionally equivalent to ManyManyList
435
     */
436
    public function testRemoveGroups()
437
    {
438
        $staffmember = $this->objFromFixture(Member::class, 'staffmember');
439
440
        $staffgroup = $this->objFromFixture(Group::class, 'staffgroup');
441
        $managementgroup = $this->objFromFixture(Group::class, 'managementgroup');
442
443
        $this->assertTrue(
444
            $staffmember->inGroups([$staffgroup, $managementgroup]),
0 ignored issues
show
Bug introduced by
The method inGroups() does not exist on SilverStripe\ORM\DataObject. Since you implemented __call, consider adding a @method annotation. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

444
            $staffmember->/** @scrutinizer ignore-call */ 
445
                          inGroups([$staffgroup, $managementgroup]),
Loading history...
445
            'inGroups() succeeds if a membership is detected on one of many passed groups'
446
        );
447
448
        $staffmember->Groups()->remove($managementgroup);
0 ignored issues
show
Bug introduced by
The method Groups() does not exist on SilverStripe\ORM\DataObject. Since you implemented __call, consider adding a @method annotation. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

448
        $staffmember->/** @scrutinizer ignore-call */ 
449
                      Groups()->remove($managementgroup);
Loading history...
449
        $this->assertFalse(
450
            $staffmember->inGroup($managementgroup),
0 ignored issues
show
Bug introduced by
The method inGroup() does not exist on SilverStripe\ORM\DataObject. Since you implemented __call, consider adding a @method annotation. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

450
            $staffmember->/** @scrutinizer ignore-call */ 
451
                          inGroup($managementgroup),
Loading history...
451
            'member was not removed from group using ->Groups()->remove()'
452
        );
453
454
        $staffmember->Groups()->removeAll();
455
        $this->assertCount(
456
            0,
457
            $staffmember->Groups(),
458
            'member was not removed from all groups using ->Groups()->removeAll()'
459
        );
460
    }
461
462
    public function testAddToGroupByCode()
463
    {
464
        /** @var Member $grouplessMember */
465
        $grouplessMember = $this->objFromFixture(Member::class, 'grouplessmember');
466
        /** @var Group $memberlessGroup */
467
        $memberlessGroup = $this->objFromFixture(Group::class, 'memberlessgroup');
468
469
        $this->assertFalse($grouplessMember->Groups()->exists());
470
        $this->assertFalse($memberlessGroup->Members()->exists());
471
472
        $grouplessMember->addToGroupByCode('memberless');
473
474
        $this->assertEquals($memberlessGroup->Members()->count(), 1);
475
        $this->assertEquals($grouplessMember->Groups()->count(), 1);
476
477
        $grouplessMember->addToGroupByCode('somegroupthatwouldneverexist', 'New Group');
478
        $this->assertEquals($grouplessMember->Groups()->count(), 2);
479
480
        /** @var Group $group */
481
        $group = DataObject::get_one(
482
            Group::class,
483
            [
484
            '"Group"."Code"' => 'somegroupthatwouldneverexist'
485
            ]
486
        );
487
        $this->assertNotNull($group);
488
        $this->assertEquals($group->Code, 'somegroupthatwouldneverexist');
489
        $this->assertEquals($group->Title, 'New Group');
490
    }
491
492
    public function testRemoveFromGroupByCode()
493
    {
494
        /** @var Member $grouplessMember */
495
        $grouplessMember = $this->objFromFixture(Member::class, 'grouplessmember');
496
        /** @var Group $memberlessGroup */
497
        $memberlessGroup = $this->objFromFixture(Group::class, 'memberlessgroup');
498
499
        $this->assertFalse($grouplessMember->Groups()->exists());
500
        $this->assertFalse($memberlessGroup->Members()->exists());
501
502
        $grouplessMember->addToGroupByCode('memberless');
503
504
        $this->assertEquals($memberlessGroup->Members()->count(), 1);
505
        $this->assertEquals($grouplessMember->Groups()->count(), 1);
506
507
        $grouplessMember->addToGroupByCode('somegroupthatwouldneverexist', 'New Group');
508
        $this->assertEquals($grouplessMember->Groups()->count(), 2);
509
510
        /** @var Group $group */
511
        $group = DataObject::get_one(Group::class, "\"Code\" = 'somegroupthatwouldneverexist'");
512
        $this->assertNotNull($group);
513
        $this->assertEquals($group->Code, 'somegroupthatwouldneverexist');
514
        $this->assertEquals($group->Title, 'New Group');
515
516
        $grouplessMember->removeFromGroupByCode('memberless');
517
        $this->assertEquals($memberlessGroup->Members()->count(), 0);
518
        $this->assertEquals($grouplessMember->Groups()->count(), 1);
519
520
        $grouplessMember->removeFromGroupByCode('somegroupthatwouldneverexist');
521
        $this->assertEquals($grouplessMember->Groups()->count(), 0);
522
    }
523
524
    public function testInGroup()
525
    {
526
        /** @var Member $staffmember */
527
        $staffmember = $this->objFromFixture(Member::class, 'staffmember');
528
        /** @var Member $managementmember */
529
        $managementmember = $this->objFromFixture(Member::class, 'managementmember');
530
        /** @var Member $accountingmember */
531
        $accountingmember = $this->objFromFixture(Member::class, 'accountingmember');
532
        /** @var Member $ceomember */
533
        $ceomember = $this->objFromFixture(Member::class, 'ceomember');
534
535
        /** @var Group $staffgroup */
536
        $staffgroup = $this->objFromFixture(Group::class, 'staffgroup');
537
        /** @var Group $managementgroup */
538
        $managementgroup = $this->objFromFixture(Group::class, 'managementgroup');
539
        /** @var Group $ceogroup */
540
        $ceogroup = $this->objFromFixture(Group::class, 'ceogroup');
541
542
        $this->assertTrue(
543
            $staffmember->inGroup($staffgroup),
544
            'Direct group membership is detected'
545
        );
546
        $this->assertTrue(
547
            $managementmember->inGroup($staffgroup),
548
            'Users of child group are members of a direct parent group (if not in strict mode)'
549
        );
550
        $this->assertTrue(
551
            $accountingmember->inGroup($staffgroup),
552
            'Users of child group are members of a direct parent group (if not in strict mode)'
553
        );
554
        $this->assertTrue(
555
            $ceomember->inGroup($staffgroup),
556
            'Users of indirect grandchild group are members of a parent group (if not in strict mode)'
557
        );
558
        $this->assertTrue(
559
            $ceomember->inGroup($ceogroup, true),
560
            'Direct group membership is dected (if in strict mode)'
561
        );
562
        $this->assertFalse(
563
            $ceomember->inGroup($staffgroup, true),
564
            'Users of child group are not members of a direct parent group (if in strict mode)'
565
        );
566
        $this->assertFalse(
567
            $staffmember->inGroup($managementgroup),
568
            'Users of parent group are not members of a direct child group'
569
        );
570
        $this->assertFalse(
571
            $staffmember->inGroup($ceogroup),
572
            'Users of parent group are not members of an indirect grandchild group'
573
        );
574
        $this->assertFalse(
575
            $accountingmember->inGroup($managementgroup),
576
            'Users of group are not members of any siblings'
577
        );
578
        $this->assertFalse(
579
            $staffmember->inGroup('does-not-exist'),
580
            'Non-existant group returns false'
581
        );
582
    }
583
584
    /**
585
     * Tests that the user is able to view their own record, and in turn, they can
586
     * edit and delete their own record too.
587
     */
588
    public function testCanManipulateOwnRecord()
589
    {
590
        $member = $this->objFromFixture(Member::class, 'test');
591
        $member2 = $this->objFromFixture(Member::class, 'staffmember');
592
593
        /* Not logged in, you can't view, delete or edit the record */
594
        $this->assertFalse($member->canView());
595
        $this->assertFalse($member->canDelete());
596
        $this->assertFalse($member->canEdit());
597
598
        /* Logged in users can edit their own record */
599
        $this->logInAs($member);
600
        $this->assertTrue($member->canView());
601
        $this->assertFalse($member->canDelete());
602
        $this->assertTrue($member->canEdit());
603
604
        /* Other uses cannot view, delete or edit others records */
605
        $this->logInAs($member2);
606
        $this->assertFalse($member->canView());
607
        $this->assertFalse($member->canDelete());
608
        $this->assertFalse($member->canEdit());
609
610
        $this->logOut();
611
    }
612
613
    public function testAuthorisedMembersCanManipulateOthersRecords()
614
    {
615
        $member = $this->objFromFixture(Member::class, 'test');
616
        $member2 = $this->objFromFixture(Member::class, 'staffmember');
617
618
        /* Group members with SecurityAdmin permissions can manipulate other records */
619
        $this->logInAs($member);
620
        $this->assertTrue($member2->canView());
621
        $this->assertTrue($member2->canDelete());
622
        $this->assertTrue($member2->canEdit());
623
624
        $this->logOut();
625
    }
626
627
    public function testExtendedCan()
628
    {
629
        $member = $this->objFromFixture(Member::class, 'test');
630
631
        /* Normal behaviour is that you can't view a member unless canView() on an extension returns true */
632
        $this->assertFalse($member->canView());
633
        $this->assertFalse($member->canDelete());
634
        $this->assertFalse($member->canEdit());
635
636
        /* Apply a extension that allows viewing in any case (most likely the case for member profiles) */
637
        Member::add_extension(MemberTest\ViewingAllowedExtension::class);
638
        $member2 = $this->objFromFixture(Member::class, 'staffmember');
639
640
        $this->assertTrue($member2->canView());
641
        $this->assertFalse($member2->canDelete());
642
        $this->assertFalse($member2->canEdit());
643
644
        /* Apply a extension that denies viewing of the Member */
645
        Member::remove_extension(MemberTest\ViewingAllowedExtension::class);
646
        Member::add_extension(MemberTest\ViewingDeniedExtension::class);
647
        $member3 = $this->objFromFixture(Member::class, 'managementmember');
648
649
        $this->assertFalse($member3->canView());
650
        $this->assertFalse($member3->canDelete());
651
        $this->assertFalse($member3->canEdit());
652
653
        /* Apply a extension that allows viewing and editing but denies deletion */
654
        Member::remove_extension(MemberTest\ViewingDeniedExtension::class);
655
        Member::add_extension(MemberTest\EditingAllowedDeletingDeniedExtension::class);
656
        $member4 = $this->objFromFixture(Member::class, 'accountingmember');
657
658
        $this->assertTrue($member4->canView());
659
        $this->assertFalse($member4->canDelete());
660
        $this->assertTrue($member4->canEdit());
661
662
        Member::remove_extension(MemberTest\EditingAllowedDeletingDeniedExtension::class);
663
    }
664
665
    /**
666
     * Tests for {@link Member::getName()} and {@link Member::setName()}
667
     */
668
    public function testName()
669
    {
670
        /** @var Member $member */
671
        $member = $this->objFromFixture(Member::class, 'test');
672
        $member->setName('Test Some User');
673
        $this->assertEquals('Test Some User', $member->getName());
674
        $this->assertEquals('Test Some', $member->FirstName);
675
        $this->assertEquals('User', $member->Surname);
676
        $member->setName('Test');
677
        $this->assertEquals('Test', $member->getName());
678
        $member->FirstName = 'Test';
679
        $member->Surname = '';
680
        $this->assertEquals('Test', $member->FirstName);
681
        $this->assertEquals('', $member->Surname);
682
        $this->assertEquals('Test', $member->getName());
683
    }
684
685
    public function testMembersWithSecurityAdminAccessCantEditAdminsUnlessTheyreAdminsThemselves()
686
    {
687
        $adminMember = $this->objFromFixture(Member::class, 'admin');
688
        $otherAdminMember = $this->objFromFixture(Member::class, 'other-admin');
689
        $securityAdminMember = $this->objFromFixture(Member::class, 'test');
690
        $ceoMember = $this->objFromFixture(Member::class, 'ceomember');
691
692
        // Careful: Don't read as english language.
693
        // More precisely this should read canBeEditedBy()
694
695
        $this->assertTrue($adminMember->canEdit($adminMember), 'Admins can edit themselves');
696
        $this->assertTrue($otherAdminMember->canEdit($adminMember), 'Admins can edit other admins');
697
        $this->assertTrue($securityAdminMember->canEdit($adminMember), 'Admins can edit other members');
698
699
        $this->assertTrue($securityAdminMember->canEdit($securityAdminMember), 'Security-Admins can edit themselves');
700
        $this->assertFalse($adminMember->canEdit($securityAdminMember), 'Security-Admins can not edit other admins');
701
        $this->assertTrue($ceoMember->canEdit($securityAdminMember), 'Security-Admins can edit other members');
702
    }
703
704
    public function testOnChangeGroups()
705
    {
706
        /** @var Group $staffGroup */
707
        $staffGroup = $this->objFromFixture(Group::class, 'staffgroup');
708
        /** @var Member $staffMember */
709
        $staffMember = $this->objFromFixture(Member::class, 'staffmember');
710
        /** @var Member $adminMember */
711
        $adminMember = $this->objFromFixture(Member::class, 'admin');
712
713
        // Construct admin and non-admin gruops
714
        $newAdminGroup = new Group(['Title' => 'newadmin']);
715
        $newAdminGroup->write();
716
        Permission::grant($newAdminGroup->ID, 'ADMIN');
717
        $newOtherGroup = new Group(['Title' => 'othergroup']);
718
        $newOtherGroup->write();
719
720
        $this->assertTrue(
721
            $staffMember->onChangeGroups([$staffGroup->ID]),
722
            'Adding existing non-admin group relation is allowed for non-admin members'
723
        );
724
        $this->assertTrue(
725
            $staffMember->onChangeGroups([$newOtherGroup->ID]),
726
            'Adding new non-admin group relation is allowed for non-admin members'
727
        );
728
        $this->assertFalse(
729
            $staffMember->onChangeGroups([$newAdminGroup->ID]),
730
            'Adding new admin group relation is not allowed for non-admin members'
731
        );
732
733
        $this->logInAs($adminMember);
734
        $this->assertTrue(
735
            $staffMember->onChangeGroups([$newAdminGroup->ID]),
736
            'Adding new admin group relation is allowed for normal users, when granter is logged in as admin'
737
        );
738
        $this->logOut();
739
740
        $this->assertTrue(
741
            $adminMember->onChangeGroups([$newAdminGroup->ID]),
742
            'Adding new admin group relation is allowed for admin members'
743
        );
744
    }
745
746
    /**
747
     * Ensure DirectGroups listbox disallows admin-promotion
748
     */
749
    public function testAllowedGroupsListbox()
750
    {
751
        /** @var Group $adminGroup */
752
        $adminGroup = $this->objFromFixture(Group::class, 'admingroup');
753
        /** @var Member $staffMember */
754
        $staffMember = $this->objFromFixture(Member::class, 'staffmember');
755
        /** @var Member $adminMember */
756
        $adminMember = $this->objFromFixture(Member::class, 'admin');
757
758
        // Ensure you can see the DirectGroups box
759
        $this->logInWithPermission('EDIT_PERMISSIONS');
760
761
        // Non-admin member field contains non-admin groups
762
        /** @var ListboxField $staffListbox */
763
        $staffListbox = $staffMember->getCMSFields()->dataFieldByName('DirectGroups');
764
        $this->assertArrayNotHasKey($adminGroup->ID, $staffListbox->getSource());
765
766
        // admin member field contains admin group
767
        /** @var ListboxField $adminListbox */
768
        $adminListbox = $adminMember->getCMSFields()->dataFieldByName('DirectGroups');
769
        $this->assertArrayHasKey($adminGroup->ID, $adminListbox->getSource());
770
771
        // If logged in as admin, staff listbox has admin group
772
        $this->logInWithPermission('ADMIN');
773
        $staffListbox = $staffMember->getCMSFields()->dataFieldByName('DirectGroups');
774
        $this->assertArrayHasKey($adminGroup->ID, $staffListbox->getSource());
0 ignored issues
show
Bug introduced by
The method getSource() does not exist on SilverStripe\Forms\FormField. Since you implemented __call, consider adding a @method annotation. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

774
        $this->assertArrayHasKey($adminGroup->ID, $staffListbox->/** @scrutinizer ignore-call */ getSource());
Loading history...
775
    }
776
777
    /**
778
     * Test Member_GroupSet::add
779
     */
780
    public function testOnChangeGroupsByAdd()
781
    {
782
        /** @var Member $staffMember */
783
        $staffMember = $this->objFromFixture(Member::class, 'staffmember');
784
        /** @var Member $adminMember */
785
        $adminMember = $this->objFromFixture(Member::class, 'admin');
786
787
        // Setup new admin group
788
        $newAdminGroup = new Group(['Title' => 'newadmin']);
789
        $newAdminGroup->write();
790
        Permission::grant($newAdminGroup->ID, 'ADMIN');
791
792
        // Setup non-admin group
793
        $newOtherGroup = new Group(['Title' => 'othergroup']);
794
        $newOtherGroup->write();
795
796
        // Test staff can be added to other group
797
        $this->assertFalse($staffMember->inGroup($newOtherGroup));
798
        $staffMember->Groups()->add($newOtherGroup);
799
        $this->assertTrue(
800
            $staffMember->inGroup($newOtherGroup),
801
            'Adding new non-admin group relation is allowed for non-admin members'
802
        );
803
804
        // Test staff member can't be added to admin groups
805
        $this->assertFalse($staffMember->inGroup($newAdminGroup));
806
        $staffMember->Groups()->add($newAdminGroup);
807
        $this->assertFalse(
808
            $staffMember->inGroup($newAdminGroup),
809
            'Adding new admin group relation is not allowed for non-admin members'
810
        );
811
812
        // Test staff member can be added to admin group by admins
813
        $this->logInAs($adminMember);
814
        $staffMember->Groups()->add($newAdminGroup);
815
        $this->assertTrue(
816
            $staffMember->inGroup($newAdminGroup),
817
            'Adding new admin group relation is allowed for normal users, when granter is logged in as admin'
818
        );
819
820
        // Test staff member can be added if they are already admin
821
        $this->logOut();
822
        $this->assertFalse($adminMember->inGroup($newAdminGroup));
823
        $adminMember->Groups()->add($newAdminGroup);
824
        $this->assertTrue(
825
            $adminMember->inGroup($newAdminGroup),
826
            'Adding new admin group relation is allowed for admin members'
827
        );
828
    }
829
830
    /**
831
     * Test Member_GroupSet::add
832
     */
833
    public function testOnChangeGroupsBySetIDList()
834
    {
835
        /** @var Member $staffMember */
836
        $staffMember = $this->objFromFixture(Member::class, 'staffmember');
837
838
        // Setup new admin group
839
        $newAdminGroup = new Group(['Title' => 'newadmin']);
840
        $newAdminGroup->write();
841
        Permission::grant($newAdminGroup->ID, 'ADMIN');
842
843
        // Test staff member can't be added to admin groups
844
        $this->assertFalse($staffMember->inGroup($newAdminGroup));
845
        $staffMember->Groups()->setByIDList([$newAdminGroup->ID]);
846
        $this->assertFalse(
847
            $staffMember->inGroup($newAdminGroup),
848
            'Adding new admin group relation is not allowed for non-admin members'
849
        );
850
    }
851
852
    /**
853
     * Test that extensions using updateCMSFields() are applied correctly
854
     */
855
    public function testUpdateCMSFields()
856
    {
857
        Member::add_extension(FieldsExtension::class);
858
859
        $member = Member::singleton();
860
        $fields = $member->getCMSFields();
861
862
        /**
863
         * @skipUpgrade
864
         */
865
        $this->assertNotNull($fields->dataFieldByName('Email'), 'Scaffolded fields are retained');
866
        $this->assertNull($fields->dataFieldByName('Salt'), 'Field modifications run correctly');
867
        $this->assertNotNull($fields->dataFieldByName('TestMemberField'), 'Extension is applied correctly');
868
869
        Member::remove_extension(FieldsExtension::class);
870
    }
871
872
    /**
873
     * Test that all members are returned
874
     */
875
    public function testMap_in_groupsReturnsAll()
876
    {
877
        $members = Member::map_in_groups();
878
        $this->assertEquals(13, $members->count(), 'There are 12 members in the mock plus a fake admin');
879
    }
880
881
    /**
882
     * Test that only admin members are returned
883
     */
884
    public function testMap_in_groupsReturnsAdmins()
885
    {
886
        $adminID = $this->objFromFixture(Group::class, 'admingroup')->ID;
887
        $members = Member::map_in_groups($adminID)->toArray();
888
889
        $admin = $this->objFromFixture(Member::class, 'admin');
890
        $otherAdmin = $this->objFromFixture(Member::class, 'other-admin');
891
892
        $this->assertTrue(
893
            in_array($admin->getTitle(), $members),
894
            $admin->getTitle() . ' should be in the returned list.'
895
        );
896
        $this->assertTrue(
897
            in_array($otherAdmin->getTitle(), $members),
898
            $otherAdmin->getTitle() . ' should be in the returned list.'
899
        );
900
        $this->assertEquals(2, count($members), 'There should be 2 members from the admin group');
901
    }
902
903
    /**
904
     * Add the given array of member extensions as class names.
905
     * This is useful for re-adding extensions after being removed
906
     * in a test case to produce an unbiased test.
907
     *
908
     * @param  array $extensions
909
     * @return array The added extensions
910
     */
911
    protected function addExtensions($extensions)
912
    {
913
        if ($extensions) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $extensions of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
914
            foreach ($extensions as $extension) {
915
                Member::add_extension($extension);
916
            }
917
        }
918
        return $extensions;
919
    }
920
921
    /**
922
     * Remove given extensions from Member. This is useful for
923
     * removing extensions that could produce a biased
924
     * test result, as some extensions applied by project
925
     * code or modules can do this.
926
     *
927
     * @param  array $extensions
928
     * @return array The removed extensions
929
     */
930
    protected function removeExtensions($extensions)
931
    {
932
        if ($extensions) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $extensions of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
933
            foreach ($extensions as $extension) {
934
                Member::remove_extension($extension);
935
            }
936
        }
937
        return $extensions;
938
    }
939
940
    public function testGenerateAutologinTokenAndStoreHash()
941
    {
942
        $m = new Member();
943
        $m->write();
944
945
        $token = $m->generateAutologinTokenAndStoreHash();
946
947
        $this->assertEquals($m->encryptWithUserSettings($token), $m->AutoLoginHash, 'Stores the token as a hash.');
948
    }
949
950
    public function testValidateAutoLoginToken()
951
    {
952
        $enc = new PasswordEncryptor_Blowfish();
0 ignored issues
show
Unused Code introduced by
The assignment to $enc is dead and can be removed.
Loading history...
953
954
        $m1 = new Member();
955
        $m1->write();
956
        $m1Token = $m1->generateAutologinTokenAndStoreHash();
957
958
        $m2 = new Member();
959
        $m2->write();
960
        $m2->generateAutologinTokenAndStoreHash();
961
962
        $this->assertTrue($m1->validateAutoLoginToken($m1Token), 'Passes token validity test against matching member.');
963
        $this->assertFalse($m2->validateAutoLoginToken($m1Token), 'Fails token validity test against other member.');
964
    }
965
966
    public function testRememberMeHashGeneration()
967
    {
968
        /** @var Member $m1 */
969
        $m1 = $this->objFromFixture(Member::class, 'grouplessmember');
970
971
        Injector::inst()->get(IdentityStore::class)->logIn($m1, true);
972
973
        $hashes = RememberLoginHash::get()->filter('MemberID', $m1->ID);
974
        $this->assertEquals($hashes->count(), 1);
975
        /** @var RememberLoginHash $firstHash */
976
        $firstHash = $hashes->first();
977
        $this->assertNotNull($firstHash->DeviceID);
978
        $this->assertNotNull($firstHash->Hash);
979
    }
980
981
    public function testRememberMeHashAutologin()
982
    {
983
        /** @var Member $m1 */
984
        $m1 = $this->objFromFixture(Member::class, 'noexpiry');
985
986
        Injector::inst()->get(IdentityStore::class)->logIn($m1, true);
987
988
        /** @var RememberLoginHash $firstHash */
989
        $firstHash = RememberLoginHash::get()->filter('MemberID', $m1->ID)->first();
990
        $this->assertNotNull($firstHash);
991
992
        // re-generates the hash so we can get the token
993
        $firstHash->Hash = $firstHash->getNewHash($m1);
994
        $token = $firstHash->getToken();
995
        $firstHash->write();
996
997
        $response = $this->get(
998
            'Security/login',
999
            $this->session(),
1000
            null,
1001
            [
1002
                'alc_enc' => $m1->ID . ':' . $token,
1003
                'alc_device' => $firstHash->DeviceID
1004
            ]
1005
        );
1006
        $message = Convert::raw2xml(
1007
            _t(
1008
                'SilverStripe\\Security\\Member.LOGGEDINAS',
1009
                "You're logged in as {name}.",
1010
                ['name' => $m1->FirstName]
1011
            )
1012
        );
1013
        $this->assertContains($message, $response->getBody());
1014
1015
        $this->logOut();
1016
1017
        // A wrong token or a wrong device ID should not let us autologin
1018
        $response = $this->get(
1019
            'Security/login',
1020
            $this->session(),
1021
            null,
1022
            [
1023
                'alc_enc' => $m1->ID . ':asdfasd' . str_rot13($token),
1024
                'alc_device' => $firstHash->DeviceID
1025
            ]
1026
        );
1027
        $this->assertNotContains($message, $response->getBody());
1028
1029
        $response = $this->get(
1030
            'Security/login',
1031
            $this->session(),
1032
            null,
1033
            [
1034
                'alc_enc' => $m1->ID . ':' . $token,
1035
                'alc_device' => str_rot13($firstHash->DeviceID)
1036
            ]
1037
        );
1038
        $this->assertNotContains($message, $response->getBody());
1039
1040
        // Re-logging (ie 'alc_enc' has expired), and not checking the "Remember Me" option
1041
        // should remove all previous hashes for this device
1042
        $response = $this->post(
1043
            'Security/login/default/LoginForm',
1044
            [
1045
                'Email' => $m1->Email,
1046
                'Password' => '1nitialPassword',
1047
                'action_doLogin' => 'action_doLogin'
1048
            ],
1049
            null,
1050
            $this->session(),
1051
            null,
1052
            [
1053
                'alc_device' => $firstHash->DeviceID
1054
            ]
1055
        );
1056
        $this->assertContains($message, $response->getBody());
1057
        $this->assertEquals(RememberLoginHash::get()->filter('MemberID', $m1->ID)->count(), 0);
1058
    }
1059
1060
    public function testExpiredRememberMeHashAutologin()
1061
    {
1062
        /** @var Member $m1 */
1063
        $m1 = $this->objFromFixture(Member::class, 'noexpiry');
1064
        Injector::inst()->get(IdentityStore::class)->logIn($m1, true);
1065
        /** @var RememberLoginHash $firstHash */
1066
        $firstHash = RememberLoginHash::get()->filter('MemberID', $m1->ID)->first();
1067
        $this->assertNotNull($firstHash);
1068
1069
        // re-generates the hash so we can get the token
1070
        $firstHash->Hash = $firstHash->getNewHash($m1);
1071
        $token = $firstHash->getToken();
1072
        $firstHash->ExpiryDate = '2000-01-01 00:00:00';
1073
        $firstHash->write();
1074
1075
        DBDatetime::set_mock_now('1999-12-31 23:59:59');
1076
1077
        $response = $this->get(
1078
            'Security/login',
1079
            $this->session(),
1080
            null,
1081
            [
1082
                'alc_enc' => $m1->ID . ':' . $token,
1083
                'alc_device' => $firstHash->DeviceID
1084
            ]
1085
        );
1086
        $message = Convert::raw2xml(
1087
            _t(
1088
                'SilverStripe\\Security\\Member.LOGGEDINAS',
1089
                "You're logged in as {name}.",
1090
                ['name' => $m1->FirstName]
1091
            )
1092
        );
1093
        $this->assertContains($message, $response->getBody());
1094
1095
        $this->logOut();
1096
1097
        // re-generates the hash so we can get the token
1098
        $firstHash->Hash = $firstHash->getNewHash($m1);
1099
        $token = $firstHash->getToken();
1100
        $firstHash->ExpiryDate = '2000-01-01 00:00:00';
1101
        $firstHash->write();
1102
1103
        DBDatetime::set_mock_now('2000-01-01 00:00:01');
1104
1105
        $response = $this->get(
1106
            'Security/login',
1107
            $this->session(),
1108
            null,
1109
            [
1110
                'alc_enc' => $m1->ID . ':' . $token,
1111
                'alc_device' => $firstHash->DeviceID
1112
            ]
1113
        );
1114
        $this->assertNotContains($message, $response->getBody());
1115
        $this->logOut();
1116
        DBDatetime::clear_mock_now();
1117
    }
1118
1119
    public function testRememberMeMultipleDevices()
1120
    {
1121
        /** @var Member $m1 */
1122
        $m1 = $this->objFromFixture(Member::class, 'noexpiry');
1123
1124
        // First device
1125
        Injector::inst()->get(IdentityStore::class)->logIn($m1, true);
1126
        Cookie::set('alc_device', null);
1127
        // Second device
1128
        Injector::inst()->get(IdentityStore::class)->logIn($m1, true);
1129
1130
        // Hash of first device
1131
        /** @var RememberLoginHash $firstHash */
1132
        $firstHash = RememberLoginHash::get()->filter('MemberID', $m1->ID)->first();
1133
        $this->assertNotNull($firstHash);
1134
1135
        // Hash of second device
1136
        /** @var RememberLoginHash $secondHash */
1137
        $secondHash = RememberLoginHash::get()->filter('MemberID', $m1->ID)->last();
1138
        $this->assertNotNull($secondHash);
1139
1140
        // DeviceIDs are different
1141
        $this->assertNotEquals($firstHash->DeviceID, $secondHash->DeviceID);
1142
1143
        // re-generates the hashes so we can get the tokens
1144
        $firstHash->Hash = $firstHash->getNewHash($m1);
1145
        $firstToken = $firstHash->getToken();
1146
        $firstHash->write();
1147
1148
        $secondHash->Hash = $secondHash->getNewHash($m1);
1149
        $secondToken = $secondHash->getToken();
1150
        $secondHash->write();
1151
1152
        // Accessing the login page should show the user's name straight away
1153
        $response = $this->get(
1154
            'Security/login',
1155
            $this->session(),
1156
            null,
1157
            [
1158
                'alc_enc' => $m1->ID . ':' . $firstToken,
1159
                'alc_device' => $firstHash->DeviceID
1160
            ]
1161
        );
1162
        $message = Convert::raw2xml(
1163
            _t(
1164
                'SilverStripe\\Security\\Member.LOGGEDINAS',
1165
                "You're logged in as {name}.",
1166
                ['name' => $m1->FirstName]
1167
            )
1168
        );
1169
        $this->assertContains($message, $response->getBody());
1170
1171
        // Test that removing session but not cookie keeps user
1172
        /** @var SessionAuthenticationHandler $sessionHandler */
1173
        $sessionHandler = Injector::inst()->get(SessionAuthenticationHandler::class);
1174
        $sessionHandler->logOut();
1175
        Security::setCurrentUser(null);
1176
1177
        // Accessing the login page from the second device
1178
        $response = $this->get(
1179
            'Security/login',
1180
            $this->session(),
1181
            null,
1182
            [
1183
                'alc_enc' => $m1->ID . ':' . $secondToken,
1184
                'alc_device' => $secondHash->DeviceID
1185
            ]
1186
        );
1187
        $this->assertContains($message, $response->getBody());
1188
1189
        // Logging out from the second device - only one device being logged out
1190
        RememberLoginHash::config()->update('logout_across_devices', false);
1191
        $this->get(
1192
            'Security/logout',
1193
            $this->session(),
1194
            null,
1195
            [
1196
                'alc_enc' => $m1->ID . ':' . $secondToken,
1197
                'alc_device' => $secondHash->DeviceID
1198
            ]
1199
        );
1200
        $this->assertEquals(
1201
            RememberLoginHash::get()->filter(['MemberID'=>$m1->ID, 'DeviceID'=>$firstHash->DeviceID])->count(),
1202
            1
1203
        );
1204
1205
        // Logging out from any device when all login hashes should be removed
1206
        RememberLoginHash::config()->update('logout_across_devices', true);
1207
        Injector::inst()->get(IdentityStore::class)->logIn($m1, true);
1208
        $this->get('Security/logout', $this->session());
1209
        $this->assertEquals(
1210
            RememberLoginHash::get()->filter('MemberID', $m1->ID)->count(),
1211
            0
1212
        );
1213
    }
1214
1215
    public function testCanDelete()
1216
    {
1217
        $admin1 = $this->objFromFixture(Member::class, 'admin');
1218
        $admin2 = $this->objFromFixture(Member::class, 'other-admin');
1219
        $member1 = $this->objFromFixture(Member::class, 'grouplessmember');
1220
        $member2 = $this->objFromFixture(Member::class, 'noformatmember');
1221
1222
        $this->assertTrue(
1223
            $admin1->canDelete($admin2),
1224
            'Admins can delete other admins'
1225
        );
1226
        $this->assertTrue(
1227
            $member1->canDelete($admin2),
1228
            'Admins can delete non-admins'
1229
        );
1230
        $this->assertFalse(
1231
            $admin1->canDelete($admin1),
1232
            'Admins can not delete themselves'
1233
        );
1234
        $this->assertFalse(
1235
            $member1->canDelete($member2),
1236
            'Non-admins can not delete other non-admins'
1237
        );
1238
        $this->assertFalse(
1239
            $member1->canDelete($member1),
1240
            'Non-admins can not delete themselves'
1241
        );
1242
    }
1243
1244
    public function testFailedLoginCount()
1245
    {
1246
        $maxFailedLoginsAllowed = 3;
1247
        //set up the config variables to enable login lockouts
1248
        Member::config()->update('lock_out_after_incorrect_logins', $maxFailedLoginsAllowed);
1249
1250
        /** @var Member $member */
1251
        $member = $this->objFromFixture(Member::class, 'test');
1252
        $failedLoginCount = $member->FailedLoginCount;
1253
1254
        for ($i = 1; $i < $maxFailedLoginsAllowed; ++$i) {
1255
            $member->registerFailedLogin();
1256
1257
            $this->assertEquals(
1258
                ++$failedLoginCount,
1259
                $member->FailedLoginCount,
1260
                'Failed to increment $member->FailedLoginCount'
1261
            );
1262
1263
            $this->assertTrue(
1264
                $member->canLogin(),
1265
                "Member has been locked out too early"
1266
            );
1267
        }
1268
    }
1269
1270
    public function testMemberValidator()
1271
    {
1272
        // clear custom requirements for this test
1273
        Member_Validator::config()->update('customRequired', null);
1274
        /** @var Member $memberA */
1275
        $memberA = $this->objFromFixture(Member::class, 'admin');
1276
        /** @var Member $memberB */
1277
        $memberB = $this->objFromFixture(Member::class, 'test');
1278
1279
        // create a blank form
1280
        $form = new MemberTest\ValidatorForm();
1281
1282
        $validator = new Member_Validator();
1283
        $validator->setForm($form);
1284
1285
        // Simulate creation of a new member via form, but use an existing member identifier
1286
        $fail = $validator->php(
1287
            [
1288
            'FirstName' => 'Test',
1289
            'Email' => $memberA->Email
1290
            ]
1291
        );
1292
1293
        $this->assertFalse(
1294
            $fail,
1295
            'Member_Validator must fail when trying to create new Member with existing Email.'
1296
        );
1297
1298
        // populate the form with values from another member
1299
        $form->loadDataFrom($memberB);
1300
1301
        // Assign the validator to an existing member
1302
        // (this is basically the same as passing the member ID with the form data)
1303
        $validator->setForMember($memberB);
1304
1305
        // Simulate update of a member via form and use an existing member Email
1306
        $fail = $validator->php(
1307
            [
1308
            'FirstName' => 'Test',
1309
            'Email' => $memberA->Email
1310
            ]
1311
        );
1312
1313
        // Simulate update to a new Email address
1314
        $pass1 = $validator->php(
1315
            [
1316
            'FirstName' => 'Test',
1317
            'Email' => '[email protected]'
1318
            ]
1319
        );
1320
1321
        // Pass in the same Email address that the member already has. Ensure that case is valid
1322
        $pass2 = $validator->php(
1323
            [
1324
            'FirstName' => 'Test',
1325
            'Surname' => 'User',
1326
            'Email' => $memberB->Email
1327
            ]
1328
        );
1329
1330
        $this->assertFalse(
1331
            $fail,
1332
            'Member_Validator must fail when trying to update existing member with existing Email.'
1333
        );
1334
1335
        $this->assertTrue(
1336
            $pass1,
1337
            'Member_Validator must pass when Email is updated to a value that\'s not in use.'
1338
        );
1339
1340
        $this->assertTrue(
1341
            $pass2,
1342
            'Member_Validator must pass when Member updates his own Email to the already existing value.'
1343
        );
1344
    }
1345
1346
    public function testMemberValidatorWithExtensions()
1347
    {
1348
        // clear custom requirements for this test
1349
        Member_Validator::config()->update('customRequired', null);
1350
1351
        // create a blank form
1352
        $form = new MemberTest\ValidatorForm();
1353
1354
        // Test extensions
1355
        Member_Validator::add_extension(MemberTest\SurnameMustMatchFirstNameExtension::class);
1356
        $validator = new Member_Validator();
1357
        $validator->setForm($form);
1358
1359
        // This test should fail, since the extension enforces FirstName == Surname
1360
        $fail = $validator->php(
1361
            [
1362
            'FirstName' => 'Test',
1363
            'Surname' => 'User',
1364
            'Email' => '[email protected]'
1365
            ]
1366
        );
1367
1368
        $pass = $validator->php(
1369
            [
1370
            'FirstName' => 'Test',
1371
            'Surname' => 'Test',
1372
            'Email' => '[email protected]'
1373
            ]
1374
        );
1375
1376
        $this->assertFalse(
1377
            $fail,
1378
            'Member_Validator must fail because of added extension.'
1379
        );
1380
1381
        $this->assertTrue(
1382
            $pass,
1383
            'Member_Validator must succeed, since it meets all requirements.'
1384
        );
1385
1386
        // Add another extension that always fails. This ensures that all extensions are considered in the validation
1387
        Member_Validator::add_extension(MemberTest\AlwaysFailExtension::class);
1388
        $validator = new Member_Validator();
1389
        $validator->setForm($form);
1390
1391
        // Even though the data is valid, This test should still fail, since one extension always returns false
1392
        $fail = $validator->php(
1393
            [
1394
            'FirstName' => 'Test',
1395
            'Surname' => 'Test',
1396
            'Email' => '[email protected]'
1397
            ]
1398
        );
1399
1400
        $this->assertFalse(
1401
            $fail,
1402
            'Member_Validator must fail because of added extensions.'
1403
        );
1404
1405
        // Remove added extensions
1406
        Member_Validator::remove_extension(MemberTest\AlwaysFailExtension::class);
1407
        Member_Validator::remove_extension(MemberTest\SurnameMustMatchFirstNameExtension::class);
1408
    }
1409
1410
    public function testCustomMemberValidator()
1411
    {
1412
        // clear custom requirements for this test
1413
        Member_Validator::config()->update('customRequired', null);
1414
1415
        $member = $this->objFromFixture(Member::class, 'admin');
1416
1417
        $form = new MemberTest\ValidatorForm();
1418
        $form->loadDataFrom($member);
1419
1420
        $validator = new Member_Validator();
1421
        $validator->setForm($form);
1422
1423
        $pass = $validator->php(
1424
            [
1425
            'FirstName' => 'Borris',
1426
            'Email' => '[email protected]'
1427
            ]
1428
        );
1429
1430
        $fail = $validator->php(
1431
            [
1432
            'Email' => '[email protected]',
1433
            'Surname' => ''
1434
            ]
1435
        );
1436
1437
        $this->assertTrue($pass, 'Validator requires a FirstName and Email');
1438
        $this->assertFalse($fail, 'Missing FirstName');
1439
1440
        $ext = new MemberTest\ValidatorExtension();
1441
        $ext->updateValidator($validator);
1442
1443
        $pass = $validator->php(
1444
            [
1445
            'FirstName' => 'Borris',
1446
            'Email' => '[email protected]'
1447
            ]
1448
        );
1449
1450
        $fail = $validator->php(
1451
            [
1452
            'Email' => '[email protected]'
1453
            ]
1454
        );
1455
1456
        $this->assertFalse($pass, 'Missing surname');
1457
        $this->assertFalse($fail, 'Missing surname value');
1458
1459
        $fail = $validator->php(
1460
            [
1461
            'Email' => '[email protected]',
1462
            'Surname' => 'Silverman'
1463
            ]
1464
        );
1465
1466
        $this->assertTrue($fail, 'Passes with email and surname now (no firstname)');
1467
    }
1468
1469
    public function testCurrentUser()
1470
    {
1471
        $this->assertNull(Security::getCurrentUser());
1472
1473
        $adminMember = $this->objFromFixture(Member::class, 'admin');
1474
        $this->logInAs($adminMember);
1475
1476
        $userFromSession = Security::getCurrentUser();
1477
        $this->assertEquals($adminMember->ID, $userFromSession->ID);
1478
    }
1479
1480
    /**
1481
     * @covers \SilverStripe\Security\Member::actAs()
1482
     */
1483
    public function testActAsUserPermissions()
1484
    {
1485
        $this->assertNull(Security::getCurrentUser());
1486
1487
        /** @var Member $adminMember */
1488
        $adminMember = $this->objFromFixture(Member::class, 'admin');
1489
1490
        // Check acting as admin when not logged in
1491
        $checkAdmin = Member::actAs($adminMember, function () {
1492
            return Permission::check('ADMIN');
1493
        });
1494
        $this->assertTrue($checkAdmin);
1495
1496
        // Check nesting
1497
        $checkAdmin = Member::actAs($adminMember, function () {
1498
            return Member::actAs(null, function () {
1499
                return Permission::check('ADMIN');
1500
            });
1501
        });
1502
        $this->assertFalse($checkAdmin);
1503
1504
        // Check logging in as non-admin user
1505
        $this->logInWithPermission('TEST_PERMISSION');
1506
1507
        $hasPerm = Member::actAs(null, function () {
1508
            return Permission::check('TEST_PERMISSION');
1509
        });
1510
        $this->assertFalse($hasPerm);
1511
1512
        // Check permissions can be promoted
1513
        $checkAdmin = Member::actAs($adminMember, function () {
1514
            return Permission::check('ADMIN');
1515
        });
1516
        $this->assertTrue($checkAdmin);
1517
    }
1518
1519
    /**
1520
     * @covers \SilverStripe\Security\Member::actAs()
1521
     */
1522
    public function testActAsUser()
1523
    {
1524
        $this->assertNull(Security::getCurrentUser());
1525
1526
        /** @var Member $adminMember */
1527
        $adminMember = $this->objFromFixture(Member::class, 'admin');
1528
        $member = Member::actAs($adminMember, function () {
1529
            return Security::getCurrentUser();
1530
        });
1531
        $this->assertEquals($adminMember->ID, $member->ID);
1532
1533
        // Check nesting
1534
        $member = Member::actAs($adminMember, function () {
1535
            return Member::actAs(null, function () {
1536
                return Security::getCurrentUser();
1537
            });
1538
        });
1539
        $this->assertEmpty($member);
1540
    }
1541
1542
    public function testChangePasswordWithExtensionsThatModifyValidationResult()
1543
    {
1544
        // Default behaviour
1545
        /** @var Member $member */
1546
        $member = $this->objFromFixture(Member::class, 'admin');
1547
        $result = $member->changePassword('my-secret-new-password');
1548
        $this->assertInstanceOf(ValidationResult::class, $result);
1549
        $this->assertTrue($result->isValid());
1550
1551
        // With an extension added
1552
        Member::add_extension(MemberTest\ExtendedChangePasswordExtension::class);
1553
        $member = $this->objFromFixture(Member::class, 'admin');
1554
        $result = $member->changePassword('my-second-secret-password');
0 ignored issues
show
Bug introduced by
The method changePassword() does not exist on SilverStripe\ORM\DataObject. Since you implemented __call, consider adding a @method annotation. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

1554
        /** @scrutinizer ignore-call */ 
1555
        $result = $member->changePassword('my-second-secret-password');
Loading history...
1555
        $this->assertInstanceOf(ValidationResult::class, $result);
1556
        $this->assertFalse($result->isValid());
1557
    }
1558
1559
    public function testNewMembersReceiveTheDefaultLocale()
1560
    {
1561
        // Set a different current locale to the default
1562
        i18n::set_locale('de_DE');
1563
1564
        $newMember = Member::create();
1565
        $newMember->update([
1566
            'FirstName' => 'Leslie',
1567
            'Surname' => 'Longly',
1568
            'Email' => '[email protected]',
1569
        ]);
1570
        $newMember->write();
1571
1572
        $this->assertSame('en_US', $newMember->Locale, 'New members receive the default locale');
1573
    }
1574
1575
    public function testChangePasswordOnlyValidatesPlaintext()
1576
    {
1577
        // This validator requires passwords to be 17 characters long
1578
        Member::set_password_validator(new MemberTest\VerySpecificPasswordValidator());
1579
1580
        // This algorithm will never return a 17 character hash
1581
        Security::config()->set('password_encryption_algorithm', 'blowfish');
1582
1583
        /** @var Member $member */
1584
        $member = $this->objFromFixture(Member::class, 'test');
1585
        $result = $member->changePassword('Password123456789'); // 17 characters long
1586
        $this->assertTrue($result->isValid());
1587
    }
1588
1589
    public function testGetLastName()
1590
    {
1591
        $member = new Member();
1592
        $member->Surname = 'Johnson';
1593
1594
        $this->assertSame('Johnson', $member->getLastName(), 'getLastName should proxy to Surname');
1595
    }
1596
    
1597
    public function testEmailIsTrimmed()
1598
    {
1599
        $member = new Member();
1600
        $member->Email = " [email protected]\r\n";
1601
        $member->write();
1602
        $this->assertNotNull(Member::get()->find('Email', '[email protected]'));
1603
    }
1604
}
1605