Completed
Push — 4 ( 6c0917...fa3556 )
by Guy
40s queued 26s
created

SessionTest::provideSecureSamesiteData()   A

Complexity

Conditions 6
Paths 6

Size

Total Lines 26
Code Lines 15

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 6
eloc 15
nc 6
nop 0
dl 0
loc 26
rs 9.2222
c 1
b 0
f 0
1
<?php
2
3
namespace SilverStripe\Control\Tests;
4
5
use Exception;
6
use LogicException;
7
use Monolog\Logger;
8
use Psr\Log\LoggerInterface;
9
use ReflectionMethod;
10
use SilverStripe\Control\Cookie;
11
use SilverStripe\Control\Director;
12
use SilverStripe\Control\Session;
13
use SilverStripe\Dev\SapphireTest;
14
use SilverStripe\Control\HTTPRequest;
15
use SilverStripe\Control\NullHTTPRequest;
16
use SilverStripe\Core\Config\Config;
17
use SilverStripe\Core\Injector\Injector;
18
19
/**
20
 * Tests to cover the {@link Session} class
21
 */
22
class SessionTest extends SapphireTest
23
{
24
    /**
25
     * @var Session
26
     */
27
    protected $session = null;
28
29
    protected function setUp(): void
30
    {
31
        $this->session = new Session([]);
32
        parent::setUp();
33
    }
34
35
    /**
36
     * @runInSeparateProcess
37
     * @preserveGlobalState disabled
38
     */
39
    public function testInitDoesNotStartSessionWithoutIdentifier()
40
    {
41
        $req = new HTTPRequest('GET', '/');
42
        $session = new Session(null); // unstarted session
43
        $session->init($req);
44
        $this->assertFalse($session->isStarted());
45
    }
46
47
    /**
48
     * @runInSeparateProcess
49
     * @preserveGlobalState disabled
50
     */
51
    public function testInitStartsSessionWithIdentifier()
52
    {
53
        $req = new HTTPRequest('GET', '/');
54
        Cookie::set(session_name(), '1234');
55
        $session = new Session(null); // unstarted session
56
        $session->init($req);
57
        $this->assertTrue($session->isStarted());
58
    }
59
60
    /**
61
     * @runInSeparateProcess
62
     * @preserveGlobalState disabled
63
     */
64
    public function testInitStartsSessionWithData()
65
    {
66
        $req = new HTTPRequest('GET', '/');
67
        $session = new Session([]);
68
        $session->init($req);
69
        $this->assertTrue($session->isStarted());
70
    }
71
72
    /**
73
     * @runInSeparateProcess
74
     * @preserveGlobalState disabled
75
     */
76
    public function testStartUsesDefaultCookieNameWithHttp()
77
    {
78
        $req = (new HTTPRequest('GET', '/'))
79
            ->setScheme('http');
80
        Cookie::set(session_name(), '1234');
81
        $session = new Session(null); // unstarted session
82
        $session->start($req);
83
        $this->assertNotEquals(session_name(), $session->config()->get('cookie_name_secure'));
84
    }
85
86
    /**
87
     * @runInSeparateProcess
88
     * @preserveGlobalState disabled
89
     */
90
    public function testStartUsesDefaultCookieNameWithHttpsAndCookieSecureOff()
91
    {
92
        $req = (new HTTPRequest('GET', '/'))
93
            ->setScheme('https');
94
        Cookie::set(session_name(), '1234');
95
        $session = new Session(null); // unstarted session
96
        $session->start($req);
97
        $this->assertNotEquals(session_name(), $session->config()->get('cookie_name_secure'));
98
    }
99
100
    /**
101
     * @runInSeparateProcess
102
     * @preserveGlobalState disabled
103
     */
104
    public function testStartUsesSecureCookieNameWithHttpsAndCookieSecureOn()
105
    {
106
        $req = (new HTTPRequest('GET', '/'))
107
            ->setScheme('https');
108
        Cookie::set(session_name(), '1234');
109
        $session = new Session(null); // unstarted session
110
        $session->config()->update('cookie_secure', true);
111
        $session->start($req);
112
        $this->assertEquals(session_name(), $session->config()->get('cookie_name_secure'));
113
    }
114
115
    /**
116
     * @runInSeparateProcess
117
     * @preserveGlobalState disabled
118
     */
119
    public function testStartErrorsWhenStartingTwice()
120
    {
121
        $this->expectException(\BadMethodCallException::class);
122
        $this->expectExceptionMessage('Session has already started');
123
        $req = new HTTPRequest('GET', '/');
124
        $session = new Session(null); // unstarted session
125
        $session->start($req);
126
        $session->start($req);
127
    }
128
129
    /**
130
     * @runInSeparateProcess
131
     * @preserveGlobalState disabled
132
     */
133
    public function testStartRetainsInMemoryData()
134
    {
135
        $this->markTestIncomplete('Test');
136
        // TODO Figure out how to simulate session vars without a session_start() resetting them
137
        // $_SESSION['existing'] = true;
138
        // $_SESSION['merge'] = 1;
139
        $req = new HTTPRequest('GET', '/');
140
        $session = new Session(null); // unstarted session
141
        $session->set('new', true);
142
        $session->set('merge', 2);
143
        $session->start($req); // simulate lazy start
144
        $this->assertEquals(
145
            [
146
                // 'existing' => true,
147
                'new' => true,
148
                'merge' => 2,
149
            ],
150
            $session->getAll()
151
        );
152
153
        unset($_SESSION);
154
    }
155
156
    public function testGetSetBasics()
157
    {
158
        $this->session->set('Test', 'Test');
159
160
        $this->assertEquals($this->session->get('Test'), 'Test');
161
    }
162
163
    public function testClearElement()
164
    {
165
        $this->session->set('Test', 'Test');
166
        $this->session->clear('Test');
167
168
        $this->assertEquals($this->session->get('Test'), '');
169
    }
170
171
    public function testClearAllElements()
172
    {
173
        $this->session->set('Test', 'Test');
174
        $this->session->set('Test-1', 'Test-1');
175
176
        $this->session->clearAll();
177
178
        // should session get return null? The array key should probably be
179
        // unset from the data array
180
        $this->assertEquals($this->session->get('Test'), '');
181
        $this->assertEquals($this->session->get('Test-1'), '');
182
    }
183
184
    public function testGetAllElements()
185
    {
186
        $this->session->clearAll(); // Remove all session that might've been set by the test harness
187
188
        $this->session->set('Test', 'Test');
189
        $this->session->set('Test-2', 'Test-2');
190
191
        $session = $this->session->getAll();
192
        unset($session['HTTP_USER_AGENT']);
193
194
        $this->assertEquals($session, ['Test' => 'Test', 'Test-2' => 'Test-2']);
195
    }
196
197
    public function testSettingExistingDoesntClear()
198
    {
199
        $s = new Session(['something' => ['does' => 'exist']]);
200
201
        $s->set('something.does', 'exist');
202
        $result = $s->changedData();
203
        unset($result['HTTP_USER_AGENT']);
204
        $this->assertEmpty($result);
205
    }
206
207
    /**
208
     * Check that changedData isn't populated with junk when clearing non-existent entries.
209
     */
210
    public function testClearElementThatDoesntExist()
211
    {
212
        $s = new Session(['something' => ['does' => 'exist']]);
213
        $s->clear('something.doesnt.exist');
214
215
        // Clear without existing data
216
        $data = $s->get('something.doesnt.exist');
217
        $this->assertEmpty($s->changedData());
218
        $this->assertNull($data);
219
220
        // Clear with existing change
221
        $s->set('something-else', 'val');
222
        $s->clear('something-new');
223
        $data = $s->get('something-else');
224
        $this->assertEquals(['something-else' => true], $s->changedData());
225
        $this->assertEquals('val', $data);
226
    }
227
228
    /**
229
     * Check that changedData is populated with clearing data.
230
     */
231
    public function testClearElementThatDoesExist()
232
    {
233
        $s = new Session(['something' => ['does' => 'exist']]);
234
235
        // Ensure keys are properly removed and not simply nullified
236
        $s->clear('something.does');
237
        $this->assertEquals(
238
            ['something' => ['does' => true]],
239
            $s->changedData()
240
        );
241
        $this->assertEquals(
242
            [], // 'does' removed
243
            $s->get('something')
244
        );
245
246
        // Clear at more specific level should also clear other changes
247
        $s->clear('something');
248
        $this->assertEquals(
249
            ['something' => true],
250
            $s->changedData()
251
        );
252
        $this->assertEquals(
253
            null, // Should be removed not just empty array
254
            $s->get('something')
255
        );
256
    }
257
258
    public function testRequestContainsSessionId()
259
    {
260
        $req = new HTTPRequest('GET', '/');
261
        $session = new Session(null); // unstarted session
262
        $this->assertFalse($session->requestContainsSessionId($req));
263
        Cookie::set(session_name(), '1234');
264
        $this->assertTrue($session->requestContainsSessionId($req));
265
    }
266
267
    public function testRequestContainsSessionIdRespectsCookieNameSecure()
268
    {
269
        $req = (new HTTPRequest('GET', '/'))
270
            ->setScheme('https');
271
        $session = new Session(null); // unstarted session
272
        Cookie::set($session->config()->get('cookie_name_secure'), '1234');
273
        $session->config()->update('cookie_secure', true);
274
        $this->assertTrue($session->requestContainsSessionId($req));
275
    }
276
277
    public function testUserAgentLockout()
278
    {
279
        // Set a user agent
280
        $req1 = new HTTPRequest('GET', '/');
281
        $req1->addHeader('User-Agent', 'Test Agent');
282
283
        // Generate our session
284
        $s = new Session([]);
285
        $s->init($req1);
286
        $s->set('val', 123);
287
        $s->finalize($req1);
288
289
        // Change our UA
290
        $req2 = new HTTPRequest('GET', '/');
291
        $req2->addHeader('User-Agent', 'Fake Agent');
292
293
        // Verify the new session reset our values
294
        $s2 = new Session($s);
295
        $s2->init($req2);
296
        $this->assertEmpty($s2->get('val'));
297
    }
298
299
    public function testDisabledUserAgentLockout()
300
    {
301
        Session::config()->set('strict_user_agent_check', false);
302
303
        // Set a user agent
304
        $req1 = new HTTPRequest('GET', '/');
305
        $req1->addHeader('User-Agent', 'Test Agent');
306
307
        // Generate our session
308
        $s = new Session([]);
309
        $s->init($req1);
310
        $s->set('val', 123);
311
        $s->finalize($req1);
312
313
        // Change our UA
314
        $req2 = new HTTPRequest('GET', '/');
315
        $req2->addHeader('User-Agent', 'Fake Agent');
316
317
        // Verify the new session reset our values
318
        $s2 = new Session($s);
319
        $s2->init($req2);
320
        $this->assertEquals($s2->get('val'), 123);
321
    }
322
323
    public function testSave()
324
    {
325
        $request = new HTTPRequest('GET', '/');
326
327
        // Test change of nested array type
328
        $s = new Session($_SESSION = ['something' => ['some' => 'value', 'another' => 'item']]);
329
        $s->set('something', 'string');
330
        $s->save($request);
331
        $this->assertEquals(
332
            ['something' => 'string'],
333
            $_SESSION
334
        );
335
336
        // Test multiple changes combine safely
337
        $s = new Session($_SESSION = ['something' => ['some' => 'value', 'another' => 'item']]);
338
        $s->set('something.another', 'newanother');
339
        $s->clear('something.some');
340
        $s->set('something.newkey', 'new value');
341
        $s->save($request);
342
        $this->assertEquals(
343
            [
344
                'something' => [
345
                    'another' => 'newanother',
346
                    'newkey' => 'new value',
347
                ],
348
            ],
349
            $_SESSION
350
        );
351
352
        // Test cleared keys are restorable
353
        $s = new Session($_SESSION = ['bookmarks' => [1 => 1, 2 => 2]]);
354
        $s->clear('bookmarks');
355
        $s->set('bookmarks', [
356
            1 => 1,
357
            3 => 3,
358
        ]);
359
        $s->save($request);
360
        $this->assertEquals(
361
            [
362
                'bookmarks' => [
363
                    1 => 1,
364
                    3 => 3,
365
                ],
366
            ],
367
            $_SESSION
368
        );
369
    }
370
371
    public function testIsCookieSecure(): void
372
    {
373
        $session = new Session(null);
374
        $methodIsCookieSecure = new ReflectionMethod($session, 'isCookieSecure');
375
        $methodIsCookieSecure->setAccessible(true);
376
377
        $this->assertFalse($methodIsCookieSecure->invoke($session, 'Lax', true));
378
        $this->assertFalse($methodIsCookieSecure->invoke($session, 'Lax', false));
379
        $this->assertTrue($methodIsCookieSecure->invoke($session, 'None', false));
380
        $this->assertTrue($methodIsCookieSecure->invoke($session, 'None', true));
381
382
        Config::modify()->set(Session::class, 'cookie_secure', true);
383
        $this->assertTrue($methodIsCookieSecure->invoke($session, 'Lax', true));
384
        $this->assertFalse($methodIsCookieSecure->invoke($session, 'Lax', false));
385
        $this->assertTrue($methodIsCookieSecure->invoke($session, 'None', false));
386
        $this->assertTrue($methodIsCookieSecure->invoke($session, 'None', true));
387
    }
388
389
    public function testBuildCookieParams(): void
390
    {
391
        $session = new Session(null);
392
        $methodBuildCookieParams = new ReflectionMethod($session, 'buildCookieParams');
393
        $methodBuildCookieParams->setAccessible(true);
394
395
        $params = $methodBuildCookieParams->invoke($session, new NullHTTPRequest());
396
        $this->assertSame(
397
            [
398
                'lifetime' => Session::config()->get('timeout'), // 0 by default but kitchen sink sets this to 1440
399
                'path' => '/',
400
                'domain' => null,
401
                'secure' => false,
402
                'httponly' => true,
403
                'samesite' => 'Lax',
404
            ],
405
            $params
406
        );
407
408
        Config::modify()->set(Session::class, 'timeout', 123);
409
        Config::modify()->set(Session::class, 'cookie_path', 'test-path');
410
        Config::modify()->set(Session::class, 'cookie_domain', 'test-domain');
411
        $params = $methodBuildCookieParams->invoke($session, new NullHTTPRequest());
412
        $this->assertSame(
413
            [
414
                'lifetime' => 123,
415
                'path' => 'test-path',
416
                'domain' => 'test-domain',
417
                'secure' => false,
418
                'httponly' => true,
419
                'samesite' => 'Lax',
420
            ],
421
            $params
422
        );
423
424
        Config::modify()->set(Session::class, 'cookie_path', '');
425
        Config::modify()->set(Director::class, 'alternate_base_url', 'https://secure.example.com/some-path/');
426
        $params = $methodBuildCookieParams->invoke($session, new NullHTTPRequest());
427
        $this->assertSame(
428
            [
429
                'lifetime' => 123,
430
                'path' => '/some-path/',
431
                'domain' => 'test-domain',
432
                'secure' => false,
433
                'httponly' => true,
434
                'samesite' => 'Lax',
435
            ],
436
            $params
437
        );
438
    }
439
440
    public function provideSecureSamesiteData(): array
441
    {
442
        $data = [];
443
        foreach ([true, false] as $secure) {
444
            foreach (['Strict', 'Lax', 'None'] as $sameSite) {
445
                foreach (['https://secure.example.com/', 'http://insecure.example.com/'] as $alternateBase) {
446
                    if ($sameSite === 'None') {
447
                        // secure is always true if samesite is "None"
448
                        $secure = true;
449
                    } else {
450
                        // secure cannot be true for insecure requests
451
                        $secure = (strpos($alternateBase, 'https:') === 0) && $secure;
452
                    }
453
                    $data[] = [
454
                        $secure,
455
                        $sameSite,
456
                        $alternateBase,
457
                        [
458
                            'secure' => $secure,
459
                            'samesite' => $sameSite,
460
                        ]
461
                    ];
462
                }
463
            }
464
        }
465
        return $data;
466
    }
467
468
    /**
469
     * @dataProvider provideSecureSamesiteData
470
     */
471
    public function testBuildCookieParamsSecureAndSamesite(
472
        bool $secure,
473
        string $sameSite,
474
        string $alternateBase,
475
        array $expected
476
    ): void {
477
        $session = new Session(null);
478
        $methodBuildCookieParams = new ReflectionMethod($session, 'buildCookieParams');
479
        $methodBuildCookieParams->setAccessible(true);
480
481
        Config::modify()->set(Session::class, 'cookie_secure', $secure);
482
        Config::modify()->set(Session::class, 'cookie_samesite', $sameSite);
483
        Config::modify()->set(Director::class, 'alternate_base_url', $alternateBase);
484
        $params = $methodBuildCookieParams->invoke($session, new NullHTTPRequest());
485
        foreach ($expected as $key => $value) {
486
            $secure = $secure ? 'true' : 'false';
487
            $this->assertSame($value, $params[$key], "Inputs were 'secure': $secure, 'samesite': $sameSite, 'anternateBase': $alternateBase");
488
        }
489
    }
490
491
    /**
492
     * Check that the samesite value is being validated
493
     */
494
    public function testBuildCookieParamsSamesiteIsValidated(): void
495
    {
496
        $session = new Session(null);
497
        $methodBuildCookieParams = new ReflectionMethod($session, 'buildCookieParams');
498
        $methodBuildCookieParams->setAccessible(true);
499
500
        // Throw an exception when a warning is logged so we can catch it
501
        $mockLogger = $this->getMockBuilder(Logger::class)->setConstructorArgs(['testLogger'])->getMock();
502
        $catchMessage = 'A warning was logged';
503
        $mockLogger->expects($this->once())
504
            ->method('warning')
505
            ->willThrowException(new Exception($catchMessage));
506
        Injector::inst()->registerService($mockLogger, LoggerInterface::class);
507
508
        // samesite "None" should log a warning for non-https requests
509
        Config::modify()->set(Director::class, 'alternate_base_url', 'http://insecure.example.com/some-path');
510
        Config::modify()->set(Session::class, 'cookie_samesite', 'None');
511
        $this->expectException(Exception::class);
512
        $this->expectExceptionMessage($catchMessage);
513
        $methodBuildCookieParams->invoke($session, new NullHTTPRequest());
514
    }
515
516
    public function testInvalidSamesite(): void
517
    {
518
        $session = new Session(null);
519
        $methodBuildCookieParams = new ReflectionMethod($session, 'buildCookieParams');
520
        $methodBuildCookieParams->setAccessible(true);
521
522
        $this->expectException(LogicException::class);
523
        Config::modify()->set(Session::class, 'cookie_samesite', 'invalid');
524
        $methodBuildCookieParams->invoke($session, new NullHTTPRequest());
525
    }
526
}
527