Issues (95)

Branch: main

tests/features/bootstrap/FeatureContext.php (6 issues)

1
<?php
2
3
/**
4
 * Copyright 2020 SURFnet B.V.
5
 *
6
 * Licensed under the Apache License, Version 2.0 (the "License");
7
 * you may not use this file except in compliance with the License.
8
 * You may obtain a copy of the License at
9
 *
10
 *     http://www.apache.org/licenses/LICENSE-2.0
11
 *
12
 * Unless required by applicable law or agreed to in writing, software
13
 * distributed under the License is distributed on an "AS IS" BASIS,
14
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
 * See the License for the specific language governing permissions and
16
 * limitations under the License.
17
 */
18
19
namespace Surfnet\StepupGateway\Behat;
20
21
use Behat\Behat\Context\Context;
22
use Behat\Behat\Hook\Scope\BeforeFeatureScope;
23
use Behat\Behat\Hook\Scope\BeforeScenarioScope;
24
use Behat\Mink\Exception\ExpectationException;
25
use DMore\ChromeDriver\ChromeDriver;
26
use FriendsOfBehat\SymfonyExtension\Driver\SymfonyDriver;
27
use Psr\Log\LoggerInterface;
28
use RuntimeException;
29
use SAML2\Compat\ContainerSingleton;
30
use Surfnet\SamlBundle\Tests\TestSaml2Container;
31
use Surfnet\StepupGateway\Behat\Service\FixtureService;
32
33
class FeatureContext implements Context
34
{
35
    /**
36
     * @var FixtureService
37
     */
38
    private $fixtureService;
39
40
    private $whitelistedInstitutions = [];
41
42
    /**
43
     * @var MinkContext
44
     */
45
    private $minkContext;
46
47
    /**
48
     * @var array
49
     */
50
    private $currentToken;
51
52
    private $sso2faCookieName;
53
54
    /**
55
     * @var string|null
56
     */
57
    private $previousSsoOn2faCookieValue;
58
59
    /**
60
     * @var string
61
     */
62
    private $sessCookieName;
63
64
    /**
65
     * @var string
66
     */
67
    private $cookieDomain;
68
69
    public function __construct(FixtureService $fixtureService, LoggerInterface $logger)
70
    {
71
        $this->fixtureService = $fixtureService;
72
        $this->sso2faCookieName = 'stepup-gateway_sso-on-second-factor-authentication';
73
        $this->sessCookieName = 'MOCKSESSID';
74
        $this->cookieDomain = '.gateway.dev.openconext.local';
75
76
        // Set a test container for the SAML2 Library to work with (the compat container is broken)
77
        ContainerSingleton::setContainer(new TestSaml2Container($logger));
78
    }
79
80
    /**
81
     * @BeforeFeature
82
     */
83
    public static function setupDatabase(BeforeFeatureScope $scope): void
0 ignored issues
show
The parameter $scope is not used and could be removed. ( Ignorable by Annotation )

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

83
    public static function setupDatabase(/** @scrutinizer ignore-unused */ BeforeFeatureScope $scope): void

This check looks for parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
84
    {
85
        // Generate test databases
86
        echo "Preparing test schemas\n";
87
        shell_exec("/var/www/html/bin/console doctrine:schema:drop --env=smoketest --force");
88
        shell_exec("/var/www/html/bin/console doctrine:schema:create --env=smoketest");
89
    }
90
91
    /**
92
     * @BeforeScenario
93
     */
94
    public function gatherContexts(BeforeScenarioScope $scope): void
95
    {
96
        $environment = $scope->getEnvironment();
97
        $this->minkContext = $environment->getContext(MinkContext::class);
0 ignored issues
show
The method getContext() does not exist on Behat\Testwork\Environment\Environment. It seems like you code against a sub-type of Behat\Testwork\Environment\Environment such as Behat\Behat\Context\Envi...lizedContextEnvironment or Behat\Behat\Context\Envi...lizedContextEnvironment or FriendsOfBehat\SymfonyEx...onyExtensionEnvironment. ( Ignorable by Annotation )

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

97
        /** @scrutinizer ignore-call */ 
98
        $this->minkContext = $environment->getContext(MinkContext::class);
Loading history...
98
    }
99
100
    /**
101
     * @Given /^a user from "([^"]*)" identified by "([^"]*)" with a vetted "([^"]*)" token$/
102
     */
103
    public function aUserIdentifiedByWithAVettedToken($institution, $nameId, $tokenType): void
104
    {
105
        switch (strtolower($tokenType)) {
106
            case "yubikey":
107
                $this->currentToken = $this->fixtureService->registerYubikeyToken($nameId, $institution);
108
                break;
109
            case "sms":
110
                $this->currentToken = $this->fixtureService->registerSmsToken($nameId, $institution);
111
                break;
112
            case "tiqr":
113
                $this->currentToken = $this->fixtureService->registerTiqrToken($nameId, $institution);
114
                break;
115
        }
116
    }
117
118
    /**
119
     * @Given /^a user from "([^"]*)" identified by "([^"]*)" with a self-asserted "([^"]*)" token$/
120
     */
121
    public function aUserIdentifiedByWithASelfAssertedToken($institution, $nameId, $tokenType): void
122
    {
123
        switch (strtolower($tokenType)) {
124
            case "yubikey":
125
                $this->currentToken = $this->fixtureService->registerYubikeyToken($nameId, $institution, true);
126
                break;
127
            case "sms":
128
                $this->currentToken = $this->fixtureService->registerSmsToken($nameId, $institution, true);
129
                break;
130
            case "tiqr":
131
                $this->currentToken = $this->fixtureService->registerTiqrToken($nameId, $institution, true);
132
                break;
133
        }
134
    }
135
136
    /**
137
     * @Then I should see the Yubikey OTP screen
138
     */
139
    public function iShouldSeeTheYubikeyOtpScreen(): void
140
    {
141
        $this->minkContext->assertPageContainsText('Your YubiKey-code');
142
    }
143
144
    /**
145
     * @Then I should see the SMS verification screen
146
     */
147
    public function iShouldSeeTheSMSScreen(): void
148
    {
149
        $this->minkContext->assertPageContainsText('Enter the received SMS-code');
150
        $this->minkContext->assertPageContainsText('Send again');
151
    }
152
153
    /**
154
     * @Given /^I should see the Tiqr authentication screen$/
155
     */
156
    public function iShouldSeeTheTiqrAuthenticationScreen(): void
157
    {
158
        $this->pressButtonWhenNoJavascriptSupport();
159
        $this->minkContext->assertPageContainsText('Log in with Tiqr');
160
    }
161
162
    /**
163
     * @When I enter the OTP
164
     */
165
    public function iEnterTheOtp(): void
166
    {
167
        $this->minkContext->fillField('gateway_verify_yubikey_otp_otp', 'bogus-otp-we-use-a-mock-yubikey-service');
168
        $this->minkContext->pressButton('gateway_verify_yubikey_otp_submit');
169
        $this->pressButtonWhenNoJavascriptSupport();
170
    }
171
172
    /**
173
     * @When I enter the SMS verification code
174
     */
175
    public function iEnterTheSmsVerificationCode(): void
176
    {
177
        $cookieValue = $this->minkContext->getSession()->getDriver()->getCookie('smoketest-sms-service');
178
        if ($cookieValue === null) {
179
            throw new RuntimeException('Unable to load the smoketest-sms-service cookie');
180
        }
181
        $matches = [];
182
        preg_match('/^Your\ SMS\ code:\ (.*)$/', $cookieValue, $matches);
183
        $this->minkContext->fillField('gateway_verify_sms_challenge_challenge', $matches[1]);
184
        $this->minkContext->pressButton('gateway_verify_sms_challenge_verify_challenge');
185
        $this->pressButtonWhenNoJavascriptSupport();
186
    }
187
188
    /**
189
     * @When I enter the expired SMS verification code
190
     */
191
    public function iEnterTheExpiredSmsVerificationCode(): void
192
    {
193
        $cookieValue = $this->minkContext->getSession()->getDriver()->getCookie('smoketest-sms-service');
194
        $matches = [];
195
        preg_match('/^Your\ SMS\ code:\ (.*)$/', $cookieValue, $matches);
0 ignored issues
show
It seems like $cookieValue can also be of type null; however, parameter $subject of preg_match() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

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

195
        preg_match('/^Your\ SMS\ code:\ (.*)$/', /** @scrutinizer ignore-type */ $cookieValue, $matches);
Loading history...
196
        $this->minkContext->fillField('gateway_verify_sms_challenge_challenge', $matches[1]);
197
        $this->minkContext->pressButton('gateway_verify_sms_challenge_verify_challenge');
198
    }
199
200
    /**
201
     * @When I finish the Tiqr authentication
202
     */
203
    public function iFinishGsspAuthentication(): void
204
    {
205
        $this->pressButtonWhenNoJavascriptSupport();
206
        $this->pressButtonWhenNoJavascriptSupport();
207
    }
208
209
    /**
210
     * @Given /^a whitelisted institution ([^"]*)$/
211
     */
212
    public function aWhitelistedInstitution($institution): void
213
    {
214
        $this->whitelistedInstitutions[] = $this->fixtureService->whitelist($institution)['institution'];
215
    }
216
217
    /**
218
     * @Given /^an institution "([^"]*)" that allows "([^"]*)"$/
219
     */
220
    public function anInstitutionThatAllows(string $institution, string $option): void
221
    {
222
        switch(true) {
223
            case $option === 'sso_on_2fa':
224
                $optionColumnName = 'sso_on2fa_enabled';
225
                break;
226
            case $option === 'sso_registration_bypass':
227
                $optionColumnName = 'sso_registration_bypass';
228
                break;
229
            default:
230
                throw new RuntimeException(sprintf('Option "%s" is not supported', $option));
231
        }
232
        $this->fixtureService->configureBoolean($institution, $optionColumnName, true);
233
    }
234
235
    /**
236
     * @Then /^I select my ([^"]*) token on the WAYG$/
237
     */
238
    public function iShouldSelectMyTokenOnTheWAYG($tokenType): void
239
    {
240
        switch (strtolower($tokenType)) {
241
            case "yubikey":
242
                $this->minkContext->pressButton('gateway_choose_second_factor_choose_yubikey');
243
                break;
244
            case "sms":
245
                $this->minkContext->pressButton('gateway_choose_second_factor_choose_sms');
246
                break;
247
            case "tiqr":
248
                $this->minkContext->pressButton('gateway_choose_second_factor_choose_tiqr');
249
                break;
250
        }
251
    }
252
253
    /**
254
     * @Then /^I should be on the WAYG$/
255
     */
256
    public function iShouldBeOnTheWAYG(): void
257
    {
258
        $this->minkContext->assertPageContainsText('Choose a token for login');
259
    }
260
261
    /**
262
     * @Then /^an error response is posted back to the SP$/
263
     */
264
    public function anErrorResponseIsPostedBackToTheSP(): void
265
    {
266
        $this->pressButtonWhenNoJavascriptSupport();
267
    }
268
269
    /**
270
     * @Given /^I cancel the authentication$/
271
     */
272
    public function iCancelTheAuthentication(): void
273
    {
274
        $this->minkContext->pressButton('Cancel');
275
    }
276
277
    /**
278
     * @Given /^I pass through the Gateway$/
279
     */
280
    public function iPassThroughTheGateway(): void
281
    {
282
        $this->pressButtonWhenNoJavascriptSupport();
283
    }
284
285
    /**
286
     * @Given /^I pass through the IdP/
287
     */
288
    public function iPassThroughTheIdP(): void
289
    {
290
        if ($this->minkContext->getSession()->getDriver() instanceof SymfonyDriver) {
291
            $this->minkContext->pressButton('Yes, continue');
292
        }
293
    }
294
295
    /**
296
     * @Then /^the response should have a SSO\-2FA cookie$/
297
     * @throws ExpectationException
298
     */
299
    public function theResponseShouldHaveASSO2FACookie(): void
300
    {
301
        $this->minkContext->visit('https://gateway.dev.openconext.local/info');
302
        $cookieValue = $this->minkContext->getSession()->getCookie($this->sso2faCookieName);
303
        // Store the previous cookie value
304
        $this->previousSsoOn2faCookieValue = $cookieValue;
305
        $this->validateSsoOn2faCookie($cookieValue);
306
    }
307
308
309
310
    /**
311
     * @Then /^the response should have a valid session cookie$/
312
     * @throws ExpectationException
313
     */
314
    public function validateSessionCookie(): void
315
    {
316
        $this->minkContext->visit('https://gateway.dev.openconext.local/info');
317
318
        $driver = $this->minkContext->getSession()->getDriver();
319
320
        $cookie = $this->minkContext->getSession()->getCookie($this->sessCookieName);
321
        if ($cookie === null) {
322
            throw new ExpectationException(
323
                'No session cookie found',
324
                $this->minkContext->getSession()->getDriver()
325
            );
326
        }
327
328
        if (!$driver instanceof ChromeDriver) {
329
            return;
330
        }
331
332
        $sessionCookie = null;
333
        foreach ($driver->getCookies() as $cookie) {
334
            if ($cookie['name'] === $this->sessCookieName) {
335
                $sessionCookie = $cookie;
336
                break;
337
            }
338
        }
339
        if ($sessionCookie === null) {
340
            throw new ExpectationException(
341
                'No session cookie found',
342
                $this->minkContext->getSession()->getDriver()
343
            );
344
        }
345
346
        if (!array_key_exists('domain', $sessionCookie) || $sessionCookie['domain'] !== $this->cookieDomain) {
0 ignored issues
show
$sessionCookie of type string is incompatible with the type ArrayObject|array expected by parameter $array of array_key_exists(). ( Ignorable by Annotation )

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

346
        if (!array_key_exists('domain', /** @scrutinizer ignore-type */ $sessionCookie) || $sessionCookie['domain'] !== $this->cookieDomain) {
Loading history...
347
            throw new ExpectationException(
348
                'The domain of the session cookie is invalid',
349
                $this->minkContext->getSession()->getDriver()
350
            );
351
        };
352
    }
353
354
    /**
355
     * @Then /^the response should not have a SSO\-2FA cookie$/
356
     * @throws ExpectationException
357
     */
358
    public function theResponseShouldNotHaveASSO2FACookie(): void
359
    {
360
        $this->minkContext->visit('https://gateway.dev.openconext.local/info');
361
        $cookie = $this->minkContext->getSession()->getCookie($this->sso2faCookieName);
362
        if (!is_null($cookie)) {
363
            throw new ExpectationException(
364
                'The SSO cookie must NOT be present',
365
                $this->minkContext->getSession()->getDriver()
366
            );
367
        }
368
    }
369
370
    /**
371
     * @Then /^a new SSO\-2FA cookie was written$/
372
     * @throws ExpectationException
373
     */
374
    public function theSSO2FACookieIsRewritten(): void
375
    {
376
        $this->minkContext->visit('https://gateway.dev.openconext.local/info');
377
        $cookieValue = $this->minkContext->getSession()->getCookie($this->sso2faCookieName);
378
        $this->validateSsoOn2faCookie($cookieValue);
379
380
        if ($this->previousSsoOn2faCookieValue === $cookieValue) {
381
            throw new ExpectationException(
382
                sprintf('The SSO on 2FA cookie did not change since the previous response: "%s" !== "%s"', $this->previousSsoOn2faCookieValue, $cookieValue),
383
                $this->minkContext->getSession()->getDriver()
384
            );
385
        }
386
    }
387
388
    /**
389
     * @Then /^the existing SSO\-2FA cookie was used$/
390
     * @throws ExpectationException
391
     */
392
    public function theSSO2FACookieRemainedTheSame(): void
393
    {
394
        $this->minkContext->visit('https://gateway.dev.openconext.local/info');
395
        $cookieValue = $this->minkContext->getSession()->getCookie($this->sso2faCookieName);
396
        $this->validateSsoOn2faCookie($cookieValue);
397
        if ($this->previousSsoOn2faCookieValue !== $cookieValue) {
398
            throw new ExpectationException(
399
                sprintf(
400
                    'The SSO on 2FA cookie changed since the previous response %s vs %s',
401
                    $this->previousSsoOn2faCookieValue,
402
                    $cookieValue
403
                ),
404
                $this->minkContext->getSession()->getDriver()
405
            );
406
        }
407
    }
408
409
    /**
410
     * @Given /^the user cleared cookies from browser$/
411
     */
412
    public function userClearedCookies(): void
413
    {
414
        $this->minkContext->visit('https://gateway.dev.openconext.local/info');
415
        $this->minkContext->getSession()->setCookie($this->sso2faCookieName, null);
416
    }
417
418
    /**
419
     * @Given /^the SSO\-2FA cookie should contain "([^"]*)"$/
420
     */
421
    public function theSSO2FACookieShouldContain($expectedCookieValue): void
422
    {
423
        $this->minkContext->visit('https://gateway.dev.openconext.local/info');
424
        $cookieValue = $this->minkContext->getSession()->getCookie($this->sso2faCookieName);
425
        if (strstr($cookieValue, $expectedCookieValue) === false) {
0 ignored issues
show
It seems like $cookieValue can also be of type null; however, parameter $haystack of strstr() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

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

425
        if (strstr(/** @scrutinizer ignore-type */ $cookieValue, $expectedCookieValue) === false) {
Loading history...
426
            throw new ExpectationException(
427
                sprintf(
428
                    'The SSO on 2FA cookie did not contain the expected value: "%s", actual contents: "%s"',
429
                    $expectedCookieValue,
430
                    $cookieValue
431
                ),
432
                $this->minkContext->getSession()->getDriver()
433
            );
434
        }
435
    }
436
437
    private function getCookieNames(array $responseCookieHeaders): array
0 ignored issues
show
The method getCookieNames() is not used, and could be removed.

This check looks for private methods that have been defined, but are not used inside the class.

Loading history...
438
    {
439
        $response = [];
440
        foreach($responseCookieHeaders as $cookie) {
441
            $parts = explode('=', $cookie);
442
            $response[] = array_shift($parts);
443
        }
444
        return $response;
445
    }
446
447
    /**
448
     * @throws ExpectationException
449
     */
450
    private function validateSsoOn2faCookie(?string $cookieValue): void
451
    {
452
        if (empty($cookieValue)) {
453
            throw new ExpectationException(
454
                sprintf(
455
                    'The SSO on 2FA cookie was not present, or empty. Cookie name: %s',
456
                    $this->sso2faCookieName
457
                ),
458
                $this->minkContext->getSession()->getDriver()
459
            );
460
        }
461
    }
462
463
    private function pressButtonWhenNoJavascriptSupport()
464
    {
465
        if ($this->minkContext->getSession()->getDriver() instanceof SymfonyDriver) {
466
            $this->minkContext->pressButton('Submit');
467
        }
468
    }
469
}
470