Completed
Pull Request — master (#7007)
by Simon
08:19
created

SecurityTest::testChangePasswordForLoggedInUsers()   B

Complexity

Conditions 1
Paths 1

Size

Total Lines 24
Code Lines 16

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 16
nc 1
nop 0
dl 0
loc 24
rs 8.9713
c 0
b 0
f 0
1
<?php
2
3
namespace SilverStripe\Security\Tests;
4
5
use SilverStripe\Dev\Debug;
6
use SilverStripe\ORM\DataObject;
7
use SilverStripe\ORM\FieldType\DBDatetime;
8
use SilverStripe\ORM\FieldType\DBClassName;
9
use SilverStripe\ORM\DB;
10
use SilverStripe\ORM\ValidationResult;
11
use SilverStripe\Security\LoginAttempt;
12
use SilverStripe\Security\Member;
13
use SilverStripe\Security\MemberAuthenticator\MemberAuthenticator;
14
use SilverStripe\Security\Security;
15
use SilverStripe\Core\Config\Config;
16
use SilverStripe\Core\Convert;
17
use SilverStripe\Dev\FunctionalTest;
18
use SilverStripe\Dev\TestOnly;
19
use SilverStripe\Control\HTTPResponse;
20
use SilverStripe\Control\Session;
21
use SilverStripe\Control\Director;
22
use SilverStripe\Control\Controller;
23
use SilverStripe\i18n\i18n;
24
25
/**
26
 * Test the security class, including log-in form, change password form, etc
27
 */
28
class SecurityTest extends FunctionalTest
29
{
30
    protected static $fixture_file = 'MemberTest.yml';
31
32
    protected $autoFollowRedirection = false;
33
34
    protected $priorAuthenticators = array();
35
36
    protected $priorDefaultAuthenticator = null;
37
38
    protected $priorUniqueIdentifierField = null;
39
40
    protected $priorRememberUsername = null;
41
42
    protected static $extra_controllers = [
43
        SecurityTest\NullController::class,
44
        SecurityTest\SecuredController::class,
45
    ];
46
47
    protected function setUp()
48
    {
49
        // Set to an empty array of authenticators to enable the default
50
        Config::modify()->set(MemberAuthenticator::class, 'authenticators', []);
51
        Config::modify()->set(MemberAuthenticator::class, 'default_authenticator', MemberAuthenticator::class);
52
53
        // And that the unique identified field is 'Email'
54
        $this->priorUniqueIdentifierField = Member::config()->unique_identifier_field;
0 ignored issues
show
Documentation introduced by
The property unique_identifier_field does not exist on object<SilverStripe\Core\Config\Config_ForClass>. Since you implemented __set, maybe consider adding a @property annotation.

Since your code implements the magic setter _set, this function will be called for any write access on an undefined variable. You can add the @property annotation to your class or interface to document the existence of this variable.

<?php

/**
 * @property int $x
 * @property int $y
 * @property string $text
 */
class MyLabel
{
    private $properties;

    private $allowedProperties = array('x', 'y', 'text');

    public function __get($name)
    {
        if (isset($properties[$name]) && in_array($name, $this->allowedProperties)) {
            return $properties[$name];
        } else {
            return null;
        }
    }

    public function __set($name, $value)
    {
        if (in_array($name, $this->allowedProperties)) {
            $properties[$name] = $value;
        } else {
            throw new \LogicException("Property $name is not defined.");
        }
    }

}

Since the property has write access only, you can use the @property-write annotation instead.

Of course, you may also just have mistyped another name, in which case you should fix the error.

See also the PhpDoc documentation for @property.

Loading history...
55
        $this->priorRememberUsername = Security::config()->remember_username;
0 ignored issues
show
Documentation introduced by
The property remember_username does not exist on object<SilverStripe\Core\Config\Config_ForClass>. Since you implemented __set, maybe consider adding a @property annotation.

Since your code implements the magic setter _set, this function will be called for any write access on an undefined variable. You can add the @property annotation to your class or interface to document the existence of this variable.

<?php

/**
 * @property int $x
 * @property int $y
 * @property string $text
 */
class MyLabel
{
    private $properties;

    private $allowedProperties = array('x', 'y', 'text');

    public function __get($name)
    {
        if (isset($properties[$name]) && in_array($name, $this->allowedProperties)) {
            return $properties[$name];
        } else {
            return null;
        }
    }

    public function __set($name, $value)
    {
        if (in_array($name, $this->allowedProperties)) {
            $properties[$name] = $value;
        } else {
            throw new \LogicException("Property $name is not defined.");
        }
    }

}

Since the property has write access only, you can use the @property-write annotation instead.

Of course, you may also just have mistyped another name, in which case you should fix the error.

See also the PhpDoc documentation for @property.

Loading history...
56
        /**
57
         * @skipUpgrade
58
         */
59
        Member::config()->unique_identifier_field = 'Email';
0 ignored issues
show
Documentation introduced by
The property unique_identifier_field does not exist on object<SilverStripe\Core\Config\Config_ForClass>. Since you implemented __set, maybe consider adding a @property annotation.

Since your code implements the magic setter _set, this function will be called for any write access on an undefined variable. You can add the @property annotation to your class or interface to document the existence of this variable.

<?php

/**
 * @property int $x
 * @property int $y
 * @property string $text
 */
class MyLabel
{
    private $properties;

    private $allowedProperties = array('x', 'y', 'text');

    public function __get($name)
    {
        if (isset($properties[$name]) && in_array($name, $this->allowedProperties)) {
            return $properties[$name];
        } else {
            return null;
        }
    }

    public function __set($name, $value)
    {
        if (in_array($name, $this->allowedProperties)) {
            $properties[$name] = $value;
        } else {
            throw new \LogicException("Property $name is not defined.");
        }
    }

}

Since the property has write access only, you can use the @property-write annotation instead.

Of course, you may also just have mistyped another name, in which case you should fix the error.

See also the PhpDoc documentation for @property.

Loading history...
60
61
        parent::setUp();
62
63
        Config::modify()->merge('SilverStripe\\Control\\Director', 'alternate_base_url', '/');
64
    }
65
66
    protected function tearDown()
67
    {
68
        // Restore selected authenticator
69
70
        // MemberAuthenticator might not actually be present
71
        // Config::modify()->set(Authenticator::class, 'authenticators', $this->priorAuthenticators);
0 ignored issues
show
Unused Code Comprehensibility introduced by
60% of this comment could be valid code. Did you maybe forget this after debugging?

Sometimes obsolete code just ends up commented out instead of removed. In this case it is better to remove the code once you have checked you do not need it.

The code might also have been commented out for debugging purposes. In this case it is vital that someone uncomments it again or your project may behave in very unexpected ways in production.

This check looks for comments that seem to be mostly valid code and reports them.

Loading history...
72
        // Config::modify()->set(Authenticator::class, 'default_authenticator', $this->priorDefaultAuthenticator);
0 ignored issues
show
Unused Code Comprehensibility introduced by
60% of this comment could be valid code. Did you maybe forget this after debugging?

Sometimes obsolete code just ends up commented out instead of removed. In this case it is better to remove the code once you have checked you do not need it.

The code might also have been commented out for debugging purposes. In this case it is vital that someone uncomments it again or your project may behave in very unexpected ways in production.

This check looks for comments that seem to be mostly valid code and reports them.

Loading history...
73
74
        // Restore unique identifier field
75
        Member::config()->unique_identifier_field = $this->priorUniqueIdentifierField;
0 ignored issues
show
Documentation introduced by
The property unique_identifier_field does not exist on object<SilverStripe\Core\Config\Config_ForClass>. Since you implemented __set, maybe consider adding a @property annotation.

Since your code implements the magic setter _set, this function will be called for any write access on an undefined variable. You can add the @property annotation to your class or interface to document the existence of this variable.

<?php

/**
 * @property int $x
 * @property int $y
 * @property string $text
 */
class MyLabel
{
    private $properties;

    private $allowedProperties = array('x', 'y', 'text');

    public function __get($name)
    {
        if (isset($properties[$name]) && in_array($name, $this->allowedProperties)) {
            return $properties[$name];
        } else {
            return null;
        }
    }

    public function __set($name, $value)
    {
        if (in_array($name, $this->allowedProperties)) {
            $properties[$name] = $value;
        } else {
            throw new \LogicException("Property $name is not defined.");
        }
    }

}

Since the property has write access only, you can use the @property-write annotation instead.

Of course, you may also just have mistyped another name, in which case you should fix the error.

See also the PhpDoc documentation for @property.

Loading history...
76
        Security::config()->remember_username = $this->priorRememberUsername;
0 ignored issues
show
Documentation introduced by
The property remember_username does not exist on object<SilverStripe\Core\Config\Config_ForClass>. Since you implemented __set, maybe consider adding a @property annotation.

Since your code implements the magic setter _set, this function will be called for any write access on an undefined variable. You can add the @property annotation to your class or interface to document the existence of this variable.

<?php

/**
 * @property int $x
 * @property int $y
 * @property string $text
 */
class MyLabel
{
    private $properties;

    private $allowedProperties = array('x', 'y', 'text');

    public function __get($name)
    {
        if (isset($properties[$name]) && in_array($name, $this->allowedProperties)) {
            return $properties[$name];
        } else {
            return null;
        }
    }

    public function __set($name, $value)
    {
        if (in_array($name, $this->allowedProperties)) {
            $properties[$name] = $value;
        } else {
            throw new \LogicException("Property $name is not defined.");
        }
    }

}

Since the property has write access only, you can use the @property-write annotation instead.

Of course, you may also just have mistyped another name, in which case you should fix the error.

See also the PhpDoc documentation for @property.

Loading history...
77
78
        parent::tearDown();
79
    }
80
81
    public function testAccessingAuthenticatedPageRedirectsToLoginForm()
82
    {
83
        $this->autoFollowRedirection = false;
84
85
        $response = $this->get('SecurityTest_SecuredController');
86
        $this->assertEquals(302, $response->getStatusCode());
87
        $this->assertContains(
88
            Config::inst()->get(Security::class, 'login_url'),
89
            $response->getHeader('Location')
90
        );
91
92
        $this->logInWithPermission('ADMIN');
93
        $response = $this->get('SecurityTest_SecuredController');
94
        $this->assertEquals(200, $response->getStatusCode());
95
        $this->assertContains('Success', $response->getBody());
96
97
        $this->autoFollowRedirection = true;
98
    }
99
100
    public function testPermissionFailureSetsCorrectFormMessages()
101
    {
102
        Config::nest();
103
104
        // Controller that doesn't attempt redirections
105
        $controller = new SecurityTest\NullController();
106
        $controller->setResponse(new HTTPResponse());
107
108
        Security::permissionFailure($controller, array('default' => 'Oops, not allowed'));
109
        $this->assertEquals('Oops, not allowed', Session::get('Security.Message.message'));
110
111
        // Test that config values are used correctly
112
        Config::inst()->update(Security::class, 'default_message_set', 'stringvalue');
0 ignored issues
show
Bug introduced by
It seems like you code against a concrete implementation and not the interface SilverStripe\Config\Coll...nfigCollectionInterface as the method update() does only exist in the following implementations of said interface: SilverStripe\Config\Coll...\MemoryConfigCollection.

Let’s take a look at an example:

interface User
{
    /** @return string */
    public function getPassword();
}

class MyUser implements User
{
    public function getPassword()
    {
        // return something
    }

    public function getDisplayName()
    {
        // return some name.
    }
}

class AuthSystem
{
    public function authenticate(User $user)
    {
        $this->logger->info(sprintf('Authenticating %s.', $user->getDisplayName()));
        // do something.
    }
}

In the above example, the authenticate() method works fine as long as you just pass instances of MyUser. However, if you now also want to pass a different implementation of User which does not have a getDisplayName() method, the code will break.

Available Fixes

  1. Change the type-hint for the parameter:

    class AuthSystem
    {
        public function authenticate(MyUser $user) { /* ... */ }
    }
    
  2. Add an additional type-check:

    class AuthSystem
    {
        public function authenticate(User $user)
        {
            if ($user instanceof MyUser) {
                $this->logger->info(/** ... */);
            }
    
            // or alternatively
            if ( ! $user instanceof MyUser) {
                throw new \LogicException(
                    '$user must be an instance of MyUser, '
                   .'other instances are not supported.'
                );
            }
    
        }
    }
    
Note: PHP Analyzer uses reverse abstract interpretation to narrow down the types inside the if block in such a case.
  1. Add the method to the interface:

    interface User
    {
        /** @return string */
        public function getPassword();
    
        /** @return string */
        public function getDisplayName();
    }
    
Loading history...
113
        Security::permissionFailure($controller);
114
        $this->assertEquals(
115
            'stringvalue',
116
            Session::get('Security.Message.message'),
117
            'Default permission failure message value was not present'
118
        );
119
120
        Config::modify()->remove(Security::class, 'default_message_set');
121
        Config::modify()->merge(Security::class, 'default_message_set', array('default' => 'arrayvalue'));
122
        Security::permissionFailure($controller);
123
        $this->assertEquals(
124
            'arrayvalue',
125
            Session::get('Security.Message.message'),
126
            'Default permission failure message value was not present'
127
        );
128
129
        // Test that non-default messages work.
130
        // NOTE: we inspect the response body here as the session message has already
131
        // been fetched and output as part of it, so has been removed from the session
132
        $this->logInWithPermission('EDITOR');
133
134
        Config::inst()->update(
0 ignored issues
show
Bug introduced by
It seems like you code against a concrete implementation and not the interface SilverStripe\Config\Coll...nfigCollectionInterface as the method update() does only exist in the following implementations of said interface: SilverStripe\Config\Coll...\MemoryConfigCollection.

Let’s take a look at an example:

interface User
{
    /** @return string */
    public function getPassword();
}

class MyUser implements User
{
    public function getPassword()
    {
        // return something
    }

    public function getDisplayName()
    {
        // return some name.
    }
}

class AuthSystem
{
    public function authenticate(User $user)
    {
        $this->logger->info(sprintf('Authenticating %s.', $user->getDisplayName()));
        // do something.
    }
}

In the above example, the authenticate() method works fine as long as you just pass instances of MyUser. However, if you now also want to pass a different implementation of User which does not have a getDisplayName() method, the code will break.

Available Fixes

  1. Change the type-hint for the parameter:

    class AuthSystem
    {
        public function authenticate(MyUser $user) { /* ... */ }
    }
    
  2. Add an additional type-check:

    class AuthSystem
    {
        public function authenticate(User $user)
        {
            if ($user instanceof MyUser) {
                $this->logger->info(/** ... */);
            }
    
            // or alternatively
            if ( ! $user instanceof MyUser) {
                throw new \LogicException(
                    '$user must be an instance of MyUser, '
                   .'other instances are not supported.'
                );
            }
    
        }
    }
    
Note: PHP Analyzer uses reverse abstract interpretation to narrow down the types inside the if block in such a case.
  1. Add the method to the interface:

    interface User
    {
        /** @return string */
        public function getPassword();
    
        /** @return string */
        public function getDisplayName();
    }
    
Loading history...
135
            Security::class,
136
            'default_message_set',
137
            array('default' => 'default', 'alreadyLoggedIn' => 'You are already logged in!')
138
        );
139
        Security::permissionFailure($controller);
140
        $this->assertContains(
141
            'You are already logged in!',
142
            $controller->getResponse()->getBody(),
143
            'Custom permission failure message was ignored'
144
        );
145
146
        Security::permissionFailure(
147
            $controller,
148
            array('default' => 'default', 'alreadyLoggedIn' => 'One-off failure message')
149
        );
150
        $this->assertContains(
151
            'One-off failure message',
152
            $controller->getResponse()->getBody(),
153
            "Message set passed to Security::permissionFailure() didn't override Config values"
154
        );
155
156
        Config::unnest();
157
    }
158
159
    /**
160
     * Follow all redirects recursively
161
     *
162
     * @param  string $url
163
     * @param  int    $limit Max number of requests
164
     * @return HTTPResponse
165
     */
166
    protected function getRecursive($url, $limit = 10)
167
    {
168
        $this->cssParser = null;
169
        $response = $this->mainSession->get($url);
170
        while (--$limit > 0 && $response instanceof HTTPResponse && $response->getHeader('Location')) {
171
            $response = $this->mainSession->followRedirection();
172
        }
173
        return $response;
174
    }
175
176
    public function testAutomaticRedirectionOnLogin()
177
    {
178
        // BackURL with permission error (not authenticated) should not redirect
179
        if ($member = Security::getCurrentUser()) {
180
            Security::setCurrentUser(null);
181
        }
182
        $response = $this->getRecursive('SecurityTest_SecuredController');
183
        $this->assertContains(Convert::raw2xml("That page is secured."), $response->getBody());
184
        $this->assertContains('<input type="submit" name="action_doLogin"', $response->getBody());
185
186
        // Non-logged in user should not be redirected, but instead shown the login form
187
        // No message/context is available as the user has not attempted to view the secured controller
188
        $response = $this->getRecursive('Security/login?BackURL=SecurityTest_SecuredController/');
189
        $this->assertNotContains(Convert::raw2xml("That page is secured."), $response->getBody());
190
        $this->assertNotContains(Convert::raw2xml("You don't have access to this page"), $response->getBody());
191
        $this->assertContains('<input type="submit" name="action_doLogin"', $response->getBody());
192
193
        // BackURL with permission error (wrong permissions) should not redirect
194
        $this->logInAs('grouplessmember');
195
        $response = $this->getRecursive('SecurityTest_SecuredController');
196
        $this->assertContains(Convert::raw2xml("You don't have access to this page"), $response->getBody());
197
        $this->assertContains(
198
            '<input type="submit" name="action_logout" value="Log in as someone else"',
199
            $response->getBody()
200
        );
201
202
        // Directly accessing this page should attempt to follow the BackURL, but stop when it encounters the error
203
        $response = $this->getRecursive('Security/login?BackURL=SecurityTest_SecuredController/');
204
        $this->assertContains(Convert::raw2xml("You don't have access to this page"), $response->getBody());
205
        $this->assertContains(
206
            '<input type="submit" name="action_logout" value="Log in as someone else"',
207
            $response->getBody()
208
        );
209
210
        // Check correctly logged in admin doesn't generate the same errors
211
        $this->logInAs('admin');
212
        $response = $this->getRecursive('SecurityTest_SecuredController');
213
        $this->assertContains(Convert::raw2xml("Success"), $response->getBody());
214
215
        // Directly accessing this page should attempt to follow the BackURL and succeed
216
        $response = $this->getRecursive('Security/login?BackURL=SecurityTest_SecuredController/');
217
        $this->assertContains(Convert::raw2xml("Success"), $response->getBody());
218
    }
219
220
    public function testLogInAsSomeoneElse()
221
    {
222
        $member = DataObject::get_one(Member::class);
223
224
        /* Log in with any user that we can find */
225
        Security::setCurrentUser($member);
226
227
        /* View the Security/login page */
228
        $response = $this->get(Config::inst()->get(Security::class, 'login_url'));
229
230
        $items = $this->cssParser()->getBySelector('#MemberLoginForm_LoginForm input.action');
231
232
        /* We have only 1 input, one to allow the user to log in as someone else */
233
        $this->assertEquals(count($items), 1, 'There is 1 input, allowing the user to log in as someone else.');
234
235
        $this->autoFollowRedirection = true;
236
237
        /* Submit the form, using only the logout action and a hidden field for the authenticator */
238
        $response = $this->submitForm(
239
            'MemberLoginForm_LoginForm',
240
            null,
241
            array(
242
                'action_logout' => 1,
243
            )
244
        );
245
246
        /* We get a good response */
247
        $this->assertEquals($response->getStatusCode(), 200, 'We have a 200 OK response');
248
        $this->assertNotNull($response->getBody(), 'There is body content on the page');
249
250
        /* Log the user out */
251
        Security::setCurrentUser(null);
252
    }
253
254
    public function testMemberIDInSessionDoesntExistInDatabaseHasToLogin()
255
    {
256
        /* Log in with a Member ID that doesn't exist in the DB */
257
        $this->session()->inst_set('loggedInAs', 500);
258
259
        $this->autoFollowRedirection = true;
260
261
        /* Attempt to get into the admin section */
262
        $response = $this->get(Config::inst()->get(Security::class, 'login_url'));
263
264
        $items = $this->cssParser()->getBySelector('#MemberLoginForm_LoginForm input.text');
265
266
        /* We have 2 text inputs - one for email, and another for the password */
267
        $this->assertEquals(count($items), 2, 'There are 2 inputs - one for email, another for password');
268
269
        $this->autoFollowRedirection = false;
270
271
        /* Log the user out */
272
        $this->session()->inst_set('loggedInAs', null);
273
    }
274
275
    public function testLoginUsernamePersists()
276
    {
277
        // Test that username does not persist
278
        $this->session()->inst_set('SessionForms.MemberLoginForm.Email', '[email protected]');
279
        Security::config()->remember_username = false;
0 ignored issues
show
Documentation introduced by
The property remember_username does not exist on object<SilverStripe\Core\Config\Config_ForClass>. Since you implemented __set, maybe consider adding a @property annotation.

Since your code implements the magic setter _set, this function will be called for any write access on an undefined variable. You can add the @property annotation to your class or interface to document the existence of this variable.

<?php

/**
 * @property int $x
 * @property int $y
 * @property string $text
 */
class MyLabel
{
    private $properties;

    private $allowedProperties = array('x', 'y', 'text');

    public function __get($name)
    {
        if (isset($properties[$name]) && in_array($name, $this->allowedProperties)) {
            return $properties[$name];
        } else {
            return null;
        }
    }

    public function __set($name, $value)
    {
        if (in_array($name, $this->allowedProperties)) {
            $properties[$name] = $value;
        } else {
            throw new \LogicException("Property $name is not defined.");
        }
    }

}

Since the property has write access only, you can use the @property-write annotation instead.

Of course, you may also just have mistyped another name, in which case you should fix the error.

See also the PhpDoc documentation for @property.

Loading history...
280
        $this->get(Config::inst()->get(Security::class, 'login_url'));
281
        $items = $this
282
            ->cssParser()
283
            ->getBySelector('#MemberLoginForm_LoginForm #MemberLoginForm_LoginForm_Email');
284
        $this->assertEquals(1, count($items));
285
        $this->assertEmpty((string)$items[0]->attributes()->value);
286
        $this->assertEquals('off', (string)$items[0]->attributes()->autocomplete);
287
        $form = $this->cssParser()->getBySelector('#MemberLoginForm_LoginForm');
288
        $this->assertEquals(1, count($form));
289
        $this->assertEquals('off', (string)$form[0]->attributes()->autocomplete);
290
291
        // Test that username does persist when necessary
292
        $this->session()->inst_set('SessionForms.MemberLoginForm.Email', '[email protected]');
293
        Security::config()->remember_username = true;
0 ignored issues
show
Documentation introduced by
The property remember_username does not exist on object<SilverStripe\Core\Config\Config_ForClass>. Since you implemented __set, maybe consider adding a @property annotation.

Since your code implements the magic setter _set, this function will be called for any write access on an undefined variable. You can add the @property annotation to your class or interface to document the existence of this variable.

<?php

/**
 * @property int $x
 * @property int $y
 * @property string $text
 */
class MyLabel
{
    private $properties;

    private $allowedProperties = array('x', 'y', 'text');

    public function __get($name)
    {
        if (isset($properties[$name]) && in_array($name, $this->allowedProperties)) {
            return $properties[$name];
        } else {
            return null;
        }
    }

    public function __set($name, $value)
    {
        if (in_array($name, $this->allowedProperties)) {
            $properties[$name] = $value;
        } else {
            throw new \LogicException("Property $name is not defined.");
        }
    }

}

Since the property has write access only, you can use the @property-write annotation instead.

Of course, you may also just have mistyped another name, in which case you should fix the error.

See also the PhpDoc documentation for @property.

Loading history...
294
        $this->get(Config::inst()->get(Security::class, 'login_url'));
295
        $items = $this
296
            ->cssParser()
297
            ->getBySelector('#MemberLoginForm_LoginForm #MemberLoginForm_LoginForm_Email');
298
        $this->assertEquals(1, count($items));
299
        $this->assertEquals('[email protected]', (string)$items[0]->attributes()->value);
300
        $this->assertNotEquals('off', (string)$items[0]->attributes()->autocomplete);
301
        $form = $this->cssParser()->getBySelector('#MemberLoginForm_LoginForm');
302
        $this->assertEquals(1, count($form));
303
        $this->assertNotEquals('off', (string)$form[0]->attributes()->autocomplete);
304
    }
305
306
    public function testExternalBackUrlRedirectionDisallowed()
307
    {
308
        // Test internal relative redirect
309
        $response = $this->doTestLoginForm('[email protected]', '1nitialPassword', 'testpage');
310
        $this->assertEquals(302, $response->getStatusCode());
311
        $this->assertRegExp(
312
            '/testpage/',
313
            $response->getHeader('Location'),
314
            "Internal relative BackURLs work when passed through to login form"
315
        );
316
        // Log the user out
317
        $this->session()->inst_set('loggedInAs', null);
318
319
        // Test internal absolute redirect
320
        $response = $this->doTestLoginForm(
321
            '[email protected]',
322
            '1nitialPassword',
323
            Director::absoluteBaseURL() . 'testpage'
324
        );
325
        // for some reason the redirect happens to a relative URL
326
        $this->assertRegExp(
327
            '/^' . preg_quote(Director::absoluteBaseURL(), '/') . 'testpage/',
328
            $response->getHeader('Location'),
329
            "Internal absolute BackURLs work when passed through to login form"
330
        );
331
        // Log the user out
332
        $this->session()->inst_set('loggedInAs', null);
333
334
        // Test external redirect
335
        $response = $this->doTestLoginForm('[email protected]', '1nitialPassword', 'http://myspoofedhost.com');
336
        $this->assertNotRegExp(
337
            '/^' . preg_quote('http://myspoofedhost.com', '/') . '/',
338
            (string)$response->getHeader('Location'),
339
            "Redirection to external links in login form BackURL gets prevented as a measure against spoofing attacks"
340
        );
341
342
        // Test external redirection on ChangePasswordForm
343
        $this->get('Security/changepassword?BackURL=http://myspoofedhost.com');
344
        $changedResponse = $this->doTestChangepasswordForm('1nitialPassword', 'changedPassword');
345
        $this->assertNotRegExp(
346
            '/^' . preg_quote('http://myspoofedhost.com', '/') . '/',
347
            (string)$changedResponse->getHeader('Location'),
348
            "Redirection to external links in change password form BackURL gets prevented to stop spoofing attacks"
349
        );
350
351
        // Log the user out
352
        $this->session()->inst_set('loggedInAs', null);
353
    }
354
355
    /**
356
     * Test that the login form redirects to the change password form after logging in with an expired password
357
     */
358
    public function testExpiredPassword()
359
    {
360
        /* BAD PASSWORDS ARE LOCKED OUT */
361
        $badResponse = $this->doTestLoginForm('[email protected]', 'badpassword');
362
        $this->assertEquals(302, $badResponse->getStatusCode());
363
        $this->assertRegExp('/Security\/login/', $badResponse->getHeader('Location'));
364
        $this->assertNull($this->session()->inst_get('loggedInAs'));
365
366
        /* UNEXPIRED PASSWORD GO THROUGH WITHOUT A HITCH */
367
        $goodResponse = $this->doTestLoginForm('[email protected]', '1nitialPassword');
368
        $this->assertEquals(302, $goodResponse->getStatusCode());
369
        $this->assertEquals(
370
            Controller::join_links(Director::absoluteBaseURL(), 'test/link'),
371
            $goodResponse->getHeader('Location')
372
        );
373
        $this->assertEquals($this->idFromFixture(Member::class, 'test'), $this->session()->inst_get('loggedInAs'));
374
375
        $this->logOut();
376
377
        /* EXPIRED PASSWORDS ARE SENT TO THE CHANGE PASSWORD FORM */
378
        $expiredResponse = $this->doTestLoginForm('[email protected]', '1nitialPassword');
379
        $this->assertEquals(302, $expiredResponse->getStatusCode());
380
        $this->assertEquals(
381
            Director::absoluteURL('Security/changepassword').'?BackURL=test%2Flink',
382
            Director::absoluteURL($expiredResponse->getHeader('Location'))
383
        );
384
        $this->assertEquals(
385
            $this->idFromFixture(Member::class, 'expiredpassword'),
386
            $this->session()->inst_get('loggedInAs')
387
        );
388
389
        // Make sure it redirects correctly after the password has been changed
390
        $this->mainSession->followRedirection();
391
        $changedResponse = $this->doTestChangepasswordForm('1nitialPassword', 'changedPassword');
392
        $this->assertEquals(302, $changedResponse->getStatusCode());
393
        $this->assertEquals(
394
            Controller::join_links(Director::absoluteBaseURL(), 'test/link'),
395
            $changedResponse->getHeader('Location')
396
        );
397
    }
398
399
    public function testChangePasswordForLoggedInUsers()
400
    {
401
        $goodResponse = $this->doTestLoginForm('[email protected]', '1nitialPassword');
402
403
        // Change the password
404
        $this->get('Security/changepassword?BackURL=test/back');
405
        $changedResponse = $this->doTestChangepasswordForm('1nitialPassword', 'changedPassword');
406
        $this->assertEquals(302, $changedResponse->getStatusCode());
407
        $this->assertEquals(
408
            Controller::join_links(Director::absoluteBaseURL(), 'test/back'),
409
            $changedResponse->getHeader('Location')
410
        );
411
        $this->assertEquals($this->idFromFixture(Member::class, 'test'), $this->session()->inst_get('loggedInAs'));
412
413
        // Check if we can login with the new password
414
        $this->logOut();
415
        $goodResponse = $this->doTestLoginForm('[email protected]', 'changedPassword');
416
        $this->assertEquals(302, $goodResponse->getStatusCode());
417
        $this->assertEquals(
418
            Controller::join_links(Director::absoluteBaseURL(), 'test/link'),
419
            $goodResponse->getHeader('Location')
420
        );
421
        $this->assertEquals($this->idFromFixture(Member::class, 'test'), $this->session()->inst_get('loggedInAs'));
422
    }
423
424
    public function testChangePasswordFromLostPassword()
425
    {
426
        $admin = $this->objFromFixture(Member::class, 'test');
427
        $admin->FailedLoginCount = 99;
0 ignored issues
show
Documentation introduced by
The property FailedLoginCount does not exist on object<SilverStripe\ORM\DataObject>. Since you implemented __set, maybe consider adding a @property annotation.

Since your code implements the magic setter _set, this function will be called for any write access on an undefined variable. You can add the @property annotation to your class or interface to document the existence of this variable.

<?php

/**
 * @property int $x
 * @property int $y
 * @property string $text
 */
class MyLabel
{
    private $properties;

    private $allowedProperties = array('x', 'y', 'text');

    public function __get($name)
    {
        if (isset($properties[$name]) && in_array($name, $this->allowedProperties)) {
            return $properties[$name];
        } else {
            return null;
        }
    }

    public function __set($name, $value)
    {
        if (in_array($name, $this->allowedProperties)) {
            $properties[$name] = $value;
        } else {
            throw new \LogicException("Property $name is not defined.");
        }
    }

}

Since the property has write access only, you can use the @property-write annotation instead.

Of course, you may also just have mistyped another name, in which case you should fix the error.

See also the PhpDoc documentation for @property.

Loading history...
428
        $admin->LockedOutUntil = DBDatetime::now()->getValue();
0 ignored issues
show
Documentation introduced by
The property LockedOutUntil does not exist on object<SilverStripe\ORM\DataObject>. Since you implemented __set, maybe consider adding a @property annotation.

Since your code implements the magic setter _set, this function will be called for any write access on an undefined variable. You can add the @property annotation to your class or interface to document the existence of this variable.

<?php

/**
 * @property int $x
 * @property int $y
 * @property string $text
 */
class MyLabel
{
    private $properties;

    private $allowedProperties = array('x', 'y', 'text');

    public function __get($name)
    {
        if (isset($properties[$name]) && in_array($name, $this->allowedProperties)) {
            return $properties[$name];
        } else {
            return null;
        }
    }

    public function __set($name, $value)
    {
        if (in_array($name, $this->allowedProperties)) {
            $properties[$name] = $value;
        } else {
            throw new \LogicException("Property $name is not defined.");
        }
    }

}

Since the property has write access only, you can use the @property-write annotation instead.

Of course, you may also just have mistyped another name, in which case you should fix the error.

See also the PhpDoc documentation for @property.

Loading history...
429
        $admin->write();
430
431
        $this->assertNull($admin->AutoLoginHash, 'Hash is empty before lost password');
432
433
        // Request new password by email
434
        $response = $this->get('Security/lostpassword');
435
        $response = $this->post('Security/lostpassword/LostPasswordForm', array('Email' => '[email protected]'));
436
437
        $this->assertEmailSent('[email protected]');
438
439
        // Load password link from email
440
        $admin = DataObject::get_by_id(Member::class, $admin->ID);
441
        $this->assertNotNull($admin->AutoLoginHash, 'Hash has been written after lost password');
442
443
        // We don't have access to the token - generate a new token and hash pair.
444
        $token = $admin->generateAutologinTokenAndStoreHash();
445
446
        // Check.
447
        $response = $this->get('Security/changepassword/?m='.$admin->ID.'&t=' . $token);
448
        $this->assertEquals(302, $response->getStatusCode());
449
        $this->assertEquals(
450
            Director::absoluteURL('Security/changepassword'),
451
            Director::absoluteURL($response->getHeader('Location'))
452
        );
453
454
        // Follow redirection to form without hash in GET parameter
455
        $response = $this->get('Security/changepassword');
456
        $changedResponse = $this->doTestChangepasswordForm('1nitialPassword', 'changedPassword');
457
        $this->assertEquals($this->idFromFixture(Member::class, 'test'), $this->session()->inst_get('loggedInAs'));
458
459
        // Check if we can login with the new password
460
        $this->logOut();
461
        $goodResponse = $this->doTestLoginForm('[email protected]', 'changedPassword');
462
        $this->assertEquals(302, $goodResponse->getStatusCode());
463
        $this->assertEquals($this->idFromFixture(Member::class, 'test'), $this->session()->inst_get('loggedInAs'));
464
465
        $admin = DataObject::get_by_id(Member::class, $admin->ID, false);
466
        $this->assertNull($admin->LockedOutUntil);
467
        $this->assertEquals(0, $admin->FailedLoginCount);
468
    }
469
470
    public function testRepeatedLoginAttemptsLockingPeopleOut()
471
    {
472
        $local = i18n::get_locale();
473
        i18n::set_locale('en_US');
474
475
        Member::config()->lock_out_after_incorrect_logins = 5;
0 ignored issues
show
Documentation introduced by
The property lock_out_after_incorrect_logins does not exist on object<SilverStripe\Core\Config\Config_ForClass>. Since you implemented __set, maybe consider adding a @property annotation.

Since your code implements the magic setter _set, this function will be called for any write access on an undefined variable. You can add the @property annotation to your class or interface to document the existence of this variable.

<?php

/**
 * @property int $x
 * @property int $y
 * @property string $text
 */
class MyLabel
{
    private $properties;

    private $allowedProperties = array('x', 'y', 'text');

    public function __get($name)
    {
        if (isset($properties[$name]) && in_array($name, $this->allowedProperties)) {
            return $properties[$name];
        } else {
            return null;
        }
    }

    public function __set($name, $value)
    {
        if (in_array($name, $this->allowedProperties)) {
            $properties[$name] = $value;
        } else {
            throw new \LogicException("Property $name is not defined.");
        }
    }

}

Since the property has write access only, you can use the @property-write annotation instead.

Of course, you may also just have mistyped another name, in which case you should fix the error.

See also the PhpDoc documentation for @property.

Loading history...
476
        Member::config()->lock_out_delay_mins = 15;
0 ignored issues
show
Documentation introduced by
The property lock_out_delay_mins does not exist on object<SilverStripe\Core\Config\Config_ForClass>. Since you implemented __set, maybe consider adding a @property annotation.

Since your code implements the magic setter _set, this function will be called for any write access on an undefined variable. You can add the @property annotation to your class or interface to document the existence of this variable.

<?php

/**
 * @property int $x
 * @property int $y
 * @property string $text
 */
class MyLabel
{
    private $properties;

    private $allowedProperties = array('x', 'y', 'text');

    public function __get($name)
    {
        if (isset($properties[$name]) && in_array($name, $this->allowedProperties)) {
            return $properties[$name];
        } else {
            return null;
        }
    }

    public function __set($name, $value)
    {
        if (in_array($name, $this->allowedProperties)) {
            $properties[$name] = $value;
        } else {
            throw new \LogicException("Property $name is not defined.");
        }
    }

}

Since the property has write access only, you can use the @property-write annotation instead.

Of course, you may also just have mistyped another name, in which case you should fix the error.

See also the PhpDoc documentation for @property.

Loading history...
477
478
        // Login with a wrong password for more than the defined threshold
479
        for ($i = 1; $i <= (Member::config()->lock_out_after_incorrect_logins+1); $i++) {
480
            $this->doTestLoginForm('[email protected]', 'incorrectpassword');
481
            $member = DataObject::get_by_id(Member::class, $this->idFromFixture(Member::class, 'test'));
482
483
            if ($i < Member::config()->get('lock_out_after_incorrect_logins')) {
484
                $this->assertNull(
485
                    $member->LockedOutUntil,
486
                    'User does not have a lockout time set if under threshold for failed attempts'
487
                );
488
                $this->assertHasMessage(
489
                    _t(
490
                        'SilverStripe\\Security\\Member.ERRORWRONGCRED',
491
                        'The provided details don\'t seem to be correct. Please try again.'
492
                    )
493
                );
494
            } else {
495
                // Fuzzy matching for time to avoid side effects from slow running tests
496
                $this->assertGreaterThan(
497
                    time() + 14*60,
498
                    strtotime($member->LockedOutUntil),
499
                    'User has a lockout time set after too many failed attempts'
500
                );
501
            }
502
        }
503
        $msg = _t(
504
            'SilverStripe\\Security\\Member.ERRORLOCKEDOUT2',
505
            'Your account has been temporarily disabled because of too many failed attempts at ' .
506
            'logging in. Please try again in {count} minutes.',
507
            null,
508
            array('count' => Member::config()->lock_out_delay_mins)
509
        );
510
        $this->assertHasMessage($msg);
511
512
513
        $this->doTestLoginForm('[email protected]', '1nitialPassword');
514
        $this->assertNull(
515
            $this->session()->inst_get('loggedInAs'),
516
            'The user can\'t log in after being locked out, even with the right password'
517
        );
518
519
        // (We fake this by re-setting LockedOutUntil)
520
        $member = DataObject::get_by_id(Member::class, $this->idFromFixture(Member::class, 'test'));
521
        $member->LockedOutUntil = date('Y-m-d H:i:s', time() - 30);
0 ignored issues
show
Documentation introduced by
The property LockedOutUntil does not exist on object<SilverStripe\ORM\DataObject>. Since you implemented __set, maybe consider adding a @property annotation.

Since your code implements the magic setter _set, this function will be called for any write access on an undefined variable. You can add the @property annotation to your class or interface to document the existence of this variable.

<?php

/**
 * @property int $x
 * @property int $y
 * @property string $text
 */
class MyLabel
{
    private $properties;

    private $allowedProperties = array('x', 'y', 'text');

    public function __get($name)
    {
        if (isset($properties[$name]) && in_array($name, $this->allowedProperties)) {
            return $properties[$name];
        } else {
            return null;
        }
    }

    public function __set($name, $value)
    {
        if (in_array($name, $this->allowedProperties)) {
            $properties[$name] = $value;
        } else {
            throw new \LogicException("Property $name is not defined.");
        }
    }

}

Since the property has write access only, you can use the @property-write annotation instead.

Of course, you may also just have mistyped another name, in which case you should fix the error.

See also the PhpDoc documentation for @property.

Loading history...
522
        $member->write();
523
        $this->doTestLoginForm('[email protected]', '1nitialPassword');
524
        $this->assertEquals(
525
            $this->session()->inst_get('loggedInAs'),
526
            $member->ID,
527
            'After lockout expires, the user can login again'
528
        );
529
530
        // Log the user out
531
        $this->logOut();
532
533
        // Login again with wrong password, but less attempts than threshold
534
        for ($i = 1; $i < Member::config()->lock_out_after_incorrect_logins; $i++) {
535
            $this->doTestLoginForm('[email protected]', 'incorrectpassword');
536
        }
537
        $this->assertNull($this->session()->inst_get('loggedInAs'));
538
        $this->assertHasMessage(
539
            _t('SilverStripe\\Security\\Member.ERRORWRONGCRED', 'The provided details don\'t seem to be correct. Please try again.'),
540
            'The user can retry with a wrong password after the lockout expires'
541
        );
542
543
        $this->doTestLoginForm('[email protected]', '1nitialPassword');
544
        $this->assertEquals(
545
            $this->session()->inst_get('loggedInAs'),
546
            $member->ID,
547
            'The user can login successfully after lockout expires, if staying below the threshold'
548
        );
549
550
        i18n::set_locale($local);
551
    }
552
553
    public function testAlternatingRepeatedLoginAttempts()
554
    {
555
        Member::config()->lock_out_after_incorrect_logins = 3;
0 ignored issues
show
Documentation introduced by
The property lock_out_after_incorrect_logins does not exist on object<SilverStripe\Core\Config\Config_ForClass>. Since you implemented __set, maybe consider adding a @property annotation.

Since your code implements the magic setter _set, this function will be called for any write access on an undefined variable. You can add the @property annotation to your class or interface to document the existence of this variable.

<?php

/**
 * @property int $x
 * @property int $y
 * @property string $text
 */
class MyLabel
{
    private $properties;

    private $allowedProperties = array('x', 'y', 'text');

    public function __get($name)
    {
        if (isset($properties[$name]) && in_array($name, $this->allowedProperties)) {
            return $properties[$name];
        } else {
            return null;
        }
    }

    public function __set($name, $value)
    {
        if (in_array($name, $this->allowedProperties)) {
            $properties[$name] = $value;
        } else {
            throw new \LogicException("Property $name is not defined.");
        }
    }

}

Since the property has write access only, you can use the @property-write annotation instead.

Of course, you may also just have mistyped another name, in which case you should fix the error.

See also the PhpDoc documentation for @property.

Loading history...
556
557
        // ATTEMPTING LOG-IN TWICE WITH ONE ACCOUNT AND TWICE WITH ANOTHER SHOULDN'T LOCK ANYBODY OUT
558
559
        $this->doTestLoginForm('[email protected]', 'incorrectpassword');
560
        $this->doTestLoginForm('[email protected]', 'incorrectpassword');
561
562
        $this->doTestLoginForm('[email protected]', 'incorrectpassword');
563
        $this->doTestLoginForm('[email protected]', 'incorrectpassword');
564
565
        $member1 = DataObject::get_by_id(Member::class, $this->idFromFixture(Member::class, 'test'));
566
        $member2 = DataObject::get_by_id(Member::class, $this->idFromFixture(Member::class, 'noexpiry'));
567
568
        $this->assertNull($member1->LockedOutUntil);
569
        $this->assertNull($member2->LockedOutUntil);
570
571
        // BUT, DOING AN ADDITIONAL LOG-IN WITH EITHER OF THEM WILL LOCK OUT, SINCE THAT IS THE 3RD FAILURE IN
572
        // THIS SESSION
573
574
        $this->doTestLoginForm('[email protected]', 'incorrectpassword');
575
        $member1 = DataObject::get_by_id(Member::class, $this->idFromFixture(Member::class, 'test'));
576
        $this->assertNotNull($member1->LockedOutUntil);
577
578
        $this->doTestLoginForm('[email protected]', 'incorrectpassword');
579
        $member2 = DataObject::get_by_id(Member::class, $this->idFromFixture(Member::class, 'noexpiry'));
580
        $this->assertNotNull($member2->LockedOutUntil);
581
    }
582
583
    public function testUnsuccessfulLoginAttempts()
584
    {
585
        Security::config()->login_recording = true;
0 ignored issues
show
Documentation introduced by
The property login_recording does not exist on object<SilverStripe\Core\Config\Config_ForClass>. Since you implemented __set, maybe consider adding a @property annotation.

Since your code implements the magic setter _set, this function will be called for any write access on an undefined variable. You can add the @property annotation to your class or interface to document the existence of this variable.

<?php

/**
 * @property int $x
 * @property int $y
 * @property string $text
 */
class MyLabel
{
    private $properties;

    private $allowedProperties = array('x', 'y', 'text');

    public function __get($name)
    {
        if (isset($properties[$name]) && in_array($name, $this->allowedProperties)) {
            return $properties[$name];
        } else {
            return null;
        }
    }

    public function __set($name, $value)
    {
        if (in_array($name, $this->allowedProperties)) {
            $properties[$name] = $value;
        } else {
            throw new \LogicException("Property $name is not defined.");
        }
    }

}

Since the property has write access only, you can use the @property-write annotation instead.

Of course, you may also just have mistyped another name, in which case you should fix the error.

See also the PhpDoc documentation for @property.

Loading history...
586
587
        /* UNSUCCESSFUL ATTEMPTS WITH WRONG PASSWORD FOR EXISTING USER ARE LOGGED */
588
        $this->doTestLoginForm('[email protected]', 'wrongpassword');
589
        $attempt = DataObject::get_one(
590
            LoginAttempt::class,
591
            array(
592
                '"LoginAttempt"."Email"' => '[email protected]'
593
            )
594
        );
595
        $this->assertTrue(is_object($attempt));
596
        $member = DataObject::get_one(
597
            Member::class,
598
            array(
599
                '"Member"."Email"' => '[email protected]'
600
            )
601
        );
602
        $this->assertEquals($attempt->Status, 'Failure');
603
        $this->assertEquals($attempt->Email, '[email protected]');
604
        $this->assertEquals($attempt->Member()->toMap(), $member->toMap());
605
606
        /* UNSUCCESSFUL ATTEMPTS WITH NONEXISTING USER ARE LOGGED */
607
        $this->doTestLoginForm('[email protected]', 'wrongpassword');
608
        $attempt = DataObject::get_one(
609
            LoginAttempt::class,
610
            array(
611
            '"LoginAttempt"."Email"' => '[email protected]'
612
            )
613
        );
614
        $this->assertTrue(is_object($attempt));
615
        $this->assertEquals($attempt->Status, 'Failure');
616
        $this->assertEquals($attempt->Email, '[email protected]');
617
        $this->assertNotEmpty($this->getValidationResult()->getMessages(), 'An invalid email returns a message.');
618
    }
619
620
    public function testSuccessfulLoginAttempts()
621
    {
622
        Security::config()->login_recording = true;
0 ignored issues
show
Documentation introduced by
The property login_recording does not exist on object<SilverStripe\Core\Config\Config_ForClass>. Since you implemented __set, maybe consider adding a @property annotation.

Since your code implements the magic setter _set, this function will be called for any write access on an undefined variable. You can add the @property annotation to your class or interface to document the existence of this variable.

<?php

/**
 * @property int $x
 * @property int $y
 * @property string $text
 */
class MyLabel
{
    private $properties;

    private $allowedProperties = array('x', 'y', 'text');

    public function __get($name)
    {
        if (isset($properties[$name]) && in_array($name, $this->allowedProperties)) {
            return $properties[$name];
        } else {
            return null;
        }
    }

    public function __set($name, $value)
    {
        if (in_array($name, $this->allowedProperties)) {
            $properties[$name] = $value;
        } else {
            throw new \LogicException("Property $name is not defined.");
        }
    }

}

Since the property has write access only, you can use the @property-write annotation instead.

Of course, you may also just have mistyped another name, in which case you should fix the error.

See also the PhpDoc documentation for @property.

Loading history...
623
624
        /* SUCCESSFUL ATTEMPTS ARE LOGGED */
625
        $this->doTestLoginForm('[email protected]', '1nitialPassword');
626
        $attempt = DataObject::get_one(
627
            LoginAttempt::class,
628
            array(
629
            '"LoginAttempt"."Email"' => '[email protected]'
630
            )
631
        );
632
        $member = DataObject::get_one(
633
            Member::class,
634
            array(
635
            '"Member"."Email"' => '[email protected]'
636
            )
637
        );
638
        $this->assertTrue(is_object($attempt));
639
        $this->assertEquals($attempt->Status, 'Success');
640
        $this->assertEquals($attempt->Email, '[email protected]');
641
        $this->assertEquals($attempt->Member()->toMap(), $member->toMap());
642
    }
643
644
    public function testDatabaseIsReadyWithInsufficientMemberColumns()
645
    {
646
        Security::clear_database_is_ready();
647
        DBClassName::clear_classname_cache();
648
649
        // Assumption: The database has been built correctly by the test runner,
650
        // and has all columns present in the ORM
651
        /**
652
         * @skipUpgrade
653
         */
654
        DB::get_schema()->renameField('Member', 'Email', 'Email_renamed');
655
656
        // Email column is now missing, which means we're not ready to do permission checks
657
        $this->assertFalse(Security::database_is_ready());
658
659
        // Rebuild the database (which re-adds the Email column), and try again
660
        static::resetDBSchema(true);
661
        $this->assertTrue(Security::database_is_ready());
662
    }
663
664
    public function testSecurityControllerSendsRobotsTagHeader()
665
    {
666
        $response = $this->get(Config::inst()->get(Security::class, 'login_url'));
667
        $robotsHeader = $response->getHeader('X-Robots-Tag');
668
        $this->assertNotNull($robotsHeader);
669
        $this->assertContains('noindex', $robotsHeader);
670
    }
671
672
    public function testDoNotSendEmptyRobotsHeaderIfNotDefined()
673
    {
674
        Config::inst()->remove(Security::class, 'robots_tag');
0 ignored issues
show
Bug introduced by
It seems like you code against a concrete implementation and not the interface SilverStripe\Config\Coll...nfigCollectionInterface as the method remove() does only exist in the following implementations of said interface: SilverStripe\Config\Coll...\MemoryConfigCollection.

Let’s take a look at an example:

interface User
{
    /** @return string */
    public function getPassword();
}

class MyUser implements User
{
    public function getPassword()
    {
        // return something
    }

    public function getDisplayName()
    {
        // return some name.
    }
}

class AuthSystem
{
    public function authenticate(User $user)
    {
        $this->logger->info(sprintf('Authenticating %s.', $user->getDisplayName()));
        // do something.
    }
}

In the above example, the authenticate() method works fine as long as you just pass instances of MyUser. However, if you now also want to pass a different implementation of User which does not have a getDisplayName() method, the code will break.

Available Fixes

  1. Change the type-hint for the parameter:

    class AuthSystem
    {
        public function authenticate(MyUser $user) { /* ... */ }
    }
    
  2. Add an additional type-check:

    class AuthSystem
    {
        public function authenticate(User $user)
        {
            if ($user instanceof MyUser) {
                $this->logger->info(/** ... */);
            }
    
            // or alternatively
            if ( ! $user instanceof MyUser) {
                throw new \LogicException(
                    '$user must be an instance of MyUser, '
                   .'other instances are not supported.'
                );
            }
    
        }
    }
    
Note: PHP Analyzer uses reverse abstract interpretation to narrow down the types inside the if block in such a case.
  1. Add the method to the interface:

    interface User
    {
        /** @return string */
        public function getPassword();
    
        /** @return string */
        public function getDisplayName();
    }
    
Loading history...
675
        $response = $this->get(Config::inst()->get(Security::class, 'login_url'));
676
        $robotsHeader = $response->getHeader('X-Robots-Tag');
677
        $this->assertNull($robotsHeader);
678
    }
679
680
    /**
681
     * Execute a log-in form using Director::test().
682
     * Helper method for the tests above
683
     */
684
    public function doTestLoginForm($email, $password, $backURL = 'test/link')
685
    {
686
        $this->get(Config::inst()->get(Security::class, 'logout_url'));
687
        $this->session()->inst_set('BackURL', $backURL);
688
        $this->get(Config::inst()->get(Security::class, 'login_url'));
689
690
        return $this->submitForm(
691
            "MemberLoginForm_LoginForm",
692
            null,
693
            array(
694
                'Email' => $email,
695
                'Password' => $password,
696
                'AuthenticationMethod' => MemberAuthenticator::class,
697
                'action_doLogin' => 1,
698
            )
699
        );
700
    }
701
702
    /**
703
     * Helper method to execute a change password form
704
     */
705
    public function doTestChangepasswordForm($oldPassword, $newPassword)
706
    {
707
        return $this->submitForm(
708
            "ChangePasswordForm_ChangePasswordForm",
709
            null,
710
            array(
711
                'OldPassword' => $oldPassword,
712
                'NewPassword1' => $newPassword,
713
                'NewPassword2' => $newPassword,
714
                'action_doChangePassword' => 1,
715
            )
716
        );
717
    }
718
719
    /**
720
     * Assert this message is in the current login form errors
721
     *
722
     * @param string $expected
723
     * @param string $errorMessage
724
     */
725
    protected function assertHasMessage($expected, $errorMessage = null)
726
    {
727
        $messages = [];
728
        $result = $this->getValidationResult();
729
        if ($result) {
730
            foreach ($result->getMessages() as $message) {
731
                $messages[] = $message['message'];
732
            }
733
        }
734
735
        $this->assertContains($expected, $messages, $errorMessage);
736
    }
737
738
    /**
739
     * Get validation result from last login form submission
740
     *
741
     * @return ValidationResult
742
     */
743
    protected function getValidationResult()
744
    {
745
        $result = $this->session()->inst_get('FormInfo.MemberLoginForm_LoginForm.result');
746
        if ($result) {
747
            return unserialize($result);
748
        }
749
        return null;
750
    }
751
}
752