Passed
Push — 4 ( f8b600...43b005 )
by Steve
08:42 queued 12s
created

MemberTest::testExtendedCan()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 36
Code Lines 22

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 22
nc 1
nop 0
dl 0
loc 36
rs 9.568
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
use SilverStripe\SessionManager\Models\LoginSession;
0 ignored issues
show
Bug introduced by
The type SilverStripe\SessionManager\Models\LoginSession was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

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

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

445
            $staffmember->/** @scrutinizer ignore-call */ 
446
                          inGroups([$staffgroup, $managementgroup]),
Loading history...
446
            'inGroups() succeeds if a membership is detected on one of many passed groups'
447
        );
448
449
        $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

449
        $staffmember->/** @scrutinizer ignore-call */ 
450
                      Groups()->remove($managementgroup);
Loading history...
450
        $this->assertFalse(
451
            $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

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

775
        $this->assertArrayHasKey($adminGroup->ID, $staffListbox->/** @scrutinizer ignore-call */ getSource());
Loading history...
776
    }
777
778
    /**
779
     * Test Member_GroupSet::add
780
     */
781
    public function testOnChangeGroupsByAdd()
782
    {
783
        /** @var Member $staffMember */
784
        $staffMember = $this->objFromFixture(Member::class, 'staffmember');
785
        /** @var Member $adminMember */
786
        $adminMember = $this->objFromFixture(Member::class, 'admin');
787
788
        // Setup new admin group
789
        $newAdminGroup = new Group(['Title' => 'newadmin']);
790
        $newAdminGroup->write();
791
        Permission::grant($newAdminGroup->ID, 'ADMIN');
792
793
        // Setup non-admin group
794
        $newOtherGroup = new Group(['Title' => 'othergroup']);
795
        $newOtherGroup->write();
796
797
        // Test staff can be added to other group
798
        $this->assertFalse($staffMember->inGroup($newOtherGroup));
799
        $staffMember->Groups()->add($newOtherGroup);
800
        $this->assertTrue(
801
            $staffMember->inGroup($newOtherGroup),
802
            'Adding new non-admin group relation is allowed for non-admin members'
803
        );
804
805
        // Test staff member can't be added to admin groups
806
        $this->assertFalse($staffMember->inGroup($newAdminGroup));
807
        $staffMember->Groups()->add($newAdminGroup);
808
        $this->assertFalse(
809
            $staffMember->inGroup($newAdminGroup),
810
            'Adding new admin group relation is not allowed for non-admin members'
811
        );
812
813
        // Test staff member can be added to admin group by admins
814
        $this->logInAs($adminMember);
815
        $staffMember->Groups()->add($newAdminGroup);
816
        $this->assertTrue(
817
            $staffMember->inGroup($newAdminGroup),
818
            'Adding new admin group relation is allowed for normal users, when granter is logged in as admin'
819
        );
820
821
        // Test staff member can be added if they are already admin
822
        $this->logOut();
823
        $this->assertFalse($adminMember->inGroup($newAdminGroup));
824
        $adminMember->Groups()->add($newAdminGroup);
825
        $this->assertTrue(
826
            $adminMember->inGroup($newAdminGroup),
827
            'Adding new admin group relation is allowed for admin members'
828
        );
829
    }
830
831
    /**
832
     * Test Member_GroupSet::add
833
     */
834
    public function testOnChangeGroupsBySetIDList()
835
    {
836
        /** @var Member $staffMember */
837
        $staffMember = $this->objFromFixture(Member::class, 'staffmember');
838
839
        // Setup new admin group
840
        $newAdminGroup = new Group(['Title' => 'newadmin']);
841
        $newAdminGroup->write();
842
        Permission::grant($newAdminGroup->ID, 'ADMIN');
843
844
        // Test staff member can't be added to admin groups
845
        $this->assertFalse($staffMember->inGroup($newAdminGroup));
846
        $staffMember->Groups()->setByIDList([$newAdminGroup->ID]);
847
        $this->assertFalse(
848
            $staffMember->inGroup($newAdminGroup),
849
            'Adding new admin group relation is not allowed for non-admin members'
850
        );
851
    }
852
853
    /**
854
     * Test that extensions using updateCMSFields() are applied correctly
855
     */
856
    public function testUpdateCMSFields()
857
    {
858
        Member::add_extension(FieldsExtension::class);
859
860
        $member = Member::singleton();
861
        $fields = $member->getCMSFields();
862
863
        /**
864
         * @skipUpgrade
865
         */
866
        $this->assertNotNull($fields->dataFieldByName('Email'), 'Scaffolded fields are retained');
867
        $this->assertNull($fields->dataFieldByName('Salt'), 'Field modifications run correctly');
868
        $this->assertNotNull($fields->dataFieldByName('TestMemberField'), 'Extension is applied correctly');
869
870
        Member::remove_extension(FieldsExtension::class);
871
    }
872
873
    /**
874
     * Test that all members are returned
875
     */
876
    public function testMap_in_groupsReturnsAll()
877
    {
878
        $members = Member::map_in_groups();
879
        $this->assertEquals(13, $members->count(), 'There are 12 members in the mock plus a fake admin');
880
    }
881
882
    /**
883
     * Test that only admin members are returned
884
     */
885
    public function testMap_in_groupsReturnsAdmins()
886
    {
887
        $adminID = $this->objFromFixture(Group::class, 'admingroup')->ID;
888
        $members = Member::map_in_groups($adminID)->toArray();
889
890
        $admin = $this->objFromFixture(Member::class, 'admin');
891
        $otherAdmin = $this->objFromFixture(Member::class, 'other-admin');
892
893
        $this->assertTrue(
894
            in_array($admin->getTitle(), $members),
895
            $admin->getTitle() . ' should be in the returned list.'
896
        );
897
        $this->assertTrue(
898
            in_array($otherAdmin->getTitle(), $members),
899
            $otherAdmin->getTitle() . ' should be in the returned list.'
900
        );
901
        $this->assertEquals(2, count($members), 'There should be 2 members from the admin group');
902
    }
903
904
    /**
905
     * Add the given array of member extensions as class names.
906
     * This is useful for re-adding extensions after being removed
907
     * in a test case to produce an unbiased test.
908
     *
909
     * @param  array $extensions
910
     * @return array The added extensions
911
     */
912
    protected function addExtensions($extensions)
913
    {
914
        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...
915
            foreach ($extensions as $extension) {
916
                Member::add_extension($extension);
917
            }
918
        }
919
        return $extensions;
920
    }
921
922
    /**
923
     * Remove given extensions from Member. This is useful for
924
     * removing extensions that could produce a biased
925
     * test result, as some extensions applied by project
926
     * code or modules can do this.
927
     *
928
     * @param  array $extensions
929
     * @return array The removed extensions
930
     */
931
    protected function removeExtensions($extensions)
932
    {
933
        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...
934
            foreach ($extensions as $extension) {
935
                Member::remove_extension($extension);
936
            }
937
        }
938
        return $extensions;
939
    }
940
941
    public function testGenerateAutologinTokenAndStoreHash()
942
    {
943
        $m = new Member();
944
        $m->write();
945
946
        $token = $m->generateAutologinTokenAndStoreHash();
947
948
        $this->assertEquals($m->encryptWithUserSettings($token), $m->AutoLoginHash, 'Stores the token as a hash.');
949
    }
950
951
    public function testValidateAutoLoginToken()
952
    {
953
        $enc = new PasswordEncryptor_Blowfish();
0 ignored issues
show
Unused Code introduced by
The assignment to $enc is dead and can be removed.
Loading history...
954
955
        $m1 = new Member();
956
        $m1->write();
957
        $m1Token = $m1->generateAutologinTokenAndStoreHash();
958
959
        $m2 = new Member();
960
        $m2->write();
961
        $m2->generateAutologinTokenAndStoreHash();
962
963
        $this->assertTrue($m1->validateAutoLoginToken($m1Token), 'Passes token validity test against matching member.');
964
        $this->assertFalse($m2->validateAutoLoginToken($m1Token), 'Fails token validity test against other member.');
965
    }
966
967
    public function testRememberMeHashGeneration()
968
    {
969
        /** @var Member $m1 */
970
        $m1 = $this->objFromFixture(Member::class, 'grouplessmember');
971
972
        Injector::inst()->get(IdentityStore::class)->logIn($m1, true);
973
974
        $hashes = RememberLoginHash::get()->filter('MemberID', $m1->ID);
975
        $this->assertEquals($hashes->count(), 1);
976
        /** @var RememberLoginHash $firstHash */
977
        $firstHash = $hashes->first();
978
        $this->assertNotNull($firstHash->DeviceID);
979
        $this->assertNotNull($firstHash->Hash);
980
    }
981
982
    public function testRememberMeHashAutologin()
983
    {
984
        /** @var Member $m1 */
985
        $m1 = $this->objFromFixture(Member::class, 'noexpiry');
986
987
        Injector::inst()->get(IdentityStore::class)->logIn($m1, true);
988
989
        /** @var RememberLoginHash $firstHash */
990
        $firstHash = RememberLoginHash::get()->filter('MemberID', $m1->ID)->first();
991
        $this->assertNotNull($firstHash);
992
993
        // re-generates the hash so we can get the token
994
        $firstHash->Hash = $firstHash->getNewHash($m1);
995
        $token = $firstHash->getToken();
996
        $firstHash->write();
997
998
        $response = $this->get(
999
            'Security/login',
1000
            $this->session(),
1001
            null,
1002
            [
1003
                'alc_enc' => $m1->ID . ':' . $token,
1004
                'alc_device' => $firstHash->DeviceID
1005
            ]
1006
        );
1007
        $message = Convert::raw2xml(
1008
            _t(
1009
                'SilverStripe\\Security\\Member.LOGGEDINAS',
1010
                "You're logged in as {name}.",
1011
                ['name' => $m1->FirstName]
1012
            )
1013
        );
1014
        $this->assertContains($message, $response->getBody());
1015
1016
        $this->logOut();
1017
1018
        // A wrong token or a wrong device ID should not let us autologin
1019
        $response = $this->get(
1020
            'Security/login',
1021
            $this->session(),
1022
            null,
1023
            [
1024
                'alc_enc' => $m1->ID . ':asdfasd' . str_rot13($token),
1025
                'alc_device' => $firstHash->DeviceID
1026
            ]
1027
        );
1028
        $this->assertNotContains($message, $response->getBody());
1029
1030
        $response = $this->get(
1031
            'Security/login',
1032
            $this->session(),
1033
            null,
1034
            [
1035
                'alc_enc' => $m1->ID . ':' . $token,
1036
                'alc_device' => str_rot13($firstHash->DeviceID)
1037
            ]
1038
        );
1039
        $this->assertNotContains($message, $response->getBody());
1040
1041
        // Re-logging (ie 'alc_enc' has expired), and not checking the "Remember Me" option
1042
        // should remove all previous hashes for this device
1043
        $response = $this->post(
1044
            'Security/login/default/LoginForm',
1045
            [
1046
                'Email' => $m1->Email,
1047
                'Password' => '1nitialPassword',
1048
                'action_doLogin' => 'action_doLogin'
1049
            ],
1050
            null,
1051
            $this->session(),
1052
            null,
1053
            [
1054
                'alc_device' => $firstHash->DeviceID
1055
            ]
1056
        );
1057
        $this->assertContains($message, $response->getBody());
1058
        $this->assertEquals(RememberLoginHash::get()->filter('MemberID', $m1->ID)->count(), 0);
1059
    }
1060
1061
    public function testExpiredRememberMeHashAutologin()
1062
    {
1063
        /** @var Member $m1 */
1064
        $m1 = $this->objFromFixture(Member::class, 'noexpiry');
1065
        Injector::inst()->get(IdentityStore::class)->logIn($m1, true);
1066
        /** @var RememberLoginHash $firstHash */
1067
        $firstHash = RememberLoginHash::get()->filter('MemberID', $m1->ID)->first();
1068
        $this->assertNotNull($firstHash);
1069
1070
        // re-generates the hash so we can get the token
1071
        $firstHash->Hash = $firstHash->getNewHash($m1);
1072
        $token = $firstHash->getToken();
1073
        $firstHash->ExpiryDate = '2000-01-01 00:00:00';
1074
        $firstHash->write();
1075
1076
        DBDatetime::set_mock_now('1999-12-31 23:59:59');
1077
1078
        $response = $this->get(
1079
            'Security/login',
1080
            $this->session(),
1081
            null,
1082
            [
1083
                'alc_enc' => $m1->ID . ':' . $token,
1084
                'alc_device' => $firstHash->DeviceID
1085
            ]
1086
        );
1087
        $message = Convert::raw2xml(
1088
            _t(
1089
                'SilverStripe\\Security\\Member.LOGGEDINAS',
1090
                "You're logged in as {name}.",
1091
                ['name' => $m1->FirstName]
1092
            )
1093
        );
1094
        $this->assertContains($message, $response->getBody());
1095
1096
        $this->logOut();
1097
1098
        // re-generates the hash so we can get the token
1099
        $firstHash->Hash = $firstHash->getNewHash($m1);
1100
        $token = $firstHash->getToken();
1101
        $firstHash->ExpiryDate = '2000-01-01 00:00:00';
1102
        $firstHash->write();
1103
1104
        DBDatetime::set_mock_now('2000-01-01 00:00:01');
1105
1106
        $response = $this->get(
1107
            'Security/login',
1108
            $this->session(),
1109
            null,
1110
            [
1111
                'alc_enc' => $m1->ID . ':' . $token,
1112
                'alc_device' => $firstHash->DeviceID
1113
            ]
1114
        );
1115
        $this->assertNotContains($message, $response->getBody());
1116
        $this->logOut();
1117
        DBDatetime::clear_mock_now();
1118
    }
1119
1120
    public function testRememberMeMultipleDevices()
1121
    {
1122
        /** @var Member $m1 */
1123
        $m1 = $this->objFromFixture(Member::class, 'noexpiry');
1124
1125
        // First device
1126
        Injector::inst()->get(IdentityStore::class)->logIn($m1, true);
1127
        Cookie::set('alc_device', null);
1128
        // Second device
1129
        Injector::inst()->get(IdentityStore::class)->logIn($m1, true);
1130
1131
        // Hash of first device
1132
        /** @var RememberLoginHash $firstHash */
1133
        $firstHash = RememberLoginHash::get()->filter('MemberID', $m1->ID)->first();
1134
        $this->assertNotNull($firstHash);
1135
1136
        // Hash of second device
1137
        /** @var RememberLoginHash $secondHash */
1138
        $secondHash = RememberLoginHash::get()->filter('MemberID', $m1->ID)->last();
1139
        $this->assertNotNull($secondHash);
1140
1141
        // DeviceIDs are different
1142
        $this->assertNotEquals($firstHash->DeviceID, $secondHash->DeviceID);
1143
1144
        // re-generates the hashes so we can get the tokens
1145
        $firstHash->Hash = $firstHash->getNewHash($m1);
1146
        $firstToken = $firstHash->getToken();
1147
        $firstHash->write();
1148
1149
        $secondHash->Hash = $secondHash->getNewHash($m1);
1150
        $secondToken = $secondHash->getToken();
1151
        $secondHash->write();
1152
1153
        // Accessing the login page should show the user's name straight away
1154
        $response = $this->get(
1155
            'Security/login',
1156
            $this->session(),
1157
            null,
1158
            [
1159
                'alc_enc' => $m1->ID . ':' . $firstToken,
1160
                'alc_device' => $firstHash->DeviceID
1161
            ]
1162
        );
1163
        $message = Convert::raw2xml(
1164
            _t(
1165
                'SilverStripe\\Security\\Member.LOGGEDINAS',
1166
                "You're logged in as {name}.",
1167
                ['name' => $m1->FirstName]
1168
            )
1169
        );
1170
        $this->assertContains($message, $response->getBody());
1171
1172
        // Test that removing session but not cookie keeps user
1173
        /** @var SessionAuthenticationHandler $sessionHandler */
1174
        $sessionHandler = Injector::inst()->get(SessionAuthenticationHandler::class);
1175
        $sessionHandler->logOut();
1176
        Security::setCurrentUser(null);
1177
1178
        // Accessing the login page from the second device
1179
        $response = $this->get(
1180
            'Security/login',
1181
            $this->session(),
1182
            null,
1183
            [
1184
                'alc_enc' => $m1->ID . ':' . $secondToken,
1185
                'alc_device' => $secondHash->DeviceID
1186
            ]
1187
        );
1188
        $this->assertContains($message, $response->getBody());
1189
1190
        // Logging out from the second device - only one device being logged out
1191
        RememberLoginHash::config()->update('logout_across_devices', false);
1192
        $this->get(
1193
            'Security/logout',
1194
            $this->session(),
1195
            null,
1196
            [
1197
                'alc_enc' => $m1->ID . ':' . $secondToken,
1198
                'alc_device' => $secondHash->DeviceID
1199
            ]
1200
        );
1201
        $this->assertEquals(
1202
            1,
1203
            RememberLoginHash::get()->filter(['MemberID'=>$m1->ID, 'DeviceID'=>$firstHash->DeviceID])->count()
1204
        );
1205
1206
        // If session-manager module is installed then logout_across_devices is modified so skip
1207
        if (!class_exists(LoginSession::class)) {
1208
            // Logging out from any device when all login hashes should be removed
1209
            RememberLoginHash::config()->update('logout_across_devices', true);
1210
            Injector::inst()->get(IdentityStore::class)->logIn($m1, true);
1211
            $this->get('Security/logout', $this->session());
1212
            $this->assertEquals(
1213
                0,
1214
                RememberLoginHash::get()->filter('MemberID', $m1->ID)->count()
1215
            );
1216
        }
1217
    }
1218
1219
    public function testCanDelete()
1220
    {
1221
        $admin1 = $this->objFromFixture(Member::class, 'admin');
1222
        $admin2 = $this->objFromFixture(Member::class, 'other-admin');
1223
        $member1 = $this->objFromFixture(Member::class, 'grouplessmember');
1224
        $member2 = $this->objFromFixture(Member::class, 'noformatmember');
1225
1226
        $this->assertTrue(
1227
            $admin1->canDelete($admin2),
1228
            'Admins can delete other admins'
1229
        );
1230
        $this->assertTrue(
1231
            $member1->canDelete($admin2),
1232
            'Admins can delete non-admins'
1233
        );
1234
        $this->assertFalse(
1235
            $admin1->canDelete($admin1),
1236
            'Admins can not delete themselves'
1237
        );
1238
        $this->assertFalse(
1239
            $member1->canDelete($member2),
1240
            'Non-admins can not delete other non-admins'
1241
        );
1242
        $this->assertFalse(
1243
            $member1->canDelete($member1),
1244
            'Non-admins can not delete themselves'
1245
        );
1246
    }
1247
1248
    public function testFailedLoginCount()
1249
    {
1250
        $maxFailedLoginsAllowed = 3;
1251
        //set up the config variables to enable login lockouts
1252
        Member::config()->update('lock_out_after_incorrect_logins', $maxFailedLoginsAllowed);
1253
1254
        /** @var Member $member */
1255
        $member = $this->objFromFixture(Member::class, 'test');
1256
        $failedLoginCount = $member->FailedLoginCount;
1257
1258
        for ($i = 1; $i < $maxFailedLoginsAllowed; ++$i) {
1259
            $member->registerFailedLogin();
1260
1261
            $this->assertEquals(
1262
                ++$failedLoginCount,
1263
                $member->FailedLoginCount,
1264
                'Failed to increment $member->FailedLoginCount'
1265
            );
1266
1267
            $this->assertTrue(
1268
                $member->canLogin(),
1269
                "Member has been locked out too early"
1270
            );
1271
        }
1272
    }
1273
1274
    public function testMemberValidator()
1275
    {
1276
        // clear custom requirements for this test
1277
        Member_Validator::config()->update('customRequired', null);
1278
        /** @var Member $memberA */
1279
        $memberA = $this->objFromFixture(Member::class, 'admin');
1280
        /** @var Member $memberB */
1281
        $memberB = $this->objFromFixture(Member::class, 'test');
1282
1283
        // create a blank form
1284
        $form = new MemberTest\ValidatorForm();
1285
1286
        $validator = new Member_Validator();
1287
        $validator->setForm($form);
1288
1289
        // Simulate creation of a new member via form, but use an existing member identifier
1290
        $fail = $validator->php(
1291
            [
1292
            'FirstName' => 'Test',
1293
            'Email' => $memberA->Email
1294
            ]
1295
        );
1296
1297
        $this->assertFalse(
1298
            $fail,
1299
            'Member_Validator must fail when trying to create new Member with existing Email.'
1300
        );
1301
1302
        // populate the form with values from another member
1303
        $form->loadDataFrom($memberB);
1304
1305
        // Assign the validator to an existing member
1306
        // (this is basically the same as passing the member ID with the form data)
1307
        $validator->setForMember($memberB);
1308
1309
        // Simulate update of a member via form and use an existing member Email
1310
        $fail = $validator->php(
1311
            [
1312
            'FirstName' => 'Test',
1313
            'Email' => $memberA->Email
1314
            ]
1315
        );
1316
1317
        // Simulate update to a new Email address
1318
        $pass1 = $validator->php(
1319
            [
1320
            'FirstName' => 'Test',
1321
            'Email' => '[email protected]'
1322
            ]
1323
        );
1324
1325
        // Pass in the same Email address that the member already has. Ensure that case is valid
1326
        $pass2 = $validator->php(
1327
            [
1328
            'FirstName' => 'Test',
1329
            'Surname' => 'User',
1330
            'Email' => $memberB->Email
1331
            ]
1332
        );
1333
1334
        $this->assertFalse(
1335
            $fail,
1336
            'Member_Validator must fail when trying to update existing member with existing Email.'
1337
        );
1338
1339
        $this->assertTrue(
1340
            $pass1,
1341
            'Member_Validator must pass when Email is updated to a value that\'s not in use.'
1342
        );
1343
1344
        $this->assertTrue(
1345
            $pass2,
1346
            'Member_Validator must pass when Member updates his own Email to the already existing value.'
1347
        );
1348
    }
1349
1350
    public function testMemberValidatorWithExtensions()
1351
    {
1352
        // clear custom requirements for this test
1353
        Member_Validator::config()->update('customRequired', null);
1354
1355
        // create a blank form
1356
        $form = new MemberTest\ValidatorForm();
1357
1358
        // Test extensions
1359
        Member_Validator::add_extension(MemberTest\SurnameMustMatchFirstNameExtension::class);
1360
        $validator = new Member_Validator();
1361
        $validator->setForm($form);
1362
1363
        // This test should fail, since the extension enforces FirstName == Surname
1364
        $fail = $validator->php(
1365
            [
1366
            'FirstName' => 'Test',
1367
            'Surname' => 'User',
1368
            'Email' => '[email protected]'
1369
            ]
1370
        );
1371
1372
        $pass = $validator->php(
1373
            [
1374
            'FirstName' => 'Test',
1375
            'Surname' => 'Test',
1376
            'Email' => '[email protected]'
1377
            ]
1378
        );
1379
1380
        $this->assertFalse(
1381
            $fail,
1382
            'Member_Validator must fail because of added extension.'
1383
        );
1384
1385
        $this->assertTrue(
1386
            $pass,
1387
            'Member_Validator must succeed, since it meets all requirements.'
1388
        );
1389
1390
        // Add another extension that always fails. This ensures that all extensions are considered in the validation
1391
        Member_Validator::add_extension(MemberTest\AlwaysFailExtension::class);
1392
        $validator = new Member_Validator();
1393
        $validator->setForm($form);
1394
1395
        // Even though the data is valid, This test should still fail, since one extension always returns false
1396
        $fail = $validator->php(
1397
            [
1398
            'FirstName' => 'Test',
1399
            'Surname' => 'Test',
1400
            'Email' => '[email protected]'
1401
            ]
1402
        );
1403
1404
        $this->assertFalse(
1405
            $fail,
1406
            'Member_Validator must fail because of added extensions.'
1407
        );
1408
1409
        // Remove added extensions
1410
        Member_Validator::remove_extension(MemberTest\AlwaysFailExtension::class);
1411
        Member_Validator::remove_extension(MemberTest\SurnameMustMatchFirstNameExtension::class);
1412
    }
1413
1414
    public function testCustomMemberValidator()
1415
    {
1416
        // clear custom requirements for this test
1417
        Member_Validator::config()->update('customRequired', null);
1418
1419
        $member = $this->objFromFixture(Member::class, 'admin');
1420
1421
        $form = new MemberTest\ValidatorForm();
1422
        $form->loadDataFrom($member);
1423
1424
        $validator = new Member_Validator();
1425
        $validator->setForm($form);
1426
1427
        $pass = $validator->php(
1428
            [
1429
            'FirstName' => 'Borris',
1430
            'Email' => '[email protected]'
1431
            ]
1432
        );
1433
1434
        $fail = $validator->php(
1435
            [
1436
            'Email' => '[email protected]',
1437
            'Surname' => ''
1438
            ]
1439
        );
1440
1441
        $this->assertTrue($pass, 'Validator requires a FirstName and Email');
1442
        $this->assertFalse($fail, 'Missing FirstName');
1443
1444
        $ext = new MemberTest\ValidatorExtension();
1445
        $ext->updateValidator($validator);
1446
1447
        $pass = $validator->php(
1448
            [
1449
            'FirstName' => 'Borris',
1450
            'Email' => '[email protected]'
1451
            ]
1452
        );
1453
1454
        $fail = $validator->php(
1455
            [
1456
            'Email' => '[email protected]'
1457
            ]
1458
        );
1459
1460
        $this->assertFalse($pass, 'Missing surname');
1461
        $this->assertFalse($fail, 'Missing surname value');
1462
1463
        $fail = $validator->php(
1464
            [
1465
            'Email' => '[email protected]',
1466
            'Surname' => 'Silverman'
1467
            ]
1468
        );
1469
1470
        $this->assertTrue($fail, 'Passes with email and surname now (no firstname)');
1471
    }
1472
1473
    public function testCurrentUser()
1474
    {
1475
        $this->assertNull(Security::getCurrentUser());
1476
1477
        $adminMember = $this->objFromFixture(Member::class, 'admin');
1478
        $this->logInAs($adminMember);
1479
1480
        $userFromSession = Security::getCurrentUser();
1481
        $this->assertEquals($adminMember->ID, $userFromSession->ID);
1482
    }
1483
1484
    /**
1485
     * @covers \SilverStripe\Security\Member::actAs()
1486
     */
1487
    public function testActAsUserPermissions()
1488
    {
1489
        $this->assertNull(Security::getCurrentUser());
1490
1491
        /** @var Member $adminMember */
1492
        $adminMember = $this->objFromFixture(Member::class, 'admin');
1493
1494
        // Check acting as admin when not logged in
1495
        $checkAdmin = Member::actAs($adminMember, function () {
1496
            return Permission::check('ADMIN');
1497
        });
1498
        $this->assertTrue($checkAdmin);
1499
1500
        // Check nesting
1501
        $checkAdmin = Member::actAs($adminMember, function () {
1502
            return Member::actAs(null, function () {
1503
                return Permission::check('ADMIN');
1504
            });
1505
        });
1506
        $this->assertFalse($checkAdmin);
1507
1508
        // Check logging in as non-admin user
1509
        $this->logInWithPermission('TEST_PERMISSION');
1510
1511
        $hasPerm = Member::actAs(null, function () {
1512
            return Permission::check('TEST_PERMISSION');
1513
        });
1514
        $this->assertFalse($hasPerm);
1515
1516
        // Check permissions can be promoted
1517
        $checkAdmin = Member::actAs($adminMember, function () {
1518
            return Permission::check('ADMIN');
1519
        });
1520
        $this->assertTrue($checkAdmin);
1521
    }
1522
1523
    /**
1524
     * @covers \SilverStripe\Security\Member::actAs()
1525
     */
1526
    public function testActAsUser()
1527
    {
1528
        $this->assertNull(Security::getCurrentUser());
1529
1530
        /** @var Member $adminMember */
1531
        $adminMember = $this->objFromFixture(Member::class, 'admin');
1532
        $member = Member::actAs($adminMember, function () {
1533
            return Security::getCurrentUser();
1534
        });
1535
        $this->assertEquals($adminMember->ID, $member->ID);
1536
1537
        // Check nesting
1538
        $member = Member::actAs($adminMember, function () {
1539
            return Member::actAs(null, function () {
1540
                return Security::getCurrentUser();
1541
            });
1542
        });
1543
        $this->assertEmpty($member);
1544
    }
1545
1546
    public function testChangePasswordWithExtensionsThatModifyValidationResult()
1547
    {
1548
        // Default behaviour
1549
        /** @var Member $member */
1550
        $member = $this->objFromFixture(Member::class, 'admin');
1551
        $result = $member->changePassword('my-secret-new-password');
1552
        $this->assertInstanceOf(ValidationResult::class, $result);
1553
        $this->assertTrue($result->isValid());
1554
1555
        // With an extension added
1556
        Member::add_extension(MemberTest\ExtendedChangePasswordExtension::class);
1557
        $member = $this->objFromFixture(Member::class, 'admin');
1558
        $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

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