Completed
Push — master ( 4282cd...e9aacf )
by Garion
23s queued 10s
created

testRegisterReturnsErrorWhenRequiredInformationIsMissing()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 6
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 1
eloc 3
c 1
b 0
f 0
nc 1
nop 0
dl 0
loc 6
rs 10
1
<?php
2
3
namespace SilverStripe\WebAuthn\Tests;
4
5
use Exception;
6
use PHPUnit_Framework_MockObject_MockObject;
7
use Psr\Log\LoggerInterface;
8
use SilverStripe\Control\HTTPRequest;
9
use SilverStripe\Core\Injector\Injector;
10
use SilverStripe\Dev\SapphireTest;
11
use SilverStripe\MFA\State\Result;
12
use SilverStripe\MFA\Store\SessionStore;
13
use SilverStripe\Security\Member;
14
use SilverStripe\WebAuthn\RegisterHandler;
15
use Webauthn\AttestationStatement\AttestationObject;
16
use Webauthn\AuthenticatorAssertionResponse;
17
use Webauthn\AuthenticatorAttestationResponse;
18
use Webauthn\AuthenticatorAttestationResponseValidator;
19
use Webauthn\AuthenticatorData;
20
use Webauthn\AuthenticatorResponse;
21
use Webauthn\AuthenticatorSelectionCriteria;
22
use Webauthn\PublicKeyCredential;
23
use Webauthn\PublicKeyCredentialCreationOptions;
24
use Webauthn\PublicKeyCredentialLoader;
25
26
class RegisterHandlerTest extends SapphireTest
27
{
28
    protected $usesDatabase = true;
29
30
    /**
31
     * @var RegisterHandler
32
     */
33
    protected $handler;
34
35
    /**
36
     * @var Member
37
     */
38
    protected $member;
39
40
    /**
41
     * @var HTTPRequest
42
     */
43
    protected $request;
44
45
    /**
46
     * @var SessionStore
47
     */
48
    protected $store;
49
50
    /**
51
     * @var array
52
     */
53
    protected $originalServer;
54
55
    protected function setUp()
56
    {
57
        parent::setUp();
58
59
        $this->request = new HTTPRequest('GET', '/');
60
        $this->handler = Injector::inst()->create(RegisterHandler::class);
61
62
        $memberID = $this->logInWithPermission();
63
        /** @var Member $member */
64
        $this->member = Member::get()->byID($memberID);
65
66
        $this->store = new SessionStore($this->member);
67
68
        $this->originalServer = $_SERVER;
69
70
        // Set default configuration settings
71
        RegisterHandler::config()->set(
72
            'authenticator_attachment',
73
            AuthenticatorSelectionCriteria::AUTHENTICATOR_ATTACHMENT_CROSS_PLATFORM
74
        );
75
    }
76
77
    protected function tearDown()
78
    {
79
        $_SERVER = $this->originalServer;
80
81
        parent::tearDown();
82
    }
83
84
    /**
85
     * @param string $baseUrl
86
     * @param string $expected
87
     * @dataProvider hostProvider
88
     */
89
    public function testRelyingPartyEntityDomainIncludesSilverStripeDomain(string $baseUrl, string $expected)
90
    {
91
        $_SERVER['HTTP_HOST'] = $baseUrl;
92
93
        $result = $this->handler->start($this->store);
94
        $this->assertArrayHasKey('keyData', $result);
95
96
        /** @var PublicKeyCredentialCreationOptions $options */
97
        $options = $result['keyData'];
98
        $this->assertInstanceOf(PublicKeyCredentialCreationOptions::class, $options);
99
100
        $relyingPartyEntity = $options->getRp();
101
        $this->assertSame(
102
            $expected,
103
            $relyingPartyEntity->getId(),
104
            'Relying party entity should identify the current SilverStripe domain'
105
        );
106
    }
107
108
    /**
109
     * @return array
110
     */
111
    public function hostProvider(): array
112
    {
113
        return [
114
            'domain only' => ['http://example.com', 'example.com'],
115
            'domain with port' => ['https://example.com:8080', 'example.com'],
116
            'subdomain' => ['https://www.example.com', 'www.example.com'],
117
            'subdomain with port' => ['http://my.example.com:8887', 'my.example.com'],
118
            'subfolder' => ['https://example.com/mysite', 'example.com'],
119
            'subfolder with port' => ['http://example.com:8080/mysite', 'example.com'],
120
            'subdomain with subfolder' => ['http://my.example.com/mysite', 'my.example.com'],
121
            'subdomain with port and subfolder' => ['https://my.example.com:8080/mysite', 'my.example.com'],
122
            'credentials with domain and trailing slash' => ['http://foo:[email protected]/', 'example.com'],
123
        ];
124
    }
125
126
    public function testAuthenticatorSelectionCriteriaRequiresCrossPlatformAttachmentByDefault()
127
    {
128
        $result = $this->handler->start($this->store);
129
        $this->assertArrayHasKey('keyData', $result);
130
131
        /** @var PublicKeyCredentialCreationOptions $options */
132
        $options = $result['keyData'];
133
        $this->assertInstanceOf(PublicKeyCredentialCreationOptions::class, $options);
134
135
        $authenticatorSelection = $options->getAuthenticatorSelection();
136
        $this->assertSame(
137
            AuthenticatorSelectionCriteria::AUTHENTICATOR_ATTACHMENT_CROSS_PLATFORM,
138
            $authenticatorSelection->getAuthenticatorAttachment()
139
        );
140
    }
141
142
    public function testStart()
143
    {
144
        $result = $this->handler->start($this->store);
145
        $this->assertArrayHasKey('keyData', $result);
146
147
        /** @var PublicKeyCredentialCreationOptions $options */
148
        $options = $result['keyData'];
149
        $this->assertInstanceOf(PublicKeyCredentialCreationOptions::class, $options);
150
    }
151
152
    public function testRegisterReturnsErrorWhenRequiredInformationIsMissing()
153
    {
154
        $result = $this->handler->register($this->request, $this->store);
155
156
        $this->assertFalse($result->isSuccessful());
157
        $this->assertContains('Incomplete data', $result->getMessage());
158
    }
159
160
    /**
161
     * @param AuthenticatorResponse $mockResponse
162
     * @param Result $expectedResult
163
     * @param callable $responseValidatorMockCallback
164
     * @dataProvider registerProvider
165
     */
166
    public function testRegister($mockResponse, $expectedResult, callable $responseValidatorMockCallback = null)
167
    {
168
        /** @var RegisterHandler&PHPUnit_Framework_MockObject_MockObject $handlerMock */
169
        $handlerMock = $this->getMockBuilder(RegisterHandler::class)
170
            ->setMethods(['getPublicKeyCredentialLoader', 'getAuthenticatorAttestationResponseValidator'])
171
            ->getMock();
172
173
        $responseValidatorMock = $this->createMock(AuthenticatorAttestationResponseValidator::class);
174
        // Allow the data provider to customise the validation check handling
175
        if ($responseValidatorMockCallback) {
176
            $responseValidatorMockCallback($responseValidatorMock);
177
        }
178
        $handlerMock->expects($this->any())->method('getAuthenticatorAttestationResponseValidator')
179
            ->willReturn($responseValidatorMock);
180
181
        $loggerMock = $this->createMock(LoggerInterface::class);
182
        $handlerMock->setLogger($loggerMock);
183
184
        $loaderMock = $this->createMock(PublicKeyCredentialLoader::class);
185
        $handlerMock->expects($this->once())->method('getPublicKeyCredentialLoader')->willReturn($loaderMock);
186
187
        $publicKeyCredentialMock = $this->createMock(PublicKeyCredential::class);
188
        $loaderMock->expects($this->once())->method('load')->with('example')->willReturn(
189
            $publicKeyCredentialMock
190
        );
191
192
        $publicKeyCredentialMock->expects($this->once())->method('getResponse')->willReturn($mockResponse);
193
194
        $this->request->setBody(json_encode([
195
            'credentials' => base64_encode('example'),
196
        ]));
197
198
        $result = $handlerMock->register($this->request, $this->store);
199
200
        $this->assertSame($expectedResult->isSuccessful(), $result->isSuccessful());
201
        if ($expectedResult->getMessage()) {
202
            $this->assertContains($expectedResult->getMessage(), $result->getMessage());
203
        }
204
    }
205
206
    /**
207
     * Some centralised or reusable logic for testRegister. Note that some of the mocks are only used in some of the
208
     * provided data scenarios, but any expected call numbers are based on all scenarios being run.
209
     *
210
     * @return array[]
211
     */
212
    public function registerProvider()
213
    {
214
        $authDataMock = $this->createMock(AuthenticatorData::class);
215
        $authDataMock->expects($this->exactly(3))->method('hasAttestedCredentialData')
216
            // The first call is the "response indicates incomplete data" test case, second is "valid response",
217
            // third is "invalid response"
218
            ->willReturnOnConsecutiveCalls(false, true, true);
219
220
        $attestationMock = $this->createMock(AttestationObject::class);
221
        $attestationMock->expects($this->any())->method('getAuthData')->willReturn($authDataMock);
222
223
        $responseMock = $this->createMock(AuthenticatorAttestationResponse::class);
224
        $responseMock->expects($this->any())->method('getAttestationObject')->willReturn($attestationMock);
225
226
        return [
227
            'wrong response return type' => [
228
                // Deliberately the wrong child implementation of \Webauthn\AuthenticatorResponse
229
                $this->createMock(AuthenticatorAssertionResponse::class),
230
                new Result(false, 'Unexpected response type found'),
231
            ],
232
            'response indicates incomplete data' => [
233
                $responseMock,
234
                new Result(false, 'Incomplete data, required information missing'),
235
            ],
236
            'valid response' => [
237
                $responseMock,
238
                new Result(true),
239
                function (PHPUnit_Framework_MockObject_MockObject $responseValidatorMock) {
240
                    // Specifically setting expectations for the result of the response validator's "check" call
241
                    $responseValidatorMock->expects($this->once())->method('check')->willReturn(true);
242
                },
243
            ],
244
            'invalid response' => [
245
                $responseMock,
246
                new Result(false, 'I am a test'),
247
                function (PHPUnit_Framework_MockObject_MockObject $responseValidatorMock) {
248
                    // Specifically setting expectations for the result of the response validator's "check" call
249
                    $responseValidatorMock->expects($this->once())->method('check')
250
                        ->willThrowException(new Exception('I am a test'));
251
                },
252
            ],
253
        ];
254
    }
255
256
    public function testGetName()
257
    {
258
        $this->assertSame('Security key', $this->handler->getName());
259
    }
260
261
    public function testGetDescription()
262
    {
263
        $this->assertContains('A small USB device', $this->handler->getDescription());
264
    }
265
266
    public function testGetSupportLink()
267
    {
268
        RegisterHandler::config()->set('user_help_link', 'http://google.com');
269
        $this->assertSame('http://google.com', $this->handler->getSupportLink());
270
    }
271
272
    public function testGetComponent()
273
    {
274
        $this->assertSame('WebAuthnRegister', $this->handler->getComponent());
275
    }
276
}
277