diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..021c06c --- /dev/null +++ b/.gitattributes @@ -0,0 +1,10 @@ +/demo export-ignore +/tests export-ignore +/.gitattributes export-ignore +/.gitignore export-ignore +/.scrutinizer.yml export-ignore +/.travis.yml export-ignore +/phpunit.xml.dist export-ignore +/phpcs.xml.dist export-ignore +/phpstan.neon export-ignore +/README.md export-ignore diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml new file mode 100644 index 0000000..75c5844 --- /dev/null +++ b/.github/workflows/php.yml @@ -0,0 +1,47 @@ +name: PHP + +on: + push: + branches: [ master ] + pull_request: + branches: [ master ] + +jobs: + run: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - php: 8.0 + - php: 8.1 + - php: 8.2 + coverage: '--coverage --coverage-xml' + name: PHP ${{ matrix.php }} + + steps: + - uses: actions/checkout@v2 + with: + fetch-depth: 10 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + coverage: xdebug + + - name: Validate composer.json + run: composer validate + + - name: Install dependencies + run: composer update --prefer-dist --no-progress --no-suggest + + - name: Run Codeception + run: vendor/bin/codecept run ${{ matrix.coverage }} + + - name: Upload coverage to Scrutinizer + if: ${{ matrix.coverage }} + uses: sudo-bot/action-scrutinizer@latest + with: + cli-args: "--format=php-clover build/logs/clover.xml --revision=${{ github.event.pull_request.head.sha || github.sha }}" + diff --git a/.gitignore b/.gitignore index 622097a..9a51957 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ tests/_output/* !.elasticbeanstalk/*.cfg.yml !.elasticbeanstalk/*.global.yml /tests/_support/_generated/ +.idea diff --git a/.scrutinizer.yml b/.scrutinizer.yml new file mode 100644 index 0000000..f891c68 --- /dev/null +++ b/.scrutinizer.yml @@ -0,0 +1,24 @@ +#language: php +checks: + php: true +filter: + excluded_paths: + - tests +build: + nodes: + analysis: + environment: + php: 8.2 + postgresql: false + redis: false + mongodb: false + tests: + override: + - phpcs-run src + - + command: vendor/bin/phpstan analyze --error-format=checkstyle | sed '/^\s*$/d' > phpstan-checkstyle.xml + analysis: + file: phpstan-checkstyle.xml + format: 'general-checkstyle' + - php-scrutinizer-run + diff --git a/LICENSE b/LICENSE index d024b32..ae4f8c8 100644 --- a/LICENSE +++ b/LICENSE @@ -1,4 +1,4 @@ -Copyright (c) 2017 Arnold Daniels +Copyright (c) 2020 Arnold Daniels Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation diff --git a/README.md b/README.md index b40012e..efb3b95 100644 --- a/README.md +++ b/README.md @@ -1,42 +1,52 @@ -Single Sign-On for PHP (Ajax compatible) ---- +![jasny-banner](https://user-images.githubusercontent.com/100821/62123924-4c501c80-b2c9-11e9-9677-2ebc21d9b713.png) -Jasny\SSO is a relatively simply and straightforward solution for an single sign on (SSO) implementation. With SSO, -logging into a single website will authenticate you for all affiliate sites. +Single Sign-On for PHP +======== -#### How it works +[![PHP](https://github.com/jasny/sso/workflows/PHP/badge.svg)](https://github.com/jasny/sso/actions) +[![Scrutinizer Code Quality](https://scrutinizer-ci.com/g/jasny/sso/badges/quality-score.png?b=master)](https://scrutinizer-ci.com/g/jasny/sso/?branch=master) +[![Code Coverage](https://scrutinizer-ci.com/g/jasny/sso/badges/coverage.png?b=master)](https://scrutinizer-ci.com/g/jasny/sso/?branch=master) +[![Packagist Stable Version](https://img.shields.io/packagist/v/jasny/sso.svg)](https://packagist.org/packages/jasny/sso) +[![Packagist License](https://img.shields.io/packagist/l/jasny/sso.svg)](https://packagist.org/packages/jasny/sso) -When using SSO, when can distinguish 3 parties: +Jasny SSO is a relatively simple and straightforward solution for single sign on (SSO). + +With SSO, logging into a single website will authenticate you for all affiliate sites. The sites don't need to share a +toplevel domain. + +### How it works + +When using SSO, we can distinguish 3 parties: * Client - This is the browser of the visitor * Broker - The website which is visited * Server - The place that holds the user info and credentials -The broker has an id and a secret. These are know to both the broker and server. +The broker has an id and a secret. These are known to both the broker and server. When the client visits the broker, it creates a random token, which is stored in a cookie. The broker will then send the client to the server, passing along the broker's id and token. The server creates a hash using the broker id, broker -secret and the token. This hash is used to create a link to the users session. When the link is created the server +secret and the token. This hash is used to create a link to the user's session. When the link is created the server redirects the client back to the broker. The broker can create the same link hash using the token (from the cookie), the broker id and the broker secret. When -doing requests, it passes that has as session id. +doing requests, it passes that hash as a session id. The server will notice that the session id is a link and use the linked session. As such, the broker and client are using the same session. When another broker joins in, it will also use the same session. -For a more indepth explanation, please [read this article](https://github.com/jasny/sso/wiki). +For a more in depth explanation, please [read this article](https://github.com/jasny/sso/wiki). -#### How is this different from OAuth? +### How is this different from OAuth? -With OAuth, you can authenticate a user at an external server and get access to their profile info. However you +With OAuth, you can authenticate a user at an external server and get access to their profile info. However, you aren't sharing a session. -A user logs in to website foo.com using Google OAuth. Next he visits website bar.org which also uses Google OAuth. -Regardless of that, he is still required to press on the 'login' button on bar.org. +A user logs in to website foo.com using Google OAuth. Next they visit website bar.org which also uses Google OAuth. +Regardless of that, they are still required to press the 'login' button on bar.org. -With Jasny/SSO both websites use the same session. So when the user visits bar.org, he's automatically logged in. -When he logs out (on either of the sites), he's logged out for both. +With Jasny SSO both websites use the same session. So when the user visits bar.org, they are automatically logged in. +When they log out (on either of the sites), they are logged out for both. ## Installation @@ -44,99 +54,260 @@ Install this library through composer composer require jasny/sso -## Usage +## Demo + +There is a demo server and two demo brokers as example. One with normal redirects and one using +[JSONP](https://en.wikipedia.org/wiki/JSONP) / AJAX. + +To prove it's working you should setup the server and two or more brokers, each on their own machine and their own +(sub)domain. However, you can also run both server and brokers on your own machine, simply to test it out. + +On *nix (Linux / Unix / OSX) run: + + php -S localhost:8000 -t demo/server/ + export SSO_SERVER=http://localhost:8000/attach.php SSO_BROKER_ID=Alice SSO_BROKER_SECRET=8iwzik1bwd; php -S localhost:8001 -t demo/broker/ + export SSO_SERVER=http://localhost:8000/attach.php SSO_BROKER_ID=Greg SSO_BROKER_SECRET=7pypoox2pc; php -S localhost:8002 -t demo/broker/ + export SSO_SERVER=http://localhost:8000/attach.php SSO_BROKER_ID=Julius SSO_BROKER_SECRET=ceda63kmhp; php -S localhost:8003 -t demo/ajax-broker/ + +Now open some tabs and visit + + * http://localhost:8001 + * http://localhost:8002 + * http://localhost:8003 + +username | password +-------- | -------- +jackie | jackie123 +john | john123 + +_Note that after logging in, you need to refresh on the other brokers to see the effect._ + +# Usage + +## Server + +The `Server` class takes a callback as first constructor argument. This callback should look up the secret +for a broker based on the id. + +The second argument must be a PSR-16 compatible cache object. It's used to store the link between broker token and +client session. + +```php +use Jasny\SSO\Server\Server; + +$brokers = [ + 'foo' => ['secret' => '8OyRi6Ix1x', 'domains' => ['example.com']], + // ... +]; + +$server = new Server( + fn($id) => $brokers[$id] ?? null, // Unique secret and allowed domains for each broker. + new Cache() // Any PSR-16 compatible cache +); +``` + +_In this example the brokers are simply configured as an array, but typically you want to fetch the broker info from a DB._ -#### Server +### Attach -`Jasny\SSO\Server` is an abstract class. You need to create a your own class which implements the abstract methods. -These methods are called fetch data from a data souce (like a DB). +A client needs to attach the broker token to the session id by doing an HTTP request to the server. This request can be +handled by calling `attach()`. + +The `attach()` method returns a verification code. This code must be returned to the broker, as it's needed to +calculate the checksum. + +```php +$verificationCode = $server->attach(); +``` + +If it's not possible to attach (for instance in case of an incorrect checksum), an Exception is thrown. + +### Handle broker API request + +After the client session is attached to the broker token, the broker is able to send API requests on behalf of the +client. Calling the `startBrokerSession()` method with start the session of the client based on the bearer token. This +means that these request the server can access the session information of the client through `$_SESSION`. + +``` +$server->startBrokerSession(); +``` + +The broker could use this to login, logout, get user information, etc. The API for handling such requests is outside +the scope of the project. However since the broker uses normal sessions, any existing the authentication can be used. + +_If you're lookup for an authentication library, consider using [Jasny Auth](https://github.com/jasny/auth)._ + +### PSR-7 + +By default, the library works with superglobals like `$_GET` and `$_SERVER`. Alternatively it can use a PSR-7 server +request. This can be passed to `attach()` and `startBrokerSession()` as argument. + +```php +$verificationCode = $server->attach($serverRequest); +``` + +### Session interface + +By default, the library uses the superglobal `$_SESSION` and the `php_session_*()` functions. It does this through +the `GlobalSession` object, which implements `SessionInterface`. + +For projects that use alternative sessions, it's possible to create a wrapper that implements `SessionInterface`. ```php -class MySSOServer extends Jasny\SSO\Server +use Jasny\SSO\Server\SessionInterface; + +class CustomerSessionHandler implements SessionInterface { - /** - * Authenticate using user credentials - * - * @param string $username - * @param string $password - * @return \Jasny\ValidationResult - */ - abstract protected function authenticate($username, $password) - { - ... - } - - /** - * Get the secret key and other info of a broker - * - * @param string $brokerId - * @return array - */ - abstract protected function getBrokerInfo($brokerId) - { - ... - } - - /** - * Get the information about a user - * - * @param string $username - * @return array|object - */ - abstract protected function getUserInfo($username) - { - ... - } + // ... +} +``` + +The `withSession()` methods creates a copy of the Server object with the custom session interface. + +```php +$server = (new Server($callback, $cache)) + ->withSession(new CustomerSessionHandler()); +``` + +The `withSession()` method can also be used with a mock object for testing. + +### Logging + +Enable logging for debugging and catching issues. + +```php +$server = (new Server($callback, $cache)) + ->withLogging(new Logger()); +``` + +Any PSR-3 compatible logger can be used, like [Monolog](https://packagist.org/packages/monolog/monolog) or +[Loggy](https://packagist.org/packages/yubb/loggy). The `context` may contain the broker id, token, and session id. + +## Broker + +When creating a `Broker` instance, you need to pass the server url, broker id and broker secret. The broker id and +secret needs to match the secret registered at the server. + +**CAVEAT**: *The broker id MUST be alphanumeric.* + +### Attach + +Before the broker can do API requests on the client's behalf, the client needs to attach the broker token to the client +session. For this, the client must do an HTTP request to the SSO Server. + +The `getAttachUrl()` method will generate a broker token for the client and use it to create an attach URL. The method +takes an array of query parameters as single argument. + +There are several methods in making the client do an HTTP request. The broker can redirect the client or do a request +via the browser using AJAX or loading an image. + +```php +use Jasny\SSO\Broker\Broker; + +// Configure the broker. +$broker = new Broker( + getenv('SSO_SERVER'), + getenv('SSO_BROKER_ID'), + getenv('SSO_BROKER_SECRET') +); + +// Attach through redirect if the client isn't attached yet. +if (!$broker->isAttached()) { + $returnUrl = (!empty($_SERVER['HTTPS']) ? 'https://' : 'http://') . $_SERVER['HTTP_HOST'] . $_SERVER['REQUEST_URI']; + $attachUrl = $broker->getAttachUrl(['return_url' => $returnUrl]); + + header("Location: $attachUrl", true, 303); + echo "You're redirected to $attachUrl"; + exit(); +} +``` + +### Verify + +Upon verification the SSO Server will return a verification code (as a query parameter or in the JSON response). The code +is used to calculate the checksum. The verification code prevents session hijacking using an attach link. + +```php +if (isset($_GET['sso_verify'])) { + $broker->verify($_GET['sso_verify']); } ``` -The MySSOServer class can be used as controller in an MVC framework. +### API requests -Alternatively you can use MySSOServer as library class. In that case pass option `fail_exception` to the constructor. -This will make the object throw a Jasny\SSO\Exception, rather than set the HTTP response and exit. +Once attached, the broker is able to do API requests on behalf of the client. This can be done by -For more information, checkout the `server` example. +- using the broker `request()` method, or by +- using any HTTP client like Guzzle -#### Broker +#### Broker request -When creating a Jasny\SSO\Broker instance, you need to pass the server url, broker id and broker secret. The broker id -and secret needs to be registered at the server (so fetched when using `getBrokerInfo($brokerId)`). +```php +// Post to modify the user info +$broker->request('POST', '/login', $credentials); -**Be careful**: *The broker id SHOULD be alphanumeric. In any case it MUST NOT contain the "-" character.* +// Get user info +$user = $broker->request('GET', '/user'); +``` -Next you need to call `attach()`. This will generate a token an redirect the client to the server to attach the token -to the client's session. If the client is already attached, the function will simply return. +The `request()` method uses Curl to send HTTP requests, adding the bearer token for authentication. It expects a JSON +response and will automatically decode it. -When the session is attached you can do actions as login/logout or get the user's info. +#### HTTP library (Guzzle) +To use a library like [Guzzle](http://docs.guzzlephp.org/) or [Httplug](http://httplug.io/), get the bearer token using +`getBearerToken()` and set the `Authorization` header + ```php -$broker = new Jasny\SSO\Broker($serverUrl, $brokerId, $brokerSecret); -$broker->attach(); +$guzzle = new GuzzleHttp\Client(['base_uri' => 'https://sso-server.example.com']); -$user = $broker->getUserInfo(); -echo json_encode($user); +$res = $guzzle->request('GET', '/user', [ + 'headers' => [ + 'Authorization' => 'Bearer ' . $broker->getBearerToken() + ] +]); ``` -For more information, checkout the `broker` and `ajax-broker` example. +### Client state -## Examples +By default, the Broker uses the cookies (`$_COOKIE` and `setcookie()`) via the `Cookies` class to persist the client's +SSO token. -There is an example server and two example brokers. One with normal redirects and one using -[JSONP](https://en.wikipedia.org/wiki/JSONP) / AJAX. +#### Cookie -To proof it's working you should setup the server and two or more brokers, each on their own machine and their own -(sub)domain. However you can also run both server and brokers on your own machine, simply to test it out. +Instantiate a new `Cookies` object with custom parameters to modify things like cookie TTL, domain and https only. -On *nix (Linux / Unix / OSX) run: +```php +use Jasny\SSO\Broker\{Broker,Cookies}; + +$broker = (new Broker(getenv('SSO_SERVER'), getenv('SSO_BROKER_ID'), getenv('SSO_BROKER_SECRET'))) + ->withTokenIn(new Cookies(7200, '/myapp', 'example.com', true)); +``` - php -S localhost:9000 -t examples/server/ - export SSO_SERVER=http://localhost:9000 SSO_BROKER_ID=Alice SSO_BROKER_SECRET=8iwzik1bwd; php -S localhost:9001 -t examples/broker/ - export SSO_SERVER=http://localhost:9000 SSO_BROKER_ID=Greg SSO_BROKER_SECRET=7pypoox2pc; php -S localhost:9002 -t examples/broker/ - export SSO_SERVER=http://localhost:9000 SSO_BROKER_ID=Julias SSO_BROKER_SECRET=ceda63kmhp; php -S localhost:9003 -t examples/ajax-broker/ +_(The cookie can never be accessed by the browser.)_ -Now open some tabs and visit http://localhost:9001, http://localhost:9002 and http://localhost:9003. -username/password -jackie/jackie123 -john/john123 +#### Session -_Note that after logging in, you need to refresh on the other brokers to see the effect._ +Alternatively, you can store the SSO token in a PHP session for the broker by using `Session`. + +```php +use Jasny\SSO\Broker\{Broker,Session}; + +session_start(); + +$broker = (new Broker(getenv('SSO_SERVER'), getenv('SSO_BROKER_ID'), getenv('SSO_BROKER_SECRET'))) + ->withTokenIn(new Session()); +``` + +#### Custom + +The method accepts any object that implements `ArrayAccess`, allowing you to create a custom handler if needed. + +```php +class CustomStateHandler implements \ArrayAccess +{ + // ... +} +``` + +This can also be used with a mock object for testing. diff --git a/codeception.yml b/codeception.yml index 3a8fca8..172fb93 100644 --- a/codeception.yml +++ b/codeception.yml @@ -5,17 +5,10 @@ paths: data: tests/_data support: tests/_support envs: tests/_envs +bootstrap: _bootstrap.php settings: - bootstrap: _bootstrap.php colors: true memory_limit: 1024M extensions: enabled: - Codeception\Extension\RunFailed -modules: - config: - Db: - dsn: '' - user: '' - password: '' - dump: tests/_data/dump.sql diff --git a/composer.json b/composer.json index 616fbe2..8867b8a 100644 --- a/composer.json +++ b/composer.json @@ -3,7 +3,7 @@ "description": "Simple Single Sign-On", "keywords": ["sso", "auth"], "license": "MIT", - "homepage": "http://www.jasny.net/articles/simple-single-sign-on-for-php/", + "homepage": "https://github.com/jasny/sso/wiki", "authors": [ { "name": "Arnold Daniels", @@ -16,18 +16,45 @@ "source": "https://github.com/jasny/sso" }, "require": { - "php": ">=5.5.0", - "desarrolla2/cache": "^2.0.0", - "jasny/validation-result": "^1.0.0" + "php": "^8.0", + "ext-json": "*", + "jasny/immutable": "^2.1", + "psr/simple-cache": "*", + "psr/log": "*" }, "require-dev": { - "codeception/codeception": "^2.1.0", - "jasny/php-code-quality": "^1.1.0" + "phpstan/phpstan": "^0.12.59", + "codeception/codeception": "^4.1", + "codeception/module-phpbrowser": "^1.0", + "codeception/module-rest": "^1.2", + "desarrolla2/cache": "^3.0", + "jasny/http-message": "^1.3", + "jasny/php-code-quality": "^2.6.0", + "jasny/phpunit-extension": "^0.3.2", + "yubb/loggy": "^2.1" }, "autoload": { "psr-4": { "Jasny\\SSO\\": "src/" } - } + }, + "autoload-dev": { + "psr-4": { + "Jasny\\Tests\\SSO\\": "tests/unit/" + } + }, + "scripts": { + "test": [ + "phpstan analyse", + "codecept run", + "phpcs -p src" + ] + }, + "config": { + "preferred-install": "dist", + "sort-packages": true, + "optimize-autoloader": true + }, + "minimum-stability": "dev", + "prefer-stable": true } - diff --git a/demo/ajax-broker/api.php b/demo/ajax-broker/api.php new file mode 100644 index 0000000..8108bee --- /dev/null +++ b/demo/ajax-broker/api.php @@ -0,0 +1,31 @@ +request($_SERVER['REQUEST_METHOD'], $path, $_POST); +} catch (Exception $e) { + $status = $e->getCode() ?: 500; + $result = ['error' => $e->getMessage()]; +} + +// REST +if (!$result) { + http_response_code(204); +} else { + http_response_code(isset($status) ? $status : 200); + header("Content-Type: application/json"); + echo json_encode($result); +} diff --git a/demo/ajax-broker/app.js b/demo/ajax-broker/app.js new file mode 100644 index 0000000..5399a77 --- /dev/null +++ b/demo/ajax-broker/app.js @@ -0,0 +1,109 @@ ++function ($) { + // Init + attach(); + + /** + * Attach session. + * Will redirect to SSO server. + */ + function attach() + { + const req = $.ajax({ + url: 'attach.php', + crossDomain: true, + dataType: 'jsonp' + }); + + req.done(function (data, code) { + if (code && code >= 400) { // jsonp failure + showError(data); + return; + } + + $.ajax({method: 'POST', url: 'verify.php', data: data}).done(function () { + doApiRequest('info', null, showUserInfo); + }); + }); + + req.fail(function (jqxhr) { + showError(jqxhr.responseJSON || jqxhr.textResponse) + }); + } + + /** + * Do an AJAX request to the API + * + * @param command API command + * @param params POST data + * @param callback Callback function + */ + function doApiRequest(command, params, callback) + { + const req = $.ajax({ + url: 'api.php?command=' + command, + method: params ? 'POST' : 'GET', + data: params, + dataType: 'json' + }); + + req.done(callback); + + req.fail(function (jqxhr) { + showError(jqxhr.responseJSON || jqxhr.textResponse); + }); + } + + /** + * Display the error message + * + * @param data + */ + function showError(data) + { + const message = typeof data === 'object' && data.error ? data.error : 'Unexpected error'; + $.growl.error({message: message}); + } + + /** + * Display user info + * + * @param info + */ + function showUserInfo(info) + { + const body = $('body'); + const userInfo = $('#user-info'); + + body.removeClass('anonymous authenticated'); + userInfo.html(''); + + if (info) { + for (const key in info) { + userInfo.append($('
').text(key)); + userInfo.append($('
').text(info[key])); + } + } + + body.addClass(info ? 'authenticated' : 'anonymous'); + } + + /** + * Submit login form through AJAX + */ + $('#login-form').on('submit', function (e) { + e.preventDefault(); + + $('#error').text('').hide(); + + var data = { + username: this.username.value, + password: this.password.value + }; + + doApiRequest('login', data, showUserInfo); + }); + + $('#logout').on('click', function () { + doApiRequest('logout', {}, () => showUserInfo(null)); + }); +}(jQuery); diff --git a/demo/ajax-broker/attach.php b/demo/ajax-broker/attach.php new file mode 100644 index 0000000..daefa43 --- /dev/null +++ b/demo/ajax-broker/attach.php @@ -0,0 +1,25 @@ +isAttached()) { + echo $jsCallback . '(null, 200)'; +} + +// Attach through redirect if the client isn't attached yet. +$url = $broker->getAttachUrl(['callback' => $jsCallback]); +header("Location: $url", true, 303); diff --git a/demo/ajax-broker/index.html b/demo/ajax-broker/index.html new file mode 100644 index 0000000..2a97c2f --- /dev/null +++ b/demo/ajax-broker/index.html @@ -0,0 +1,48 @@ + + + + Single Sign-On Ajax demo + + + + + + + + +
+

Single Sign-On Ajax demo

+ +
+ + + + + + + +
+ +
+

Logged in

+
+ + +
+
+ + + + + + + + diff --git a/demo/ajax-broker/verify.php b/demo/ajax-broker/verify.php new file mode 100644 index 0000000..107906e --- /dev/null +++ b/demo/ajax-broker/verify.php @@ -0,0 +1,20 @@ +verify($_POST['verify']); + +http_response_code(204); diff --git a/demo/broker/error.php b/demo/broker/error.php new file mode 100644 index 0000000..8c09af7 --- /dev/null +++ b/demo/broker/error.php @@ -0,0 +1,49 @@ +getMessage() : ($_GET['sso_error'] ?? "Unknown error"); +$errorDetails = isset($exception) && $exception->getPrevious() !== null + ? $exception->getPrevious()->getMessage() + : null; + +?> + + + + Single Sign-On demo (<?= $brokerId ?>) + + + + + + + + +
+

Single Sign-On demo ()

+ +
+ + + +
+ + +
+ +
+ + Try again +
+ + diff --git a/demo/broker/include/attach.php b/demo/broker/include/attach.php new file mode 100644 index 0000000..0133502 --- /dev/null +++ b/demo/broker/include/attach.php @@ -0,0 +1,41 @@ +verify($_GET['sso_verify']); + + $url = (!empty($_SERVER['HTTPS']) ? 'https://' : 'http://') . $_SERVER['HTTP_HOST'] . $_SERVER['REQUEST_URI']; + $redirectUrl = preg_replace('/sso_verify=\w+&|[?&]sso_verify=\w+$/', '', $url); + redirect($redirectUrl); + exit(); +} + +// Attach through redirect if the client isn't attached yet. +if (!$broker->isAttached()) { + $returnUrl = (!empty($_SERVER['HTTPS']) ? 'https://' : 'http://') . $_SERVER['HTTP_HOST'] . $_SERVER['REQUEST_URI']; + $attachUrl = $broker->getAttachUrl(['return_url' => $returnUrl]); + + redirect($attachUrl); + exit(); +} + +return $broker; diff --git a/demo/broker/include/functions.php b/demo/broker/include/functions.php new file mode 100644 index 0000000..faf6bf5 --- /dev/null +++ b/demo/broker/include/functions.php @@ -0,0 +1,12 @@ +$url"; +} diff --git a/demo/broker/index.php b/demo/broker/index.php new file mode 100644 index 0000000..c835c31 --- /dev/null +++ b/demo/broker/index.php @@ -0,0 +1,45 @@ +request('GET', '/api/info.php'); +} catch (\RuntimeException $exception) { + require __DIR__ . '/error.php'; + exit(); +} + +?> + + + + <?= $broker->getBrokerId() ?> — Single Sign-On demo + + + + + + +
+

Single Sign-On demo (Broker: getBrokerId() ?>)

+ + +

Logged out

+ Login + +

Logged in

+
+ + Logout + +
+ + diff --git a/demo/broker/login.php b/demo/broker/login.php new file mode 100644 index 0000000..f81e12e --- /dev/null +++ b/demo/broker/login.php @@ -0,0 +1,69 @@ + $_POST['username'], + 'password' => $_POST['password'] + ]; + + $broker->request('POST', '/api/login.php', $credentials); + + redirect('index.php'); + exit(); + } catch (\RuntimeException $exception) { + $error = $exception->getMessage(); + } +} + +// Show the form in case of GET request +?> + + + + <?= $broker->getBrokerId() ?> | Login (Single Sign-On demo) + + + + + + + + +
+

Single Sign-On demo (Broker: getBrokerId() ?>)

+ + +
+ + +
+ + + + + + + +
+
+ + diff --git a/demo/broker/logout.php b/demo/broker/logout.php new file mode 100644 index 0000000..9289c86 --- /dev/null +++ b/demo/broker/logout.php @@ -0,0 +1,19 @@ +request('POST', 'api/logout.php'); +} catch (\RuntimeException $exception) { + require __DIR__ . '/error.php'; + exit(); +} + +redirect('index.php'); diff --git a/demo/server/api/info.php b/demo/server/api/info.php new file mode 100644 index 0000000..55429e4 --- /dev/null +++ b/demo/server/api/info.php @@ -0,0 +1,32 @@ + $username] + $config['users'][$username]; +unset($info['password']); + +header('Content-Type: application/json'); +echo json_encode($info); diff --git a/demo/server/api/login.php b/demo/server/api/login.php new file mode 100644 index 0000000..26a8cb9 --- /dev/null +++ b/demo/server/api/login.php @@ -0,0 +1,39 @@ + "Invalid credentials"]); + exit(); +} + +// Store the current user in the session. +$_SESSION['user'] = $username; + +// Output user info as JSON. +$info = ['username' => $username] + $config['users'][$username]; +unset($info['password']); + +header('Content-Type: application/json'); +echo json_encode($info); diff --git a/demo/server/api/logout.php b/demo/server/api/logout.php new file mode 100644 index 0000000..3f9287b --- /dev/null +++ b/demo/server/api/logout.php @@ -0,0 +1,23 @@ +withLogger(new Loggy('SSO')); + +try { + // Attach the broker token to the user session. Uses query parameters from $_GET. + $verificationCode = $ssoServer->attach(); + $error = null; +} catch (SSOException $exception) { + $verificationCode = null; + $error = ['code' => $exception->getCode(), 'message' => $exception->getMessage()]; +} + +// The token is attached; output 'success'. + +// In this demo we support multiple types of attaching the session. If you choose to support only one method, +// you don't need to detect the return type. + +$returnType = + (isset($_GET['return_url']) ? 'redirect' : null) ?? + (isset($_GET['callback']) ? 'jsonp' : null) ?? + (strpos($_SERVER['HTTP_ACCEPT'], 'text/html') !== false ? 'html' : null) ?? + (strpos($_SERVER['HTTP_ACCEPT'], 'application/json') !== false ? 'json' : null); + +switch ($returnType) { + case 'json': + header('Access-Control-Allow-Origin: *'); + header('Content-type: application/json'); + http_response_code($error['code'] ?? 200); + echo json_encode($error ?? ['verify' => $verificationCode]); + break; + + case 'jsonp': + $callback = $_GET['callback']; + if (!preg_match('/^[a-z_]\w*$/i', $callback)) { + http_response_code(400); + header('Content-Type: text/plain'); + echo "JSONP callback must be a valid js function name"; + break; + } + + header('Content-type: application/javascript'); + $data = json_encode($error ?? ['verify' => $verificationCode]); + $responseCode = $error['code'] ?? 200; + echo "{$callback}($data, $responseCode);"; + break; + + case 'redirect': + $query = isset($error) ? 'sso_error=' . $error['message'] : 'sso_verify=' . $verificationCode; + $url = $_GET['return_url'] . (strpos($_GET['return_url'], '?') === false ? '?' : '&') . $query; + header('Location: ' . $url, true, 303); + echo "You're being redirected to $url"; + break; + + default: + http_response_code(400); + header('Content-Type: text/plain'); + echo "Missing 'return_url' query parameter"; + break; +} diff --git a/demo/server/include/config.php b/demo/server/include/config.php new file mode 100644 index 0000000..d4e08ad --- /dev/null +++ b/demo/server/include/config.php @@ -0,0 +1,37 @@ + [ + 'Alice' => [ + 'secret' => '8iwzik1bwd', + 'domains' => ['localhost'], + ], + 'Greg' => [ + 'secret' => '7pypoox2pc', + 'domains' => ['localhost'], + ], + 'Julius' => [ + 'secret' => 'ceda63kmhp', + 'domains' => ['localhost'], + ], + ], + 'users' => [ + 'jackie' => [ + 'fullname' => 'Jackie Black', + 'email' => 'jackie.black@example.com', + 'password' => '$2y$10$lVUeiphXLAm4pz6l7lF9i.6IelAqRxV4gCBu8GBGhCpaRb6o0qzUO' // jackie123 + ], + 'john' => [ + 'fullname' => 'John Doe', + 'email' => 'john.doe@example.com', + 'password' => '$2y$10$RU85KDMhbh8pDhpvzL6C5.kD3qWpzXARZBzJ5oJ2mFoW7Ren.apC2' // john123 + ], + ], +]; diff --git a/demo/server/include/start_broker_session.php b/demo/server/include/start_broker_session.php new file mode 100644 index 0000000..960cd18 --- /dev/null +++ b/demo/server/include/start_broker_session.php @@ -0,0 +1,44 @@ +withLogger(new Loggy('SSO')); + +// Start the session using the broker bearer token (rather than a session cookie). +try { + $ssoServer->startBrokerSession(); +} catch (SsoException $exception) { + $code = $exception->getCode(); + $message = $code === 403 + ? "Invalid or expired bearer token" + : $exception->getMessage(); + + http_response_code($code); + if ($code === 401) { + header('WWW-Authenticate: Bearer'); + } + + header('Content-Type: application/json'); + echo json_encode(['error' => $message]); + + exit(); +} + +return $ssoServer; diff --git a/examples/ajax-broker/api.php b/examples/ajax-broker/api.php deleted file mode 100644 index 304febd..0000000 --- a/examples/ajax-broker/api.php +++ /dev/null @@ -1,38 +0,0 @@ - 'Command not specified']); - return; -} - -try { - $result = $broker->{$_REQUEST['command']}(); -} catch (Exception $e) { - $status = $e->getCode() ?: 500; - $result = ['error' => $e->getMessage()]; -} - -// JSONP -if (!empty($_GET['callback'])) { - if (!isset($result)) $result = null; - if (!isset($status)) $status = isset($result) ? 200 : 204; - - header('Content-Type: application/javascript'); - echo $_GET['callback'] . '(' . json_encode($result) . ', ' . $status . ')'; - return; -} - -// REST -if (!$result) { - http_response_code(204); -} else { - http_response_code(isset($status) ? $status : 200); - header("Content-Type: application/json"); - echo json_encode($result); -} diff --git a/examples/ajax-broker/app.js b/examples/ajax-broker/app.js deleted file mode 100644 index 7a627c9..0000000 --- a/examples/ajax-broker/app.js +++ /dev/null @@ -1,107 +0,0 @@ -+function($) { - // Init - attach(); - - /** - * Attach session. - * Will redirect to SSO server. - */ - function attach() { - var req = $.ajax({ - url: 'api.php?command=attach', - crossDomain: true, - dataType: 'jsonp' - }); - - req.done(function(data, code) { - if (code && code >= 400) { // jsonp failure - showError(data.error); - return; - } - - loadUserInfo(); - }); - - req.fail(function(jqxhr) { - showError(jqxhr.responseJSON || jqxhr.textResponse) - }); - } - - /** - * Do an AJAX request to the API - * - * @param command API command - * @param params POST data - * @param callback Callback function - */ - function doApiRequest(command, params, callback) { - var req = $.ajax({ - url: 'api.php?command=' + command, - method: params ? 'POST' : 'GET', - data: params, - dataType: 'json' - }); - - req.done(callback); - - req.fail(function(jqxhr) { - showError(jqxhr.responseJSON || jqxhr.textResponse); - }); - } - - /** - * Display the error message - * - * @param data - */ - function showError(data) { - var message = typeof data === 'object' && data.error ? data.error : 'Unexpected error'; - $('#error').text(message).show(); - } - - /** - * Load and display user info - */ - function loadUserInfo() { - doApiRequest('getUserinfo', null, showUserInfo); - } - - /** - * Display user info - * - * @param info - */ - function showUserInfo(info) { - $('body').removeClass('anonymous authenticated'); - $('#user-info').html(''); - - if (info) { - for (var key in info) { - $('#user-info').append($('
').text(key)); - $('#user-info').append($('
').text(info[key])); - } - } - - $('body').addClass(info ? 'authenticated' : 'anonymous'); - } - - /** - * Submit login form through AJAX - */ - $('#login-form').on('submit', function(e) { - e.preventDefault(); - - $('#error').text('').hide(); - - var data = { - username: this.username.value, - password: this.password.value - }; - - doApiRequest('login', data, showUserInfo); - }); - - $('#logout').on('click', function() { - doApiRequest('logout', {}, function() { showUserInfo(null); }); - }) -}(jQuery); diff --git a/examples/ajax-broker/index.html b/examples/ajax-broker/index.html deleted file mode 100644 index 8b8a98b..0000000 --- a/examples/ajax-broker/index.html +++ /dev/null @@ -1,56 +0,0 @@ - - - - Single Sign-On Ajax demo - - - - - -
-

Single Sign-On Ajax demo

- - - -
-
- -
- -
-
-
- -
- -
-
- -
-
- -
-
-
- -
-

Logged in

-
- - Logout -
-
- - - - - - diff --git a/examples/broker/error.php b/examples/broker/error.php deleted file mode 100644 index 52c7a53..0000000 --- a/examples/broker/error.php +++ /dev/null @@ -1,25 +0,0 @@ - - - - - Single Sign-On demo (<?= $broker->broker ?>) - - - -
-

Single Sign-On demo (broker ?>)

- -
- -
- - Try again -
- - diff --git a/examples/broker/index.php b/examples/broker/index.php deleted file mode 100644 index a3a2bf8..0000000 --- a/examples/broker/index.php +++ /dev/null @@ -1,46 +0,0 @@ -attach(true); - -try { - $user = $broker->getUserInfo(); -} catch (NotAttachedException $e) { - header('Location: ' . $_SERVER['REQUEST_URI']); - exit; -} catch (SsoException $e) { - header("Location: error.php?sso_error=" . $e->getMessage(), true, 307); -} - -if (!$user) { - header("Location: login.php", true, 307); - exit; -} -?> - - - - <?= $broker->broker ?> (Single Sign-On demo) - - - -
-

broker ?> (Single Sign-On demo)

-

Logged in

- -
- - Logout -
- - - diff --git a/examples/broker/login.php b/examples/broker/login.php deleted file mode 100644 index e0e60f5..0000000 --- a/examples/broker/login.php +++ /dev/null @@ -1,65 +0,0 @@ -attach(true); - -try { - if (!empty($_GET['logout'])) { - $broker->logout(); - } elseif ($broker->getUserInfo() || ($_SERVER['REQUEST_METHOD'] == 'POST' && $broker->login($_POST['username'], $_POST['password']))) { - header("Location: index.php", true, 303); - exit; - } - - if ($_SERVER['REQUEST_METHOD'] == 'POST') $errmsg = "Login failed"; -} catch (NotAttachedException $e) { - header('Location: ' . $_SERVER['REQUEST_URI']); - exit; -} catch (Jasny\SSO\Exception $e) { - $errmsg = $e->getMessage(); -} - -?> - - - - <?= $broker->broker ?> | Login (Single Sign-On demo) - - - - - -
-

broker ?> (Single Sign-On demo)

- -
- -
-
- -
- -
-
-
- -
- -
-
- -
-
- -
-
-
-
- - diff --git a/examples/server/.htaccess b/examples/server/.htaccess deleted file mode 100644 index 7bb4b5b..0000000 --- a/examples/server/.htaccess +++ /dev/null @@ -1,6 +0,0 @@ -RewriteEngine On - -RewriteCond %{REQUEST_FILENAME} !-d -RewriteCond %{REQUEST_FILENAME} !-f -RewriteRule (.+) index.php?command=$1 [L] - diff --git a/examples/server/MySSOServer.php b/examples/server/MySSOServer.php deleted file mode 100644 index ca523b0..0000000 --- a/examples/server/MySSOServer.php +++ /dev/null @@ -1,90 +0,0 @@ - ['secret'=>'8iwzik1bwd'], - 'Greg' => ['secret'=>'7pypoox2pc'], - 'Julias' => ['secret'=>'ceda63kmhp'] - ]; - - /** - * System users - * @var array - */ - private static $users = array ( - 'jackie' => [ - 'fullname' => 'Jackie Black', - 'email' => 'jackie.black@example.com', - 'password' => '$2y$10$lVUeiphXLAm4pz6l7lF9i.6IelAqRxV4gCBu8GBGhCpaRb6o0qzUO' // jackie123 - ], - 'john' => [ - 'fullname' => 'John Doe', - 'email' => 'john.doe@example.com', - 'password' => '$2y$10$RU85KDMhbh8pDhpvzL6C5.kD3qWpzXARZBzJ5oJ2mFoW7Ren.apC2' // john123 - ], - ); - - /** - * Get the API secret of a broker and other info - * - * @param string $brokerId - * @return array - */ - protected function getBrokerInfo($brokerId) - { - return isset(self::$brokers[$brokerId]) ? self::$brokers[$brokerId] : null; - } - - /** - * Authenticate using user credentials - * - * @param string $username - * @param string $password - * @return ValidationResult - */ - protected function authenticate($username, $password) - { - if (!isset($username)) { - return ValidationResult::error("username isn't set"); - } - - if (!isset($password)) { - return ValidationResult::error("password isn't set"); - } - - if (!isset(self::$users[$username]) || !password_verify($password, self::$users[$username]['password'])) { - return ValidationResult::error("Invalid credentials"); - } - - return ValidationResult::success(); - } - - - /** - * Get the user information - * - * @return array - */ - protected function getUserInfo($username) - { - if (!isset(self::$users[$username])) return null; - - $user = compact('username') + self::$users[$username]; - unset($user['password']); - - return $user; - } -} diff --git a/examples/server/index.php b/examples/server/index.php deleted file mode 100644 index 5416eb9..0000000 --- a/examples/server/index.php +++ /dev/null @@ -1,18 +0,0 @@ - 'Unknown command']); - exit(); -} - -$result = $ssoServer->$command(); - diff --git a/phpcs.xml b/phpcs.xml new file mode 100644 index 0000000..5897613 --- /dev/null +++ b/phpcs.xml @@ -0,0 +1,12 @@ + + + The Jasny coding standard. + + + + + + + + + diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..12d99a6 --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,7 @@ +parameters: + level: 7 + paths: + - src + reportUnmatchedIgnoredErrors: false +includes: + - vendor/phpstan/phpstan-strict-rules/rules.neon diff --git a/src/Broker.php b/src/Broker.php deleted file mode 100644 index 98b2ae9..0000000 --- a/src/Broker.php +++ /dev/null @@ -1,293 +0,0 @@ -url = $url; - $this->broker = $broker; - $this->secret = $secret; - $this->cookie_lifetime = $cookie_lifetime; - - if (isset($_COOKIE[$this->getCookieName()])) $this->token = $_COOKIE[$this->getCookieName()]; - } - - /** - * Get the cookie name. - * - * Note: Using the broker name in the cookie name. - * This resolves issues when multiple brokers are on the same domain. - * - * @return string - */ - protected function getCookieName() - { - return 'sso_token_' . preg_replace('/[_\W]+/', '_', strtolower($this->broker)); - } - - /** - * Generate session id from session key - * - * @return string - */ - protected function getSessionId() - { - if (!isset($this->token)) return null; - - $checksum = hash('sha256', 'session' . $this->token . $this->secret); - return "SSO-{$this->broker}-{$this->token}-$checksum"; - } - - /** - * Generate session token - */ - public function generateToken() - { - if (isset($this->token)) return; - - $this->token = base_convert(md5(uniqid(rand(), true)), 16, 36); - setcookie($this->getCookieName(), $this->token, time() + $this->cookie_lifetime, '/'); - } - - /** - * Clears session token - */ - public function clearToken() - { - setcookie($this->getCookieName(), null, 1, '/'); - $this->token = null; - } - - /** - * Check if we have an SSO token. - * - * @return boolean - */ - public function isAttached() - { - return isset($this->token); - } - - /** - * Get URL to attach session at SSO server. - * - * @param array $params - * @return string - */ - public function getAttachUrl($params = []) - { - $this->generateToken(); - - $data = [ - 'command' => 'attach', - 'broker' => $this->broker, - 'token' => $this->token, - 'checksum' => hash('sha256', 'attach' . $this->token . $this->secret) - ] + $_GET; - - return $this->url . "?" . http_build_query($data + $params); - } - - /** - * Attach our session to the user's session on the SSO server. - * - * @param string|true $returnUrl The URL the client should be returned to after attaching - */ - public function attach($returnUrl = null) - { - if ($this->isAttached()) return; - - if ($returnUrl === true) { - $protocol = !empty($_SERVER['HTTPS']) ? 'https://' : 'http://'; - $returnUrl = $protocol . $_SERVER['HTTP_HOST'] . $_SERVER['REQUEST_URI']; - } - - $params = ['return_url' => $returnUrl]; - $url = $this->getAttachUrl($params); - - header("Location: $url", true, 307); - echo "You're redirected to $url"; - exit(); - } - - /** - * Get the request url for a command - * - * @param string $command - * @param array $params Query parameters - * @return string - */ - protected function getRequestUrl($command, $params = []) - { - $params['command'] = $command; - return $this->url . '?' . http_build_query($params); - } - - /** - * Execute on SSO server. - * - * @param string $method HTTP method: 'GET', 'POST', 'DELETE' - * @param string $command Command - * @param array|string $data Query or post parameters - * @return array|object - */ - protected function request($method, $command, $data = null) - { - if (!$this->isAttached()) { - throw new NotAttachedException('No token'); - } - $url = $this->getRequestUrl($command, !$data || $method === 'POST' ? [] : $data); - - $ch = curl_init($url); - curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); - curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method); - curl_setopt($ch, CURLOPT_HTTPHEADER, ['Accept: application/json', 'Authorization: Bearer '. $this->getSessionID()]); - - if ($method === 'POST' && !empty($data)) { - $post = is_string($data) ? $data : http_build_query($data); - curl_setopt($ch, CURLOPT_POSTFIELDS, $post); - } - - $response = curl_exec($ch); - if (curl_errno($ch) != 0) { - $message = 'Server request failed: ' . curl_error($ch); - throw new Exception($message); - } - - $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); - list($contentType) = explode(';', curl_getinfo($ch, CURLINFO_CONTENT_TYPE)); - - if ($contentType != 'application/json') { - $message = 'Expected application/json response, got ' . $contentType; - throw new Exception($message); - } - - $data = json_decode($response, true); - if ($httpCode == 403) { - $this->clearToken(); - throw new NotAttachedException($data['error'] ?: $response, $httpCode); - } - if ($httpCode >= 400) throw new Exception($data['error'] ?: $response, $httpCode); - - return $data; - } - - - /** - * Log the client in at the SSO server. - * - * Only brokers marked trused can collect and send the user's credentials. Other brokers should omit $username and - * $password. - * - * @param string $username - * @param string $password - * @return array user info - * @throws Exception if login fails eg due to incorrect credentials - */ - public function login($username = null, $password = null) - { - if (!isset($username) && isset($_POST['username'])) $username = $_POST['username']; - if (!isset($password) && isset($_POST['password'])) $password = $_POST['password']; - - $result = $this->request('POST', 'login', compact('username', 'password')); - $this->userinfo = $result; - - return $this->userinfo; - } - - /** - * Logout at sso server. - */ - public function logout() - { - $this->request('POST', 'logout', 'logout'); - } - - /** - * Get user information. - * - * @return object|null - */ - public function getUserInfo() - { - if (!isset($this->userinfo)) { - $this->userinfo = $this->request('GET', 'userInfo'); - } - - return $this->userinfo; - } - - /** - * Magic method to do arbitrary request - * - * @param string $fn - * @param array $args - * @return mixed - */ - public function __call($fn, $args) - { - $sentence = strtolower(preg_replace('/([a-z0-9])([A-Z])/', '$1 $2', $fn)); - $parts = explode(' ', $sentence); - - $method = count($parts) > 1 && in_array(strtoupper($parts[0]), ['GET', 'DELETE']) - ? strtoupper(array_shift($parts)) - : 'POST'; - $command = join('-', $parts); - - return $this->request($method, $command, $args); - } -} diff --git a/src/Broker/Broker.php b/src/Broker/Broker.php new file mode 100644 index 0000000..90b5e9d --- /dev/null +++ b/src/Broker/Broker.php @@ -0,0 +1,350 @@ + + */ + protected $state; + + /** + * @var Curl + */ + protected $curl; + + /** + * Class constructor + * + * @param string $url Url of SSO server + * @param string $broker My identifier, given by SSO provider. + * @param string $secret My secret word, given by SSO provider. + */ + public function __construct(string $url, string $broker, string $secret) + { + if (!(bool)preg_match('~^https?://~', $url)) { + throw new \InvalidArgumentException("Invalid SSO server URL '$url'"); + } + + if ((bool)preg_match('/\W/', $broker)) { + throw new \InvalidArgumentException("Invalid broker id '$broker': must be alphanumeric"); + } + + $this->url = $url; + $this->broker = $broker; + $this->secret = $secret; + + $this->state = new Cookies(); + } + + /** + * Get a copy with a different handler for the user state (like cookie or session). + * + * @param \ArrayAccess $handler + * @return static + */ + public function withTokenIn(\ArrayAccess $handler): self + { + return $this->withProperty('state', $handler); + } + + /** + * Set a custom wrapper for cURL. + * + * @param Curl $curl + * @return static + */ + public function withCurl(Curl $curl): self + { + return $this->withProperty('curl', $curl); + } + + /** + * Get Wrapped cURL. + */ + protected function getCurl(): Curl + { + if (!isset($this->curl)) { + $this->curl = new Curl(); // @codeCoverageIgnore + } + + return $this->curl; + } + + /** + * Get the broker identifier. + */ + public function getBrokerId(): string + { + return $this->broker; + } + + /** + * Get information from cookie. + */ + protected function initialize(): void + { + if ($this->initialized) { + return; + } + + $this->token = $this->state[$this->getCookieName('token')] ?? null; + $this->verificationCode = $this->state[$this->getCookieName('verify')] ?? null; + $this->initialized = true; + } + + /** + * @return string|null + */ + protected function getToken(): ?string + { + $this->initialize(); + + return $this->token; + } + + /** + * @return string|null + */ + protected function getVerificationCode(): ?string + { + $this->initialize(); + + return $this->verificationCode; + } + + /** + * Get the cookie name. + * The broker name is part of the cookie name. This resolves issues when multiple brokers are on the same domain. + */ + protected function getCookieName(string $type): string + { + $brokerName = preg_replace('/[_\W]+/', '_', strtolower($this->broker)); + + return "sso_{$type}_{$brokerName}"; + } + + /** + * Generate session id from session key + * + * @throws NotAttachedException + */ + public function getBearerToken(): string + { + $token = $this->getToken(); + $verificationCode = $this->getVerificationCode(); + + if ($verificationCode === null) { + throw new NotAttachedException("The client isn't attached to the SSO server for this broker. " + . "Make sure that the '" . $this->getCookieName('verify') . "' cookie is set."); + } + + return "SSO-{$this->broker}-{$token}-" . $this->generateChecksum("bearer:$verificationCode"); + } + + /** + * Generate session token. + */ + protected function generateToken(): void + { + $this->token = base_convert(bin2hex(random_bytes(32)), 16, 36); + $this->state[$this->getCookieName('token')] = $this->token; + } + + /** + * Clears session token. + */ + public function clearToken(): void + { + unset($this->state[$this->getCookieName('token')]); + unset($this->state[$this->getCookieName('verify')]); + + $this->token = null; + $this->verificationCode = null; + } + + /** + * Check if we have an SSO token. + */ + public function isAttached(): bool + { + return $this->getVerificationCode() !== null; + } + + /** + * Get URL to attach session at SSO server. + * + * @param array $params + * @return string + */ + public function getAttachUrl(array $params = []): string + { + if ($this->getToken() === null) { + $this->generateToken(); + } + + $data = [ + 'broker' => $this->broker, + 'token' => $this->getToken(), + 'checksum' => $this->generateChecksum('attach') + ]; + + return $this->url . "?" . http_build_query($data + $params); + } + + /** + * Verify attaching to the SSO server by providing the verification code. + */ + public function verify(string $code): void + { + $this->initialize(); + + if ($this->verificationCode === $code) { + return; + } + + if ($this->verificationCode !== null) { + trigger_error("SSO attach already verified", E_USER_WARNING); + return; + } + + $this->verificationCode = $code; + $this->state[$this->getCookieName('verify')] = $code; + } + + /** + * Generate checksum for a broker. + */ + protected function generateChecksum(string $command): string + { + return base_convert(hash_hmac('sha256', $command . ':' . $this->token, $this->secret), 16, 36); + } + + /** + * Get the request url for a command + * + * @param string $path + * @param array|string $params Query parameters + * @return string + */ + protected function getRequestUrl(string $path, $params = ''): string + { + $query = is_array($params) ? http_build_query($params) : $params; + + $base = $path[0] === '/' + ? preg_replace('~^(\w+://[^/]+).*~', '$1', $this->url) + : preg_replace('~/[^/]*$~', '', $this->url); + + return $base . '/' . ltrim($path, '/') . ($query !== '' ? '?' . $query : ''); + } + + + /** + * Send an HTTP request to the SSO server. + * + * @param string $method HTTP method: 'GET', 'POST', 'DELETE' + * @param string $path Relative path + * @param array|string $data Query or post parameters + * @return mixed + * @throws RequestException + */ + public function request(string $method, string $path, $data = '') + { + $url = $this->getRequestUrl($path, $method === 'POST' ? '' : $data); + $headers = [ + 'Accept: application/json', + 'Authorization: Bearer ' . $this->getBearerToken() + ]; + + ['httpCode' => $httpCode, 'contentType' => $contentType, 'body' => $body] = + $this->getCurl()->request($method, $url, $headers, $method === 'POST' ? $data : ''); + + return $this->handleResponse($httpCode, $contentType, $body); + } + + /** + * Handle the response of the cURL request. + * + * @param int $httpCode HTTP status code + * @param string|null $ctHeader Content-Type header + * @param string $body Response body + * @return mixed + * @throws RequestException + */ + protected function handleResponse(int $httpCode, $ctHeader, string $body) + { + if ($httpCode === 204) { + return null; + } + + [$contentType] = explode(';', $ctHeader, 2); + + if ($contentType != 'application/json') { + throw new RequestException( + "Expected 'application/json' response, got '$contentType'", + 500, + new RequestException($body, $httpCode) + ); + } + + try { + $data = json_decode($body, true, 512, JSON_THROW_ON_ERROR); + } catch (\JsonException $exception) { + throw new RequestException("Invalid JSON response from server", 500, $exception); + } + + if ($httpCode >= 400) { + throw new RequestException($data['error'] ?? $body, $httpCode); + } + + return $data; + } +} diff --git a/src/Broker/Cookies.php b/src/Broker/Cookies.php new file mode 100644 index 0000000..62c4721 --- /dev/null +++ b/src/Broker/Cookies.php @@ -0,0 +1,73 @@ + + * @codeCoverageIgnore + */ +class Cookies implements \ArrayAccess +{ + /** @var int */ + protected int $ttl; + + /** @var string */ + protected string $path; + + /** @var string */ + protected string $domain; + + /** @var bool */ + protected bool $secure; + + public function __construct(int $ttl = 3600, string $path = '', string $domain = '', bool $secure = false) + { + $this->ttl = $ttl; + $this->path = $path; + $this->domain = $domain; + $this->secure = $secure; + } + + /** + * @inheritDoc + */ + public function offsetExists(mixed $offset): bool + { + return isset($_COOKIE[$offset]); + } + + /** + * @inheritDoc + */ + public function offsetGet(mixed $offset): mixed + { + return $_COOKIE[$offset] ?? null; + } + + /** + * @inheritDoc + */ + public function offsetSet(mixed $offset, mixed $value): void + { + $success = setcookie($offset, $value, time() + $this->ttl, $this->path, $this->domain, $this->secure, true); + + if (!$success) { + throw new \RuntimeException("Failed to set cookie '$offset'"); + } + + $_COOKIE[$offset] = $value; + } + + /** + * @inheritDoc + */ + public function offsetUnset(mixed $offset): void + { + setcookie($offset, '', 1, $this->path, $this->domain, $this->secure, true); + unset($_COOKIE[$offset]); + } +} diff --git a/src/Broker/Curl.php b/src/Broker/Curl.php new file mode 100644 index 0000000..7a525ee --- /dev/null +++ b/src/Broker/Curl.php @@ -0,0 +1,64 @@ +|string $data Query or post parameters + * @return array{httpCode:int,contentType:string,body:string} + * @throws RequestException + */ + public function request(string $method, string $url, array $headers, $data = '') + { + $ch = curl_init($url); + + if ($ch === false) { + throw new \RuntimeException("Failed to initialize a cURL session"); + } + + if ($data !== [] && $data !== '') { + $post = is_string($data) ? $data : http_build_query($data); + curl_setopt($ch, CURLOPT_POSTFIELDS, $post); + } + + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method); + curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); + + $responseBody = (string)curl_exec($ch); + + if (curl_errno($ch) != 0) { + throw new RequestException('Server request failed: ' . curl_error($ch)); + } + + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + $contentType = curl_getinfo($ch, CURLINFO_CONTENT_TYPE) ?? 'text/html'; + + return ['httpCode' => $httpCode, 'contentType' => $contentType, 'body' => $responseBody]; + } +} diff --git a/src/Broker/NotAttachedException.php b/src/Broker/NotAttachedException.php new file mode 100644 index 0000000..ffcb9d1 --- /dev/null +++ b/src/Broker/NotAttachedException.php @@ -0,0 +1,12 @@ + + * @codeCoverageIgnore + */ +class Session implements \ArrayAccess +{ + /** + * @inheritDoc + */ + public function offsetSet($name, $value): void + { + $_SESSION[$name] = $value; + } + + /** + * @inheritDoc + */ + public function offsetUnset($name): void + { + unset($_SESSION[$name]); + } + + /** + * @inheritDoc + */ + public function offsetGet($name) + { + return $_SESSION[$name] ?? null; + } + + /** + * @inheritDoc + */ + public function offsetExists($name): bool + { + return isset($_SESSION[$name]); + } +} diff --git a/src/Exception.php b/src/Exception.php deleted file mode 100644 index 3188b89..0000000 --- a/src/Exception.php +++ /dev/null @@ -1,9 +0,0 @@ - '/tmp', 'files_cache_ttl' => 36000]; - - /** - * Cache that stores the special session data for the brokers. - * - * @var Cache - */ - protected $cache; - - /** - * @var string - */ - protected $returnType; - - /** - * @var mixed - */ - protected $brokerId; - - - /** - * Class constructor - * - * @param array $options - */ - public function __construct(array $options = []) - { - $this->options = $options + $this->options; - $this->cache = $this->createCacheAdapter(); - } - - /** - * Create a cache to store the broker session id. - * - * @return Cache - */ - protected function createCacheAdapter() - { - $adapter = new Adapter\File($this->options['files_cache_directory']); - $adapter->setOption('ttl', $this->options['files_cache_ttl']); - - return new Cache($adapter); - } - - /** - * Start the session for broker requests to the SSO server - */ - public function startBrokerSession() - { - if (isset($this->brokerId)) return; - - $sid = $this->getBrokerSessionID(); - - if ($sid === false) { - return $this->fail("Broker didn't send a session key", 400); - } - - $linkedId = $this->cache->get($sid); - - if (!$linkedId) { - return $this->fail("The broker session id isn't attached to a user session", 403); - } - - if (session_status() === PHP_SESSION_ACTIVE) { - if ($linkedId !== session_id()) throw new \Exception("Session has already started", 400); - return; - } - - session_id($linkedId); - session_start(); - - $this->brokerId = $this->validateBrokerSessionId($sid); - } - - /** - * Get session ID from header Authorization or from $_GET/$_POST - */ - protected function getBrokerSessionID() - { - $headers = getallheaders(); - - if (isset($headers['Authorization']) && strpos($headers['Authorization'], 'Bearer') === 0) { - $headers['Authorization'] = substr($headers['Authorization'], 7); - return $headers['Authorization']; - } - if (isset($_GET['access_token'])) { - return $_GET['access_token']; - } - if (isset($_POST['access_token'])) { - return $_POST['access_token']; - } - if (isset($_GET['sso_session'])) { - return $_GET['sso_session']; - } - - return false; - } - - /** - * Validate the broker session id - * - * @param string $sid session id - * @return string the broker id - */ - protected function validateBrokerSessionId($sid) - { - $matches = null; - - if (!preg_match('/^SSO-(\w*+)-(\w*+)-([a-z0-9]*+)$/', $this->getBrokerSessionID(), $matches)) { - return $this->fail("Invalid session id"); - } - - $brokerId = $matches[1]; - $token = $matches[2]; - - if ($this->generateSessionId($brokerId, $token) != $sid) { - return $this->fail("Checksum failed: Client IP address may have changed", 403); - } - - return $brokerId; - } - - /** - * Start the session when a user visits the SSO server - */ - protected function startUserSession() - { - if (session_status() !== PHP_SESSION_ACTIVE) session_start(); - } - - /** - * Generate session id from session token - * - * @param string $brokerId - * @param string $token - * @return string - */ - protected function generateSessionId($brokerId, $token) - { - $broker = $this->getBrokerInfo($brokerId); - - if (!isset($broker)) return null; - - return "SSO-{$brokerId}-{$token}-" . hash('sha256', 'session' . $token . $broker['secret']); - } - - /** - * Generate session id from session token - * - * @param string $brokerId - * @param string $token - * @return string - */ - protected function generateAttachChecksum($brokerId, $token) - { - $broker = $this->getBrokerInfo($brokerId); - - if (!isset($broker)) return null; - - return hash('sha256', 'attach' . $token . $broker['secret']); - } - - - /** - * Detect the type for the HTTP response. - * Should only be done for an `attach` request. - */ - protected function detectReturnType() - { - if (!empty($_GET['return_url'])) { - $this->returnType = 'redirect'; - } elseif (!empty($_GET['callback'])) { - $this->returnType = 'jsonp'; - } elseif (strpos($_SERVER['HTTP_ACCEPT'], 'image/') !== false) { - $this->returnType = 'image'; - } elseif (strpos($_SERVER['HTTP_ACCEPT'], 'application/json') !== false) { - $this->returnType = 'json'; - } - } - - /** - * Attach a user session to a broker session - */ - public function attach() - { - $this->detectReturnType(); - - if (empty($_REQUEST['broker'])) return $this->fail("No broker specified", 400); - if (empty($_REQUEST['token'])) return $this->fail("No token specified", 400); - - if (!$this->returnType) return $this->fail("No return url specified", 400); - - $checksum = $this->generateAttachChecksum($_REQUEST['broker'], $_REQUEST['token']); - - if (empty($_REQUEST['checksum']) || $checksum != $_REQUEST['checksum']) { - return $this->fail("Invalid checksum", 400); - } - - $this->startUserSession(); - $sid = $this->generateSessionId($_REQUEST['broker'], $_REQUEST['token']); - - $this->cache->set($sid, $this->getSessionData('id')); - $this->outputAttachSuccess(); - } - - /** - * Output on a successful attach - */ - protected function outputAttachSuccess() - { - if ($this->returnType === 'image') { - $this->outputImage(); - } - - if ($this->returnType === 'json') { - header('Content-type: application/json; charset=UTF-8'); - echo json_encode(['success' => 'attached']); - } - - if ($this->returnType === 'jsonp') { - $data = json_encode(['success' => 'attached']); - echo $_REQUEST['callback'] . "($data, 200);"; - } - - if ($this->returnType === 'redirect') { - $url = $_REQUEST['return_url']; - header("Location: $url", true, 307); - echo "You're being redirected to $url"; - } - } - - /** - * Output a 1x1px transparent image - */ - protected function outputImage() - { - header('Content-Type: image/png'); - echo base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABAQ' - . 'MAAAAl21bKAAAAA1BMVEUAAACnej3aAAAAAXRSTlMAQObYZg' - . 'AAAApJREFUCNdjYAAAAAIAAeIhvDMAAAAASUVORK5CYII='); - } - - - /** - * Authenticate - */ - public function login() - { - $this->startBrokerSession(); - - if (empty($_POST['username'])) $this->fail("No username specified", 400); - if (empty($_POST['password'])) $this->fail("No password specified", 400); - - $validation = $this->authenticate($_POST['username'], $_POST['password']); - - if ($validation->failed()) { - return $this->fail($validation->getError(), 400); - } - - $this->setSessionData('sso_user', $_POST['username']); - $this->userInfo(); - } - - /** - * Log out - */ - public function logout() - { - $this->startBrokerSession(); - $this->setSessionData('sso_user', null); - - header('Content-type: application/json; charset=UTF-8'); - http_response_code(204); - } - - /** - * Ouput user information as json. - */ - public function userInfo() - { - $this->startBrokerSession(); - $user = null; - - $username = $this->getSessionData('sso_user'); - - if ($username) { - $user = $this->getUserInfo($username); - if (!$user) return $this->fail("User not found", 500); // Shouldn't happen - } - - header('Content-type: application/json; charset=UTF-8'); - echo json_encode($user); - } - - - /** - * Set session data - * - * @param string $key - * @param string $value - */ - protected function setSessionData($key, $value) - { - if (!isset($value)) { - unset($_SESSION[$key]); - return; - } - - $_SESSION[$key] = $value; - } - - /** - * Get session data - * - * @param type $key - */ - protected function getSessionData($key) - { - if ($key === 'id') return session_id(); - - return isset($_SESSION[$key]) ? $_SESSION[$key] : null; - } - - - /** - * An error occured. - * - * @param string $message - * @param int $http_status - */ - protected function fail($message, $http_status = 500) - { - if (!empty($this->options['fail_exception'])) { - throw new Exception($message, $http_status); - } - - if ($http_status === 500) trigger_error($message, E_USER_WARNING); - - if ($this->returnType === 'jsonp') { - echo $_REQUEST['callback'] . "(" . json_encode(['error' => $message]) . ", $http_status);"; - exit(); - } - - if ($this->returnType === 'redirect') { - $url = $_REQUEST['return_url'] . '?sso_error=' . $message; - header("Location: $url", true, 307); - echo "You're being redirected to $url"; - exit(); - } - - http_response_code($http_status); - header('Content-type: application/json; charset=UTF-8'); - - echo json_encode(['error' => $message]); - exit(); - } - - - /** - * Authenticate using user credentials - * - * @param string $username - * @param string $password - * @return \Jasny\ValidationResult - */ - abstract protected function authenticate($username, $password); - - /** - * Get the secret key and other info of a broker - * - * @param string $brokerId - * @return array - */ - abstract protected function getBrokerInfo($brokerId); - - /** - * Get the information about a user - * - * @param string $username - * @return array|object - */ - abstract protected function getUserInfo($username); -} - diff --git a/src/Server/BrokerException.php b/src/Server/BrokerException.php new file mode 100644 index 0000000..a41f143 --- /dev/null +++ b/src/Server/BrokerException.php @@ -0,0 +1,13 @@ + + */ + protected $options; + + /** + * Class constructor. + * + * @param array $options Options passed to session_start(). + */ + public function __construct(array $options = []) + { + $this->options = $options + ['cookie_samesite' => 'None', 'cookie_secure' => true]; + } + + /** + * @inheritDoc + */ + public function getId(): string + { + return session_id(); + } + + /** + * @inheritDoc + */ + public function start(): void + { + $started = session_status() !== PHP_SESSION_ACTIVE + ? session_start($this->options) + : true; + + if (!$started) { + $err = error_get_last() ?? ['message' => 'Failed to start session']; + throw new ServerException($err['message'], 500); + } + + // Session shouldn't be empty when resumed. + $_SESSION['_sso_init'] = 1; + } + + /** + * @inheritDoc + */ + public function resume(string $id): void + { + session_id($id); + $started = session_start($this->options); + + if (!$started) { + $err = error_get_last() ?? ['message' => 'Failed to start session']; + throw new ServerException($err['message'], 500); + } + + if ($_SESSION === []) { + session_abort(); + throw new BrokerException("Session has expired. Client must attach with new token.", 401); + } + } + + /** + * @inheritDoc + */ + public function isActive(): bool + { + return session_status() === PHP_SESSION_ACTIVE; + } +} diff --git a/src/Server/Server.php b/src/Server/Server.php new file mode 100644 index 0000000..c993693 --- /dev/null +++ b/src/Server/Server.php @@ -0,0 +1,361 @@ +getBrokerInfo = \Closure::fromCallable($getBrokerInfo); + $this->cache = $cache; + + $this->logger = new NullLogger(); + $this->session = new GlobalSession(); + } + + /** + * Get a copy of the service with logging. + * + * @return static + */ + public function withLogger(LoggerInterface $logger): self + { + return $this->withProperty('logger', $logger); + } + + /** + * Get a copy of the service with a custom session service. + * + * @return static + */ + public function withSession(SessionInterface $session): self + { + return $this->withProperty('session', $session); + } + + + /** + * Start the session for broker requests to the SSO server. + * + * @throws BrokerException + * @throws ServerException + */ + public function startBrokerSession(?ServerRequestInterface $request = null): void + { + if ($this->session->isActive()) { + throw new ServerException("Session is already started", 500); + } + + $bearer = $this->getBearerToken($request); + + [$brokerId, $token, $checksum] = $this->parseBearer($bearer); + + $sessionId = $this->cache->get($this->getCacheKey($brokerId, $token)); + + if ($sessionId === null) { + $this->logger->warning( + "Bearer token isn't attached to a client session", + ['broker' => $brokerId, 'token' => $token] + ); + throw new BrokerException("Bearer token isn't attached to a client session", 403); + } + + $code = $this->getVerificationCode($brokerId, $token, $sessionId); + $this->validateChecksum($checksum, 'bearer', $brokerId, $token, $code); + + $this->session->resume($sessionId); + + $this->logger->debug( + "Broker request with session", + ['broker' => $brokerId, 'token' => $token, 'session' => $sessionId] + ); + } + + /** + * Get bearer token from Authorization header. + */ + protected function getBearerToken(?ServerRequestInterface $request = null): string + { + $authorization = $request === null + ? ($_SERVER['HTTP_AUTHORIZATION'] ?? '') // @codeCoverageIgnore + : $request->getHeaderLine('Authorization'); + + [$type, $token] = explode(' ', $authorization, 2) + ['', '']; + + if ($type !== 'Bearer') { + $this->logger->warning("Broker didn't use bearer authentication: " + . ($authorization === '' ? "No 'Authorization' header" : "$type authorization used")); + throw new BrokerException("Broker didn't use bearer authentication", 401); + } + + return $token; + } + + /** + * Get the broker id and token from the bearer token used by the broker. + * + * @return string[] + * @throws BrokerException + */ + protected function parseBearer(string $bearer): array + { + $matches = null; + + if (!(bool)preg_match('/^SSO-(\w*+)-(\w*+)-([a-z0-9]*+)$/', $bearer, $matches)) { + $this->logger->warning("Invalid bearer token", ['bearer' => $bearer]); + throw new BrokerException("Invalid bearer token", 403); + } + + return array_slice($matches, 1); + } + + /** + * Generate cache key for linking the broker token to the client session. + */ + protected function getCacheKey(string $brokerId, string $token): string + { + return "SSO-{$brokerId}-{$token}"; + } + + /** + * Get the broker secret using the configured callback. + * + * @param string $brokerId + * @return string|null + */ + protected function getBrokerSecret(string $brokerId): ?string + { + return ($this->getBrokerInfo)($brokerId)['secret'] ?? null; + } + + /** + * Generate the verification code based on the token using the server secret. + */ + protected function getVerificationCode(string $brokerId, string $token, string $sessionId): string + { + return base_convert(hash('sha256', $brokerId . $token . $sessionId), 16, 36); + } + + /** + * Generate checksum for a broker. + */ + protected function generateChecksum(string $command, string $brokerId, string $token): string + { + $secret = $this->getBrokerSecret($brokerId); + + if ($secret === null) { + $this->logger->warning("Unknown broker", ['broker' => $brokerId, 'token' => $token]); + throw new BrokerException("Broker is unknown or disabled", 403); + } + + return base_convert(hash_hmac('sha256', $command . ':' . $token, $secret), 16, 36); + } + + /** + * Assert that the checksum matches the expected checksum. + * + * @throws BrokerException + */ + protected function validateChecksum( + string $checksum, + string $command, + string $brokerId, + string $token, + ?string $code = null + ): void { + $expected = $this->generateChecksum($command . ($code !== null ? ":$code" : ''), $brokerId, $token); + + if ($checksum !== $expected) { + $this->logger->warning( + "Invalid $command checksum", + ['expected' => $expected, 'received' => $checksum, 'broker' => $brokerId, 'token' => $token] + + ($code !== null ? ['verification_code' => $code] : []) + ); + throw new BrokerException("Invalid $command checksum", 403); + } + } + + /** + * Validate that the URL has a domain that is allowed for the broker. + */ + public function validateDomain(string $type, string $url, string $brokerId, ?string $token = null): void + { + $domains = ($this->getBrokerInfo)($brokerId)['domains'] ?? []; + $host = parse_url($url, PHP_URL_HOST); + + if (!in_array($host, $domains, true)) { + $this->logger->warning( + "Domain of $type is not allowed for broker", + [$type => $url, 'broker' => $brokerId] + ($token !== null ? ['token' => $token] : []) + ); + throw new BrokerException("Domain of $type is not allowed", 400); + } + } + + /** + * Attach a client session to a broker session. + * Returns the verification code. + * + * @throws BrokerException + * @throws ServerException + */ + public function attach(?ServerRequestInterface $request = null): string + { + ['broker' => $brokerId, 'token' => $token] = $this->processAttachRequest($request); + + $this->session->start(); + + $this->assertNotAttached($brokerId, $token); + + $key = $this->getCacheKey($brokerId, $token); + $cached = $this->cache->set($key, $this->session->getId()); + + $info = ['broker' => $brokerId, 'token' => $token, 'session' => $this->session->getId()]; + + if (!$cached) { + $this->logger->error("Failed to attach bearer token to session id due to cache issue", $info); + throw new ServerException("Failed to attach bearer token to session id", 500); + } + + $this->logger->info("Attached broker token to session", $info); + + return $this->getVerificationCode($brokerId, $token, $this->session->getId()); + } + + /** + * Assert that the token isn't already attached to a different session. + */ + protected function assertNotAttached(string $brokerId, string $token): void + { + $key = $this->getCacheKey($brokerId, $token); + $attached = $this->cache->get($key); + + if ($attached !== null && $attached !== $this->session->getId()) { + $this->logger->warning("Token is already attached", [ + 'broker' => $brokerId, + 'token' => $token, + 'attached_to' => $attached, + 'session' => $this->session->getId() + ]); + throw new BrokerException("Token is already attached", 400); + } + } + + /** + * Validate attach request and return broker id and token. + * + * @param ServerRequestInterface|null $request + * @return array{broker:string,token:string} + * @throws BrokerException + */ + protected function processAttachRequest(?ServerRequestInterface $request): array + { + $brokerId = $this->getRequiredQueryParam($request, 'broker'); + $token = $this->getRequiredQueryParam($request, 'token'); + $checksum = $this->getRequiredQueryParam($request, 'checksum'); + + $this->validateChecksum($checksum, 'attach', $brokerId, $token); + + $origin = $this->getHeader($request, 'Origin'); + if ($origin !== '') { + $this->validateDomain('origin', $origin, $brokerId, $token); + } + + $referer = $this->getHeader($request, 'Referer'); + if ($referer !== '') { + $this->validateDomain('referer', $referer, $brokerId, $token); + } + + $returnUrl = $this->getQueryParam($request, 'return_url'); + if ($returnUrl !== null) { + $this->validateDomain('return_url', $returnUrl, $brokerId, $token); + } + + return ['broker' => $brokerId, 'token' => $token]; + } + + /** + * Get query parameter from PSR-7 request or $_GET. + */ + protected function getQueryParam(?ServerRequestInterface $request, string $key): ?string + { + $params = $request === null + ? $_GET // @codeCoverageIgnore + : $request->getQueryParams(); + + return $params[$key] ?? null; + } + + /** + * Get required query parameter from PSR-7 request or $_GET. + * + * @throws BrokerException if query parameter isn't set + */ + protected function getRequiredQueryParam(?ServerRequestInterface $request, string $key): string + { + $value = $this->getQueryParam($request, $key); + + if ($value === null) { + throw new BrokerException("Missing '$key' query parameter", 400); + } + + return $value; + } + + /** + * Get HTTP Header from PSR-7 request or $_SERVER. + * + * @param ServerRequestInterface $request + * @param string $key + * @return string + */ + protected function getHeader(?ServerRequestInterface $request, string $key): string + { + return $request === null + ? ($_SERVER['HTTP_' . str_replace('-', '_', strtoupper($key))] ?? '') // @codeCoverageIgnore + : $request->getHeaderLine($key); + } +} diff --git a/src/Server/ServerException.php b/src/Server/ServerException.php new file mode 100644 index 0000000..c103c65 --- /dev/null +++ b/src/Server/ServerException.php @@ -0,0 +1,13 @@ +server = new PhpBuiltInServer(ROOT_DIR . '/demo/server/', 8200); + + $this->broker1 = new PhpBuiltInServer( + ROOT_DIR . '/demo/broker/', + 8201, + [ + 'SSO_SERVER' => 'http://localhost:8200/attach.php', + 'SSO_BROKER_ID' => 'Alice', + 'SSO_BROKER_SECRET' => '8iwzik1bwd' + ] + ); + + $this->broker2 = new PhpBuiltInServer( + ROOT_DIR . '/demo/broker/', + 8202, + [ + 'SSO_SERVER' => 'http://localhost:8200/attach.php', + 'SSO_BROKER_ID' => 'Greg', + 'SSO_BROKER_SECRET' => '7pypoox2pc' + ] + ); + } + + /** + * Hook runs after all test of the suite is run + */ + public function _afterSuite() + { + $this->server = null; + $this->broker1 = null; + $this->broker2 = null; + + parent::_afterSuite(); + } + + /** + * Set URL of broker as base host. + * + * @param int $nr + */ + public function amOnBroker(int $nr): void + { + if ($nr < 1 || $nr > 2) { + throw new \Exception("Invalid broker number $nr"); + } + + $port = $nr + 8200; + + /** @var PhpBrowser $phpBrowser */ + $phpBrowser =$this->getModule('PhpBrowser'); + + $phpBrowser->amOnUrl("http://localhost:$port"); + } +} diff --git a/tests/_support/Helper/Functional.php b/tests/_support/Helper/Functional.php deleted file mode 100644 index 34fbe73..0000000 --- a/tests/_support/Helper/Functional.php +++ /dev/null @@ -1,9 +0,0 @@ -port = $port; + + $this->run($documentRoot, $env); + $this->testConnection(); + } + + /** + * Start the web server + * + * @param string $documentRoot Path to router file. + * @param string[] $env Environment variables + */ + protected function run(string $documentRoot, array $env): void + { + if ($this->handle) { + trigger_error("Built-in webserver on port {$this->port} already started", E_USER_NOTICE); + return; + } + + $cmd = $this->getCommand($documentRoot); + $descriptorSpec = [ + ["pipe", "r"], + ['file', Configuration::logDir() . "phpbuiltinserver.{$this->port}.output.txt", 'w'], + ['file', Configuration::logDir() . "phpbuiltinserver.{$this->port}.errors.txt", 'a'] + ]; + $pipes = []; + + $this->handle = proc_open($cmd, $descriptorSpec, $this->pipes, ROOT_DIR, $env, ['bypass_shell' => true]); + fclose($this->pipes[0]); // close stdin + + $this->registerShutdown(); + + usleep(10000); + $status = proc_get_status($this->handle); + + if (!$status['running']) { + proc_close($this->handle); + + $error = stream_get_contents($pipes[2]) ?: stream_get_contents($pipes[1]); + throw new \Exception("Failed to start PHP built-in web server. $error"); + } + } + + /** + * Get the executable command to start the webserver. + */ + protected function getCommand(string $documentRoot): string + { + // Platform uses POSIX process handling. Use exec to avoid controlling the shell process instead of the PHP + // interpreter. + $exec = (PHP_OS !== 'WINNT' && PHP_OS !== 'WIN32') ? 'exec ' : ''; + + return $exec . escapeshellcmd(PHP_BINARY) + . " -S localhost:{$this->port}" + . " -t " . escapeshellarg($documentRoot) + . ($this->isRemoteDebug() ? ' -dxdebug.remote_enable=1' : ''); + } + + /** + * Check if codeception remote debugging is available. + */ + protected function isRemoteDebug(): bool + { + return Configuration::isExtensionEnabled('Codeception\Extension\RemoteDebug'); + } + + /** + * Make sure we can connect to the webserver + */ + protected function testConnection() + { + for ($i=0; $i < 5; $i++) { + if ($this->connect()) { + return; + } + sleep(1); + } + + $err = error_get_last(); + throw new \Exception("Failed to connect to built-in web server: {$err['message']}"); + } + + /** + * Connect to the webserver + */ + protected function connect(): bool + { + $sock = @fsockopen('localhost', $this->port, $errno, $errstr, 1); + + return is_resource($sock) && $errno === 0; + } + + /** + * Stop the web server + */ + public function __destruct() + { + $this->stop(); + } + + /** + * Stop the web server + */ + public function stop(): void + { + if ($this->handle === null) { + return; + } + + foreach ($this->pipes as $pipe) { + if (is_resource($pipe)) { + fclose($pipe); + } + } + + proc_terminate($this->handle, 15); + unset($this->handle); + } + + /** + * Register shutdown function to stop webserver on an error. + */ + protected function registerShutdown(): void + { + $handle = $this->handle; + + register_shutdown_function(function () use ($handle) { + if (is_resource($handle)) { + proc_terminate($handle); + } + }); + } +} diff --git a/tests/_support/ServerApiTester.php b/tests/_support/ServerApiTester.php deleted file mode 100644 index e6d4c8c..0000000 --- a/tests/_support/ServerApiTester.php +++ /dev/null @@ -1,39 +0,0 @@ -defaultArgs; - $args['command'] = $command; - - foreach ($extraArgs as $key => $value) { - $args[$key] = $value; - } - - $this->sendPost('/', $args); - } -} diff --git a/tests/_support/UnitTester.php b/tests/_support/UnitTester.php new file mode 100644 index 0000000..e19544a --- /dev/null +++ b/tests/_support/UnitTester.php @@ -0,0 +1,26 @@ +setHeader('X-Requested-With', 'Codeception'); - * $I->amOnPage('test-headers.php'); - * ?> - * ``` - * - * @param string $name the name of the request header - * @param string $value the value to set it to for subsequent - * requests - * @see \Codeception\Module\PhpBrowser::setHeader() - */ - public function setHeader($name, $value) { - return $this->getScenario()->runStep(new \Codeception\Step\Action('setHeader', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Deletes the header with the passed name. Subsequent requests - * will not have the deleted header in its request. - * - * Example: - * ```php - * setHeader('X-Requested-With', 'Codeception'); - * $I->amOnPage('test-headers.php'); - * // ... - * $I->deleteHeader('X-Requested-With'); - * $I->amOnPage('some-other-page.php'); - * ?> - * ``` - * - * @param string $name the name of the header to delete. - * @see \Codeception\Module\PhpBrowser::deleteHeader() - */ - public function deleteHeader($name) { - return $this->getScenario()->runStep(new \Codeception\Step\Action('deleteHeader', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Authenticates user for HTTP_AUTH - * - * @param $username - * @param $password - * @see \Codeception\Module\PhpBrowser::amHttpAuthenticated() - */ - public function amHttpAuthenticated($username, $password) { - return $this->getScenario()->runStep(new \Codeception\Step\Condition('amHttpAuthenticated', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Open web page at the given absolute URL and sets its hostname as the base host. - * - * ``` php - * amOnUrl('http://codeception.com'); - * $I->amOnPage('/quickstart'); // moves to http://codeception.com/quickstart - * ?> - * ``` - * @see \Codeception\Module\PhpBrowser::amOnUrl() - */ - public function amOnUrl($url) { - return $this->getScenario()->runStep(new \Codeception\Step\Condition('amOnUrl', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Changes the subdomain for the 'url' configuration parameter. - * Does not open a page; use `amOnPage` for that. - * - * ``` php - * amOnSubdomain('user'); - * $I->amOnPage('/'); - * // moves to http://user.mysite.com/ - * ?> - * ``` - * - * @param $subdomain - * - * @return mixed - * @see \Codeception\Module\PhpBrowser::amOnSubdomain() - */ - public function amOnSubdomain($subdomain) { - return $this->getScenario()->runStep(new \Codeception\Step\Condition('amOnSubdomain', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Low-level API method. - * If Codeception commands are not enough, use [Guzzle HTTP Client](http://guzzlephp.org/) methods directly - * - * Example: - * - * ``` php - * executeInGuzzle(function (\GuzzleHttp\Client $client) { - * $client->get('/get', ['query' => ['foo' => 'bar']]); - * }); - * ?> - * ``` - * - * It is not recommended to use this command on a regular basis. - * If Codeception lacks important Guzzle Client methods, implement them and submit patches. - * - * @param callable $function - * @see \Codeception\Module\PhpBrowser::executeInGuzzle() - */ - public function executeInGuzzle($function) { - return $this->getScenario()->runStep(new \Codeception\Step\Action('executeInGuzzle', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Opens the page for the given relative URI. - * - * ``` php - * amOnPage('/'); - * // opens /register page - * $I->amOnPage('/register'); - * ?> - * ``` - * - * @param $page - * @see \Codeception\Lib\InnerBrowser::amOnPage() - */ - public function amOnPage($page) { - return $this->getScenario()->runStep(new \Codeception\Step\Condition('amOnPage', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Perform a click on a link or a button, given by a locator. - * If a fuzzy locator is given, the page will be searched for a button, link, or image matching the locator string. - * For buttons, the "value" attribute, "name" attribute, and inner text are searched. - * For links, the link text is searched. - * For images, the "alt" attribute and inner text of any parent links are searched. - * - * The second parameter is a context (CSS or XPath locator) to narrow the search. - * - * Note that if the locator matches a button of type `submit`, the form will be submitted. - * - * ``` php - * click('Logout'); - * // button of form - * $I->click('Submit'); - * // CSS button - * $I->click('#form input[type=submit]'); - * // XPath - * $I->click('//form/*[@type=submit]'); - * // link in context - * $I->click('Logout', '#nav'); - * // using strict locator - * $I->click(['link' => 'Login']); - * ?> - * ``` - * - * @param $link - * @param $context - * @see \Codeception\Lib\InnerBrowser::click() - */ - public function click($link, $context = null) { - return $this->getScenario()->runStep(new \Codeception\Step\Action('click', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks that the current page contains the given string. - * Specify a locator as the second parameter to match a specific region. - * - * ``` php - * see('Logout'); // I can suppose user is logged in - * $I->see('Sign Up','h1'); // I can suppose it's a signup page - * $I->see('Sign Up','//body/h1'); // with XPath - * ?> - * ``` - * - * @param $text - * @param null $selector - * Conditional Assertion: Test won't be stopped on fail - * @see \Codeception\Lib\InnerBrowser::see() - */ - public function canSee($text, $selector = null) { - return $this->getScenario()->runStep(new \Codeception\Step\ConditionalAssertion('see', func_get_args())); - } - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks that the current page contains the given string. - * Specify a locator as the second parameter to match a specific region. - * - * ``` php - * see('Logout'); // I can suppose user is logged in - * $I->see('Sign Up','h1'); // I can suppose it's a signup page - * $I->see('Sign Up','//body/h1'); // with XPath - * ?> - * ``` - * - * @param $text - * @param null $selector - * @see \Codeception\Lib\InnerBrowser::see() - */ - public function see($text, $selector = null) { - return $this->getScenario()->runStep(new \Codeception\Step\Assertion('see', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks that the current page doesn't contain the text specified. - * Give a locator as the second parameter to match a specific region. - * - * ```php - * dontSee('Login'); // I can suppose user is already logged in - * $I->dontSee('Sign Up','h1'); // I can suppose it's not a signup page - * $I->dontSee('Sign Up','//body/h1'); // with XPath - * ?> - * ``` - * - * @param $text - * @param null $selector - * Conditional Assertion: Test won't be stopped on fail - * @see \Codeception\Lib\InnerBrowser::dontSee() - */ - public function cantSee($text, $selector = null) { - return $this->getScenario()->runStep(new \Codeception\Step\ConditionalAssertion('dontSee', func_get_args())); - } - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks that the current page doesn't contain the text specified. - * Give a locator as the second parameter to match a specific region. - * - * ```php - * dontSee('Login'); // I can suppose user is already logged in - * $I->dontSee('Sign Up','h1'); // I can suppose it's not a signup page - * $I->dontSee('Sign Up','//body/h1'); // with XPath - * ?> - * ``` - * - * @param $text - * @param null $selector - * @see \Codeception\Lib\InnerBrowser::dontSee() - */ - public function dontSee($text, $selector = null) { - return $this->getScenario()->runStep(new \Codeception\Step\Assertion('dontSee', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks that there's a link with the specified text. - * Give a full URL as the second parameter to match links with that exact URL. - * - * ``` php - * seeLink('Logout'); // matches Logout - * $I->seeLink('Logout','/logout'); // matches Logout - * ?> - * ``` - * - * @param $text - * @param null $url - * Conditional Assertion: Test won't be stopped on fail - * @see \Codeception\Lib\InnerBrowser::seeLink() - */ - public function canSeeLink($text, $url = null) { - return $this->getScenario()->runStep(new \Codeception\Step\ConditionalAssertion('seeLink', func_get_args())); - } - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks that there's a link with the specified text. - * Give a full URL as the second parameter to match links with that exact URL. - * - * ``` php - * seeLink('Logout'); // matches Logout - * $I->seeLink('Logout','/logout'); // matches Logout - * ?> - * ``` - * - * @param $text - * @param null $url - * @see \Codeception\Lib\InnerBrowser::seeLink() - */ - public function seeLink($text, $url = null) { - return $this->getScenario()->runStep(new \Codeception\Step\Assertion('seeLink', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks that the page doesn't contain a link with the given string. - * If the second parameter is given, only links with a matching "href" attribute will be checked. - * - * ``` php - * dontSeeLink('Logout'); // I suppose user is not logged in - * $I->dontSeeLink('Checkout now', '/store/cart.php'); - * ?> - * ``` - * - * @param $text - * @param null $url - * Conditional Assertion: Test won't be stopped on fail - * @see \Codeception\Lib\InnerBrowser::dontSeeLink() - */ - public function cantSeeLink($text, $url = null) { - return $this->getScenario()->runStep(new \Codeception\Step\ConditionalAssertion('dontSeeLink', func_get_args())); - } - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks that the page doesn't contain a link with the given string. - * If the second parameter is given, only links with a matching "href" attribute will be checked. - * - * ``` php - * dontSeeLink('Logout'); // I suppose user is not logged in - * $I->dontSeeLink('Checkout now', '/store/cart.php'); - * ?> - * ``` - * - * @param $text - * @param null $url - * @see \Codeception\Lib\InnerBrowser::dontSeeLink() - */ - public function dontSeeLink($text, $url = null) { - return $this->getScenario()->runStep(new \Codeception\Step\Assertion('dontSeeLink', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks that current URI contains the given string. - * - * ``` php - * seeInCurrentUrl('home'); - * // to match: /users/1 - * $I->seeInCurrentUrl('/users/'); - * ?> - * ``` - * - * @param $uri - * Conditional Assertion: Test won't be stopped on fail - * @see \Codeception\Lib\InnerBrowser::seeInCurrentUrl() - */ - public function canSeeInCurrentUrl($uri) { - return $this->getScenario()->runStep(new \Codeception\Step\ConditionalAssertion('seeInCurrentUrl', func_get_args())); - } - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks that current URI contains the given string. - * - * ``` php - * seeInCurrentUrl('home'); - * // to match: /users/1 - * $I->seeInCurrentUrl('/users/'); - * ?> - * ``` - * - * @param $uri - * @see \Codeception\Lib\InnerBrowser::seeInCurrentUrl() - */ - public function seeInCurrentUrl($uri) { - return $this->getScenario()->runStep(new \Codeception\Step\Assertion('seeInCurrentUrl', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks that the current URI doesn't contain the given string. - * - * ``` php - * dontSeeInCurrentUrl('/users/'); - * ?> - * ``` - * - * @param $uri - * Conditional Assertion: Test won't be stopped on fail - * @see \Codeception\Lib\InnerBrowser::dontSeeInCurrentUrl() - */ - public function cantSeeInCurrentUrl($uri) { - return $this->getScenario()->runStep(new \Codeception\Step\ConditionalAssertion('dontSeeInCurrentUrl', func_get_args())); - } - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks that the current URI doesn't contain the given string. - * - * ``` php - * dontSeeInCurrentUrl('/users/'); - * ?> - * ``` - * - * @param $uri - * @see \Codeception\Lib\InnerBrowser::dontSeeInCurrentUrl() - */ - public function dontSeeInCurrentUrl($uri) { - return $this->getScenario()->runStep(new \Codeception\Step\Assertion('dontSeeInCurrentUrl', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks that the current URL is equal to the given string. - * Unlike `seeInCurrentUrl`, this only matches the full URL. - * - * ``` php - * seeCurrentUrlEquals('/'); - * ?> - * ``` - * - * @param $uri - * Conditional Assertion: Test won't be stopped on fail - * @see \Codeception\Lib\InnerBrowser::seeCurrentUrlEquals() - */ - public function canSeeCurrentUrlEquals($uri) { - return $this->getScenario()->runStep(new \Codeception\Step\ConditionalAssertion('seeCurrentUrlEquals', func_get_args())); - } - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks that the current URL is equal to the given string. - * Unlike `seeInCurrentUrl`, this only matches the full URL. - * - * ``` php - * seeCurrentUrlEquals('/'); - * ?> - * ``` - * - * @param $uri - * @see \Codeception\Lib\InnerBrowser::seeCurrentUrlEquals() - */ - public function seeCurrentUrlEquals($uri) { - return $this->getScenario()->runStep(new \Codeception\Step\Assertion('seeCurrentUrlEquals', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks that the current URL doesn't equal the given string. - * Unlike `dontSeeInCurrentUrl`, this only matches the full URL. - * - * ``` php - * dontSeeCurrentUrlEquals('/'); - * ?> - * ``` - * - * @param $uri - * Conditional Assertion: Test won't be stopped on fail - * @see \Codeception\Lib\InnerBrowser::dontSeeCurrentUrlEquals() - */ - public function cantSeeCurrentUrlEquals($uri) { - return $this->getScenario()->runStep(new \Codeception\Step\ConditionalAssertion('dontSeeCurrentUrlEquals', func_get_args())); - } - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks that the current URL doesn't equal the given string. - * Unlike `dontSeeInCurrentUrl`, this only matches the full URL. - * - * ``` php - * dontSeeCurrentUrlEquals('/'); - * ?> - * ``` - * - * @param $uri - * @see \Codeception\Lib\InnerBrowser::dontSeeCurrentUrlEquals() - */ - public function dontSeeCurrentUrlEquals($uri) { - return $this->getScenario()->runStep(new \Codeception\Step\Assertion('dontSeeCurrentUrlEquals', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks that the current URL matches the given regular expression. - * - * ``` php - * seeCurrentUrlMatches('~$/users/(\d+)~'); - * ?> - * ``` - * - * @param $uri - * Conditional Assertion: Test won't be stopped on fail - * @see \Codeception\Lib\InnerBrowser::seeCurrentUrlMatches() - */ - public function canSeeCurrentUrlMatches($uri) { - return $this->getScenario()->runStep(new \Codeception\Step\ConditionalAssertion('seeCurrentUrlMatches', func_get_args())); - } - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks that the current URL matches the given regular expression. - * - * ``` php - * seeCurrentUrlMatches('~$/users/(\d+)~'); - * ?> - * ``` - * - * @param $uri - * @see \Codeception\Lib\InnerBrowser::seeCurrentUrlMatches() - */ - public function seeCurrentUrlMatches($uri) { - return $this->getScenario()->runStep(new \Codeception\Step\Assertion('seeCurrentUrlMatches', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks that current url doesn't match the given regular expression. - * - * ``` php - * dontSeeCurrentUrlMatches('~$/users/(\d+)~'); - * ?> - * ``` - * - * @param $uri - * Conditional Assertion: Test won't be stopped on fail - * @see \Codeception\Lib\InnerBrowser::dontSeeCurrentUrlMatches() - */ - public function cantSeeCurrentUrlMatches($uri) { - return $this->getScenario()->runStep(new \Codeception\Step\ConditionalAssertion('dontSeeCurrentUrlMatches', func_get_args())); - } - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks that current url doesn't match the given regular expression. - * - * ``` php - * dontSeeCurrentUrlMatches('~$/users/(\d+)~'); - * ?> - * ``` - * - * @param $uri - * @see \Codeception\Lib\InnerBrowser::dontSeeCurrentUrlMatches() - */ - public function dontSeeCurrentUrlMatches($uri) { - return $this->getScenario()->runStep(new \Codeception\Step\Assertion('dontSeeCurrentUrlMatches', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Executes the given regular expression against the current URI and returns the first match. - * If no parameters are provided, the full URI is returned. - * - * ``` php - * grabFromCurrentUrl('~$/user/(\d+)/~'); - * $uri = $I->grabFromCurrentUrl(); - * ?> - * ``` - * - * @param null $uri - * - * @internal param $url - * @return mixed - * @see \Codeception\Lib\InnerBrowser::grabFromCurrentUrl() - */ - public function grabFromCurrentUrl($uri = null) { - return $this->getScenario()->runStep(new \Codeception\Step\Action('grabFromCurrentUrl', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks that the specified checkbox is checked. - * - * ``` php - * seeCheckboxIsChecked('#agree'); // I suppose user agreed to terms - * $I->seeCheckboxIsChecked('#signup_form input[type=checkbox]'); // I suppose user agreed to terms, If there is only one checkbox in form. - * $I->seeCheckboxIsChecked('//form/input[@type=checkbox and @name=agree]'); - * ?> - * ``` - * - * @param $checkbox - * Conditional Assertion: Test won't be stopped on fail - * @see \Codeception\Lib\InnerBrowser::seeCheckboxIsChecked() - */ - public function canSeeCheckboxIsChecked($checkbox) { - return $this->getScenario()->runStep(new \Codeception\Step\ConditionalAssertion('seeCheckboxIsChecked', func_get_args())); - } - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks that the specified checkbox is checked. - * - * ``` php - * seeCheckboxIsChecked('#agree'); // I suppose user agreed to terms - * $I->seeCheckboxIsChecked('#signup_form input[type=checkbox]'); // I suppose user agreed to terms, If there is only one checkbox in form. - * $I->seeCheckboxIsChecked('//form/input[@type=checkbox and @name=agree]'); - * ?> - * ``` - * - * @param $checkbox - * @see \Codeception\Lib\InnerBrowser::seeCheckboxIsChecked() - */ - public function seeCheckboxIsChecked($checkbox) { - return $this->getScenario()->runStep(new \Codeception\Step\Assertion('seeCheckboxIsChecked', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Check that the specified checkbox is unchecked. - * - * ``` php - * dontSeeCheckboxIsChecked('#agree'); // I suppose user didn't agree to terms - * $I->seeCheckboxIsChecked('#signup_form input[type=checkbox]'); // I suppose user didn't check the first checkbox in form. - * ?> - * ``` - * - * @param $checkbox - * Conditional Assertion: Test won't be stopped on fail - * @see \Codeception\Lib\InnerBrowser::dontSeeCheckboxIsChecked() - */ - public function cantSeeCheckboxIsChecked($checkbox) { - return $this->getScenario()->runStep(new \Codeception\Step\ConditionalAssertion('dontSeeCheckboxIsChecked', func_get_args())); - } - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Check that the specified checkbox is unchecked. - * - * ``` php - * dontSeeCheckboxIsChecked('#agree'); // I suppose user didn't agree to terms - * $I->seeCheckboxIsChecked('#signup_form input[type=checkbox]'); // I suppose user didn't check the first checkbox in form. - * ?> - * ``` - * - * @param $checkbox - * @see \Codeception\Lib\InnerBrowser::dontSeeCheckboxIsChecked() - */ - public function dontSeeCheckboxIsChecked($checkbox) { - return $this->getScenario()->runStep(new \Codeception\Step\Assertion('dontSeeCheckboxIsChecked', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks that the given input field or textarea contains the given value. - * For fuzzy locators, fields are matched by label text, the "name" attribute, CSS, and XPath. - * - * ``` php - * seeInField('Body','Type your comment here'); - * $I->seeInField('form textarea[name=body]','Type your comment here'); - * $I->seeInField('form input[type=hidden]','hidden_value'); - * $I->seeInField('#searchform input','Search'); - * $I->seeInField('//form/*[@name=search]','Search'); - * $I->seeInField(['name' => 'search'], 'Search'); - * ?> - * ``` - * - * @param $field - * @param $value - * Conditional Assertion: Test won't be stopped on fail - * @see \Codeception\Lib\InnerBrowser::seeInField() - */ - public function canSeeInField($field, $value) { - return $this->getScenario()->runStep(new \Codeception\Step\ConditionalAssertion('seeInField', func_get_args())); - } - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks that the given input field or textarea contains the given value. - * For fuzzy locators, fields are matched by label text, the "name" attribute, CSS, and XPath. - * - * ``` php - * seeInField('Body','Type your comment here'); - * $I->seeInField('form textarea[name=body]','Type your comment here'); - * $I->seeInField('form input[type=hidden]','hidden_value'); - * $I->seeInField('#searchform input','Search'); - * $I->seeInField('//form/*[@name=search]','Search'); - * $I->seeInField(['name' => 'search'], 'Search'); - * ?> - * ``` - * - * @param $field - * @param $value - * @see \Codeception\Lib\InnerBrowser::seeInField() - */ - public function seeInField($field, $value) { - return $this->getScenario()->runStep(new \Codeception\Step\Assertion('seeInField', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks that an input field or textarea doesn't contain the given value. - * For fuzzy locators, the field is matched by label text, CSS and XPath. - * - * ``` php - * dontSeeInField('Body','Type your comment here'); - * $I->dontSeeInField('form textarea[name=body]','Type your comment here'); - * $I->dontSeeInField('form input[type=hidden]','hidden_value'); - * $I->dontSeeInField('#searchform input','Search'); - * $I->dontSeeInField('//form/*[@name=search]','Search'); - * $I->dontSeeInField(['name' => 'search'], 'Search'); - * ?> - * ``` - * - * @param $field - * @param $value - * Conditional Assertion: Test won't be stopped on fail - * @see \Codeception\Lib\InnerBrowser::dontSeeInField() - */ - public function cantSeeInField($field, $value) { - return $this->getScenario()->runStep(new \Codeception\Step\ConditionalAssertion('dontSeeInField', func_get_args())); - } - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks that an input field or textarea doesn't contain the given value. - * For fuzzy locators, the field is matched by label text, CSS and XPath. - * - * ``` php - * dontSeeInField('Body','Type your comment here'); - * $I->dontSeeInField('form textarea[name=body]','Type your comment here'); - * $I->dontSeeInField('form input[type=hidden]','hidden_value'); - * $I->dontSeeInField('#searchform input','Search'); - * $I->dontSeeInField('//form/*[@name=search]','Search'); - * $I->dontSeeInField(['name' => 'search'], 'Search'); - * ?> - * ``` - * - * @param $field - * @param $value - * @see \Codeception\Lib\InnerBrowser::dontSeeInField() - */ - public function dontSeeInField($field, $value) { - return $this->getScenario()->runStep(new \Codeception\Step\Assertion('dontSeeInField', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks if the array of form parameters (name => value) are set on the form matched with the - * passed selector. - * - * ``` php - * seeInFormFields('form[name=myform]', [ - * 'input1' => 'value', - * 'input2' => 'other value', - * ]); - * ?> - * ``` - * - * For multi-select elements, or to check values of multiple elements with the same name, an - * array may be passed: - * - * ``` php - * seeInFormFields('.form-class', [ - * 'multiselect' => [ - * 'value1', - * 'value2', - * ], - * 'checkbox[]' => [ - * 'a checked value', - * 'another checked value', - * ], - * ]); - * ?> - * ``` - * - * Additionally, checkbox values can be checked with a boolean. - * - * ``` php - * seeInFormFields('#form-id', [ - * 'checkbox1' => true, // passes if checked - * 'checkbox2' => false, // passes if unchecked - * ]); - * ?> - * ``` - * - * Pair this with submitForm for quick testing magic. - * - * ``` php - * 'value', - * 'field2' => 'another value', - * 'checkbox1' => true, - * // ... - * ]; - * $I->submitForm('//form[@id=my-form]', $form, 'submitButton'); - * // $I->amOnPage('/path/to/form-page') may be needed - * $I->seeInFormFields('//form[@id=my-form]', $form); - * ?> - * ``` - * - * @param $formSelector - * @param $params - * Conditional Assertion: Test won't be stopped on fail - * @see \Codeception\Lib\InnerBrowser::seeInFormFields() - */ - public function canSeeInFormFields($formSelector, $params) { - return $this->getScenario()->runStep(new \Codeception\Step\ConditionalAssertion('seeInFormFields', func_get_args())); - } - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks if the array of form parameters (name => value) are set on the form matched with the - * passed selector. - * - * ``` php - * seeInFormFields('form[name=myform]', [ - * 'input1' => 'value', - * 'input2' => 'other value', - * ]); - * ?> - * ``` - * - * For multi-select elements, or to check values of multiple elements with the same name, an - * array may be passed: - * - * ``` php - * seeInFormFields('.form-class', [ - * 'multiselect' => [ - * 'value1', - * 'value2', - * ], - * 'checkbox[]' => [ - * 'a checked value', - * 'another checked value', - * ], - * ]); - * ?> - * ``` - * - * Additionally, checkbox values can be checked with a boolean. - * - * ``` php - * seeInFormFields('#form-id', [ - * 'checkbox1' => true, // passes if checked - * 'checkbox2' => false, // passes if unchecked - * ]); - * ?> - * ``` - * - * Pair this with submitForm for quick testing magic. - * - * ``` php - * 'value', - * 'field2' => 'another value', - * 'checkbox1' => true, - * // ... - * ]; - * $I->submitForm('//form[@id=my-form]', $form, 'submitButton'); - * // $I->amOnPage('/path/to/form-page') may be needed - * $I->seeInFormFields('//form[@id=my-form]', $form); - * ?> - * ``` - * - * @param $formSelector - * @param $params - * @see \Codeception\Lib\InnerBrowser::seeInFormFields() - */ - public function seeInFormFields($formSelector, $params) { - return $this->getScenario()->runStep(new \Codeception\Step\Assertion('seeInFormFields', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks if the array of form parameters (name => value) are not set on the form matched with - * the passed selector. - * - * ``` php - * dontSeeInFormFields('form[name=myform]', [ - * 'input1' => 'non-existent value', - * 'input2' => 'other non-existent value', - * ]); - * ?> - * ``` - * - * To check that an element hasn't been assigned any one of many values, an array can be passed - * as the value: - * - * ``` php - * dontSeeInFormFields('.form-class', [ - * 'fieldName' => [ - * 'This value shouldn\'t be set', - * 'And this value shouldn\'t be set', - * ], - * ]); - * ?> - * ``` - * - * Additionally, checkbox values can be checked with a boolean. - * - * ``` php - * dontSeeInFormFields('#form-id', [ - * 'checkbox1' => true, // fails if checked - * 'checkbox2' => false, // fails if unchecked - * ]); - * ?> - * ``` - * - * @param $formSelector - * @param $params - * Conditional Assertion: Test won't be stopped on fail - * @see \Codeception\Lib\InnerBrowser::dontSeeInFormFields() - */ - public function cantSeeInFormFields($formSelector, $params) { - return $this->getScenario()->runStep(new \Codeception\Step\ConditionalAssertion('dontSeeInFormFields', func_get_args())); - } - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks if the array of form parameters (name => value) are not set on the form matched with - * the passed selector. - * - * ``` php - * dontSeeInFormFields('form[name=myform]', [ - * 'input1' => 'non-existent value', - * 'input2' => 'other non-existent value', - * ]); - * ?> - * ``` - * - * To check that an element hasn't been assigned any one of many values, an array can be passed - * as the value: - * - * ``` php - * dontSeeInFormFields('.form-class', [ - * 'fieldName' => [ - * 'This value shouldn\'t be set', - * 'And this value shouldn\'t be set', - * ], - * ]); - * ?> - * ``` - * - * Additionally, checkbox values can be checked with a boolean. - * - * ``` php - * dontSeeInFormFields('#form-id', [ - * 'checkbox1' => true, // fails if checked - * 'checkbox2' => false, // fails if unchecked - * ]); - * ?> - * ``` - * - * @param $formSelector - * @param $params - * @see \Codeception\Lib\InnerBrowser::dontSeeInFormFields() - */ - public function dontSeeInFormFields($formSelector, $params) { - return $this->getScenario()->runStep(new \Codeception\Step\Assertion('dontSeeInFormFields', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Submits the given form on the page, optionally with the given form - * values. Give the form fields values as an array. - * - * Skipped fields will be filled by their values from the page. - * You don't need to click the 'Submit' button afterwards. - * This command itself triggers the request to form's action. - * - * You can optionally specify what button's value to include - * in the request with the last parameter as an alternative to - * explicitly setting its value in the second parameter, as - * button values are not otherwise included in the request. - * - * Examples: - * - * ``` php - * submitForm('#login', [ - * 'login' => 'davert', - * 'password' => '123456' - * ]); - * // or - * $I->submitForm('#login', [ - * 'login' => 'davert', - * 'password' => '123456' - * ], 'submitButtonName'); - * - * ``` - * - * For example, given this sample "Sign Up" form: - * - * ``` html - *
- * Login: - *
- * Password: - *
- * Do you agree to our terms? - *
- * Select pricing plan: - * - * - *
- * ``` - * - * You could write the following to submit it: - * - * ``` php - * submitForm( - * '#userForm', - * [ - * 'user' => [ - * 'login' => 'Davert', - * 'password' => '123456', - * 'agree' => true - * ] - * ], - * 'submitButton' - * ); - * ``` - * Note that "2" will be the submitted value for the "plan" field, as it is - * the selected option. - * - * You can also emulate a JavaScript submission by not specifying any - * buttons in the third parameter to submitForm. - * - * ```php - * submitForm( - * '#userForm', - * [ - * 'user' => [ - * 'login' => 'Davert', - * 'password' => '123456', - * 'agree' => true - * ] - * ] - * ); - * ``` - * - * Pair this with seeInFormFields for quick testing magic. - * - * ``` php - * 'value', - * 'field2' => 'another value', - * 'checkbox1' => true, - * // ... - * ]; - * $I->submitForm('//form[@id=my-form]', $form, 'submitButton'); - * // $I->amOnPage('/path/to/form-page') may be needed - * $I->seeInFormFields('//form[@id=my-form]', $form); - * ?> - * ``` - * - * Parameter values can be set to arrays for multiple input fields - * of the same name, or multi-select combo boxes. For checkboxes, - * either the string value can be used, or boolean values which will - * be replaced by the checkbox's value in the DOM. - * - * ``` php - * submitForm('#my-form', [ - * 'field1' => 'value', - * 'checkbox' => [ - * 'value of first checkbox', - * 'value of second checkbox, - * ], - * 'otherCheckboxes' => [ - * true, - * false, - * false - * ], - * 'multiselect' => [ - * 'first option value', - * 'second option value' - * ] - * ]); - * ?> - * ``` - * - * Mixing string and boolean values for a checkbox's value is not supported - * and may produce unexpected results. - * - * Field names ending in "[]" must be passed without the trailing square - * bracket characters, and must contain an array for its value. This allows - * submitting multiple values with the same name, consider: - * - * ```php - * $I->submitForm('#my-form', [ - * 'field[]' => 'value', - * 'field[]' => 'another value', // 'field[]' is already a defined key - * ]); - * ``` - * - * The solution is to pass an array value: - * - * ```php - * // this way both values are submitted - * $I->submitForm('#my-form', [ - * 'field' => [ - * 'value', - * 'another value', - * ] - * ]); - * ``` - * - * @param $selector - * @param $params - * @param $button - * @see \Codeception\Lib\InnerBrowser::submitForm() - */ - public function submitForm($selector, $params, $button = null) { - return $this->getScenario()->runStep(new \Codeception\Step\Action('submitForm', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Fills a text field or textarea with the given string. - * - * ``` php - * fillField("//input[@type='text']", "Hello World!"); - * $I->fillField(['name' => 'email'], 'jon@mail.com'); - * ?> - * ``` - * - * @param $field - * @param $value - * @see \Codeception\Lib\InnerBrowser::fillField() - */ - public function fillField($field, $value) { - return $this->getScenario()->runStep(new \Codeception\Step\Action('fillField', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Selects an option in a select tag or in radio button group. - * - * ``` php - * selectOption('form select[name=account]', 'Premium'); - * $I->selectOption('form input[name=payment]', 'Monthly'); - * $I->selectOption('//form/select[@name=account]', 'Monthly'); - * ?> - * ``` - * - * Provide an array for the second argument to select multiple options: - * - * ``` php - * selectOption('Which OS do you use?', array('Windows','Linux')); - * ?> - * ``` - * - * @param $select - * @param $option - * @see \Codeception\Lib\InnerBrowser::selectOption() - */ - public function selectOption($select, $option) { - return $this->getScenario()->runStep(new \Codeception\Step\Action('selectOption', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Ticks a checkbox. For radio buttons, use the `selectOption` method instead. - * - * ``` php - * checkOption('#agree'); - * ?> - * ``` - * - * @param $option - * @see \Codeception\Lib\InnerBrowser::checkOption() - */ - public function checkOption($option) { - return $this->getScenario()->runStep(new \Codeception\Step\Action('checkOption', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Unticks a checkbox. - * - * ``` php - * uncheckOption('#notify'); - * ?> - * ``` - * - * @param $option - * @see \Codeception\Lib\InnerBrowser::uncheckOption() - */ - public function uncheckOption($option) { - return $this->getScenario()->runStep(new \Codeception\Step\Action('uncheckOption', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Attaches a file relative to the Codeception data directory to the given file upload field. - * - * ``` php - * attachFile('input[@type="file"]', 'prices.xls'); - * ?> - * ``` - * - * @param $field - * @param $filename - * @see \Codeception\Lib\InnerBrowser::attachFile() - */ - public function attachFile($field, $filename) { - return $this->getScenario()->runStep(new \Codeception\Step\Action('attachFile', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * If your page triggers an ajax request, you can perform it manually. - * This action sends a GET ajax request with specified params. - * - * See ->sendAjaxPostRequest for examples. - * - * @param $uri - * @param $params - * @see \Codeception\Lib\InnerBrowser::sendAjaxGetRequest() - */ - public function sendAjaxGetRequest($uri, $params = null) { - return $this->getScenario()->runStep(new \Codeception\Step\Action('sendAjaxGetRequest', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * If your page triggers an ajax request, you can perform it manually. - * This action sends a POST ajax request with specified params. - * Additional params can be passed as array. - * - * Example: - * - * Imagine that by clicking checkbox you trigger ajax request which updates user settings. - * We emulate that click by running this ajax request manually. - * - * ``` php - * sendAjaxPostRequest('/updateSettings', array('notifications' => true)); // POST - * $I->sendAjaxGetRequest('/updateSettings', array('notifications' => true)); // GET - * - * ``` - * - * @param $uri - * @param $params - * @see \Codeception\Lib\InnerBrowser::sendAjaxPostRequest() - */ - public function sendAjaxPostRequest($uri, $params = null) { - return $this->getScenario()->runStep(new \Codeception\Step\Action('sendAjaxPostRequest', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * If your page triggers an ajax request, you can perform it manually. - * This action sends an ajax request with specified method and params. - * - * Example: - * - * You need to perform an ajax request specifying the HTTP method. - * - * ``` php - * sendAjaxRequest('PUT', '/posts/7', array('title' => 'new title')); - * - * ``` - * - * @param $method - * @param $uri - * @param $params - * @see \Codeception\Lib\InnerBrowser::sendAjaxRequest() - */ - public function sendAjaxRequest($method, $uri, $params = null) { - return $this->getScenario()->runStep(new \Codeception\Step\Action('sendAjaxRequest', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Finds and returns the text contents of the given element. - * If a fuzzy locator is used, the element is found using CSS, XPath, and by matching the full page source by regular expression. - * - * ``` php - * grabTextFrom('h1'); - * $heading = $I->grabTextFrom('descendant-or-self::h1'); - * $value = $I->grabTextFrom('~ - * ``` - * - * @param $cssOrXPathOrRegex - * - * @return mixed - * @see \Codeception\Lib\InnerBrowser::grabTextFrom() - */ - public function grabTextFrom($cssOrXPathOrRegex) { - return $this->getScenario()->runStep(new \Codeception\Step\Action('grabTextFrom', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Grabs the value of the given attribute value from the given element. - * Fails if element is not found. - * - * ``` php - * grabAttributeFrom('#tooltip', 'title'); - * ?> - * ``` - * - * - * @param $cssOrXpath - * @param $attribute - * @internal param $element - * @return mixed - * @see \Codeception\Lib\InnerBrowser::grabAttributeFrom() - */ - public function grabAttributeFrom($cssOrXpath, $attribute) { - return $this->getScenario()->runStep(new \Codeception\Step\Action('grabAttributeFrom', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * - * @see \Codeception\Lib\InnerBrowser::grabMultiple() - */ - public function grabMultiple($cssOrXpath, $attribute = null) { - return $this->getScenario()->runStep(new \Codeception\Step\Action('grabMultiple', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * @param $field - * - * @return array|mixed|null|string - * @see \Codeception\Lib\InnerBrowser::grabValueFrom() - */ - public function grabValueFrom($field) { - return $this->getScenario()->runStep(new \Codeception\Step\Action('grabValueFrom', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Sets a cookie with the given name and value. - * You can set additional cookie params like `domain`, `path`, `expire`, `secure` in array passed as last argument. - * - * ``` php - * setCookie('PHPSESSID', 'el4ukv0kqbvoirg7nkp4dncpk3'); - * ?> - * ``` - * - * @param $name - * @param $val - * @param array $params - * - * @return mixed - * @see \Codeception\Lib\InnerBrowser::setCookie() - */ - public function setCookie($name, $val, $params = null) { - return $this->getScenario()->runStep(new \Codeception\Step\Action('setCookie', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Grabs a cookie value. - * You can set additional cookie params like `domain`, `path` in array passed as last argument. - * - * @param $cookie - * - * @param array $params - * @return mixed - * @see \Codeception\Lib\InnerBrowser::grabCookie() - */ - public function grabCookie($cookie, $params = null) { - return $this->getScenario()->runStep(new \Codeception\Step\Action('grabCookie', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks that a cookie with the given name is set. - * You can set additional cookie params like `domain`, `path` as array passed in last argument. - * - * ``` php - * seeCookie('PHPSESSID'); - * ?> - * ``` - * - * @param $cookie - * @param array $params - * @return mixed - * Conditional Assertion: Test won't be stopped on fail - * @see \Codeception\Lib\InnerBrowser::seeCookie() - */ - public function canSeeCookie($cookie, $params = null) { - return $this->getScenario()->runStep(new \Codeception\Step\ConditionalAssertion('seeCookie', func_get_args())); - } - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks that a cookie with the given name is set. - * You can set additional cookie params like `domain`, `path` as array passed in last argument. - * - * ``` php - * seeCookie('PHPSESSID'); - * ?> - * ``` - * - * @param $cookie - * @param array $params - * @return mixed - * @see \Codeception\Lib\InnerBrowser::seeCookie() - */ - public function seeCookie($cookie, $params = null) { - return $this->getScenario()->runStep(new \Codeception\Step\Assertion('seeCookie', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks that there isn't a cookie with the given name. - * You can set additional cookie params like `domain`, `path` as array passed in last argument. - * - * @param $cookie - * - * @param array $params - * @return mixed - * Conditional Assertion: Test won't be stopped on fail - * @see \Codeception\Lib\InnerBrowser::dontSeeCookie() - */ - public function cantSeeCookie($cookie, $params = null) { - return $this->getScenario()->runStep(new \Codeception\Step\ConditionalAssertion('dontSeeCookie', func_get_args())); - } - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks that there isn't a cookie with the given name. - * You can set additional cookie params like `domain`, `path` as array passed in last argument. - * - * @param $cookie - * - * @param array $params - * @return mixed - * @see \Codeception\Lib\InnerBrowser::dontSeeCookie() - */ - public function dontSeeCookie($cookie, $params = null) { - return $this->getScenario()->runStep(new \Codeception\Step\Assertion('dontSeeCookie', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Unsets cookie with the given name. - * You can set additional cookie params like `domain`, `path` in array passed as last argument. - * - * @param $cookie - * - * @param array $params - * @return mixed - * @see \Codeception\Lib\InnerBrowser::resetCookie() - */ - public function resetCookie($name, $params = null) { - return $this->getScenario()->runStep(new \Codeception\Step\Action('resetCookie', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks that the given element exists on the page and is visible. - * You can also specify expected attributes of this element. - * - * ``` php - * seeElement('.error'); - * $I->seeElement('//form/input[1]'); - * $I->seeElement('input', ['name' => 'login']); - * $I->seeElement('input', ['value' => '123456']); - * - * // strict locator in first arg, attributes in second - * $I->seeElement(['css' => 'form input'], ['name' => 'login']); - * ?> - * ``` - * - * @param $selector - * @param array $attributes - * @return - * Conditional Assertion: Test won't be stopped on fail - * @see \Codeception\Lib\InnerBrowser::seeElement() - */ - public function canSeeElement($selector, $attributes = null) { - return $this->getScenario()->runStep(new \Codeception\Step\ConditionalAssertion('seeElement', func_get_args())); - } - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks that the given element exists on the page and is visible. - * You can also specify expected attributes of this element. - * - * ``` php - * seeElement('.error'); - * $I->seeElement('//form/input[1]'); - * $I->seeElement('input', ['name' => 'login']); - * $I->seeElement('input', ['value' => '123456']); - * - * // strict locator in first arg, attributes in second - * $I->seeElement(['css' => 'form input'], ['name' => 'login']); - * ?> - * ``` - * - * @param $selector - * @param array $attributes - * @return - * @see \Codeception\Lib\InnerBrowser::seeElement() - */ - public function seeElement($selector, $attributes = null) { - return $this->getScenario()->runStep(new \Codeception\Step\Assertion('seeElement', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks that the given element is invisible or not present on the page. - * You can also specify expected attributes of this element. - * - * ``` php - * dontSeeElement('.error'); - * $I->dontSeeElement('//form/input[1]'); - * $I->dontSeeElement('input', ['name' => 'login']); - * $I->dontSeeElement('input', ['value' => '123456']); - * ?> - * ``` - * - * @param $selector - * @param array $attributes - * Conditional Assertion: Test won't be stopped on fail - * @see \Codeception\Lib\InnerBrowser::dontSeeElement() - */ - public function cantSeeElement($selector, $attributes = null) { - return $this->getScenario()->runStep(new \Codeception\Step\ConditionalAssertion('dontSeeElement', func_get_args())); - } - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks that the given element is invisible or not present on the page. - * You can also specify expected attributes of this element. - * - * ``` php - * dontSeeElement('.error'); - * $I->dontSeeElement('//form/input[1]'); - * $I->dontSeeElement('input', ['name' => 'login']); - * $I->dontSeeElement('input', ['value' => '123456']); - * ?> - * ``` - * - * @param $selector - * @param array $attributes - * @see \Codeception\Lib\InnerBrowser::dontSeeElement() - */ - public function dontSeeElement($selector, $attributes = null) { - return $this->getScenario()->runStep(new \Codeception\Step\Assertion('dontSeeElement', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks that there are a certain number of elements matched by the given locator on the page. - * - * ``` php - * seeNumberOfElements('tr', 10); - * $I->seeNumberOfElements('tr', [0,10]); //between 0 and 10 elements - * ?> - * ``` - * @param $selector - * @param mixed $expected : - * - string: strict number - * - array: range of numbers [0,10] - * Conditional Assertion: Test won't be stopped on fail - * @see \Codeception\Lib\InnerBrowser::seeNumberOfElements() - */ - public function canSeeNumberOfElements($selector, $expected) { - return $this->getScenario()->runStep(new \Codeception\Step\ConditionalAssertion('seeNumberOfElements', func_get_args())); - } - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks that there are a certain number of elements matched by the given locator on the page. - * - * ``` php - * seeNumberOfElements('tr', 10); - * $I->seeNumberOfElements('tr', [0,10]); //between 0 and 10 elements - * ?> - * ``` - * @param $selector - * @param mixed $expected : - * - string: strict number - * - array: range of numbers [0,10] - * @see \Codeception\Lib\InnerBrowser::seeNumberOfElements() - */ - public function seeNumberOfElements($selector, $expected) { - return $this->getScenario()->runStep(new \Codeception\Step\Assertion('seeNumberOfElements', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks that the given option is selected. - * - * ``` php - * seeOptionIsSelected('#form input[name=payment]', 'Visa'); - * ?> - * ``` - * - * @param $selector - * @param $optionText - * - * @return mixed - * Conditional Assertion: Test won't be stopped on fail - * @see \Codeception\Lib\InnerBrowser::seeOptionIsSelected() - */ - public function canSeeOptionIsSelected($selector, $optionText) { - return $this->getScenario()->runStep(new \Codeception\Step\ConditionalAssertion('seeOptionIsSelected', func_get_args())); - } - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks that the given option is selected. - * - * ``` php - * seeOptionIsSelected('#form input[name=payment]', 'Visa'); - * ?> - * ``` - * - * @param $selector - * @param $optionText - * - * @return mixed - * @see \Codeception\Lib\InnerBrowser::seeOptionIsSelected() - */ - public function seeOptionIsSelected($selector, $optionText) { - return $this->getScenario()->runStep(new \Codeception\Step\Assertion('seeOptionIsSelected', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks that the given option is not selected. - * - * ``` php - * dontSeeOptionIsSelected('#form input[name=payment]', 'Visa'); - * ?> - * ``` - * - * @param $selector - * @param $optionText - * - * @return mixed - * Conditional Assertion: Test won't be stopped on fail - * @see \Codeception\Lib\InnerBrowser::dontSeeOptionIsSelected() - */ - public function cantSeeOptionIsSelected($selector, $optionText) { - return $this->getScenario()->runStep(new \Codeception\Step\ConditionalAssertion('dontSeeOptionIsSelected', func_get_args())); - } - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks that the given option is not selected. - * - * ``` php - * dontSeeOptionIsSelected('#form input[name=payment]', 'Visa'); - * ?> - * ``` - * - * @param $selector - * @param $optionText - * - * @return mixed - * @see \Codeception\Lib\InnerBrowser::dontSeeOptionIsSelected() - */ - public function dontSeeOptionIsSelected($selector, $optionText) { - return $this->getScenario()->runStep(new \Codeception\Step\Assertion('dontSeeOptionIsSelected', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Asserts that current page has 404 response status code. - * Conditional Assertion: Test won't be stopped on fail - * @see \Codeception\Lib\InnerBrowser::seePageNotFound() - */ - public function canSeePageNotFound() { - return $this->getScenario()->runStep(new \Codeception\Step\ConditionalAssertion('seePageNotFound', func_get_args())); - } - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Asserts that current page has 404 response status code. - * @see \Codeception\Lib\InnerBrowser::seePageNotFound() - */ - public function seePageNotFound() { - return $this->getScenario()->runStep(new \Codeception\Step\Assertion('seePageNotFound', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks that response code is equal to value provided. - * - * @param $code - * - * @return mixed - * Conditional Assertion: Test won't be stopped on fail - * @see \Codeception\Lib\InnerBrowser::seeResponseCodeIs() - */ - public function canSeeResponseCodeIs($code) { - return $this->getScenario()->runStep(new \Codeception\Step\ConditionalAssertion('seeResponseCodeIs', func_get_args())); - } - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks that response code is equal to value provided. - * - * @param $code - * - * @return mixed - * @see \Codeception\Lib\InnerBrowser::seeResponseCodeIs() - */ - public function seeResponseCodeIs($code) { - return $this->getScenario()->runStep(new \Codeception\Step\Assertion('seeResponseCodeIs', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks that the page title contains the given string. - * - * ``` php - * seeInTitle('Blog - Post #1'); - * ?> - * ``` - * - * @param $title - * - * @return mixed - * Conditional Assertion: Test won't be stopped on fail - * @see \Codeception\Lib\InnerBrowser::seeInTitle() - */ - public function canSeeInTitle($title) { - return $this->getScenario()->runStep(new \Codeception\Step\ConditionalAssertion('seeInTitle', func_get_args())); - } - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks that the page title contains the given string. - * - * ``` php - * seeInTitle('Blog - Post #1'); - * ?> - * ``` - * - * @param $title - * - * @return mixed - * @see \Codeception\Lib\InnerBrowser::seeInTitle() - */ - public function seeInTitle($title) { - return $this->getScenario()->runStep(new \Codeception\Step\Assertion('seeInTitle', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks that the page title does not contain the given string. - * - * @param $title - * - * @return mixed - * Conditional Assertion: Test won't be stopped on fail - * @see \Codeception\Lib\InnerBrowser::dontSeeInTitle() - */ - public function cantSeeInTitle($title) { - return $this->getScenario()->runStep(new \Codeception\Step\ConditionalAssertion('dontSeeInTitle', func_get_args())); - } - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks that the page title does not contain the given string. - * - * @param $title - * - * @return mixed - * @see \Codeception\Lib\InnerBrowser::dontSeeInTitle() - */ - public function dontSeeInTitle($title) { - return $this->getScenario()->runStep(new \Codeception\Step\Assertion('dontSeeInTitle', func_get_args())); - } -} diff --git a/tests/_support/_generated/ApiTesterActions.php b/tests/_support/_generated/ApiTesterActions.php deleted file mode 100644 index 48a1129..0000000 --- a/tests/_support/_generated/ApiTesterActions.php +++ /dev/null @@ -1,905 +0,0 @@ -getScenario()->runStep(new \Codeception\Step\Action('haveHttpHeader', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks over the given HTTP header and (optionally) - * its value, asserting that are there - * - * @param $name - * @param $value - * @part json - * @part xml - * Conditional Assertion: Test won't be stopped on fail - * @see \Codeception\Module\REST::seeHttpHeader() - */ - public function canSeeHttpHeader($name, $value = null) { - return $this->getScenario()->runStep(new \Codeception\Step\ConditionalAssertion('seeHttpHeader', func_get_args())); - } - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks over the given HTTP header and (optionally) - * its value, asserting that are there - * - * @param $name - * @param $value - * @part json - * @part xml - * @see \Codeception\Module\REST::seeHttpHeader() - */ - public function seeHttpHeader($name, $value = null) { - return $this->getScenario()->runStep(new \Codeception\Step\Assertion('seeHttpHeader', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks over the given HTTP header and (optionally) - * its value, asserting that are not there - * - * @param $name - * @param $value - * @part json - * @part xml - * Conditional Assertion: Test won't be stopped on fail - * @see \Codeception\Module\REST::dontSeeHttpHeader() - */ - public function cantSeeHttpHeader($name, $value = null) { - return $this->getScenario()->runStep(new \Codeception\Step\ConditionalAssertion('dontSeeHttpHeader', func_get_args())); - } - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks over the given HTTP header and (optionally) - * its value, asserting that are not there - * - * @param $name - * @param $value - * @part json - * @part xml - * @see \Codeception\Module\REST::dontSeeHttpHeader() - */ - public function dontSeeHttpHeader($name, $value = null) { - return $this->getScenario()->runStep(new \Codeception\Step\Assertion('dontSeeHttpHeader', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks that http response header is received only once. - * HTTP RFC2616 allows multiple response headers with the same name. - * You can check that you didn't accidentally sent the same header twice. - * - * ``` php - * seeHttpHeaderOnce('Cache-Control'); - * ?>> - * ``` - * - * @param $name - * @part json - * @part xml - * Conditional Assertion: Test won't be stopped on fail - * @see \Codeception\Module\REST::seeHttpHeaderOnce() - */ - public function canSeeHttpHeaderOnce($name) { - return $this->getScenario()->runStep(new \Codeception\Step\ConditionalAssertion('seeHttpHeaderOnce', func_get_args())); - } - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks that http response header is received only once. - * HTTP RFC2616 allows multiple response headers with the same name. - * You can check that you didn't accidentally sent the same header twice. - * - * ``` php - * seeHttpHeaderOnce('Cache-Control'); - * ?>> - * ``` - * - * @param $name - * @part json - * @part xml - * @see \Codeception\Module\REST::seeHttpHeaderOnce() - */ - public function seeHttpHeaderOnce($name) { - return $this->getScenario()->runStep(new \Codeception\Step\Assertion('seeHttpHeaderOnce', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Returns the value of the specified header name - * - * @param $name - * @param Boolean $first Whether to return the first value or all header values - * - * @return string|array The first header value if $first is true, an array of values otherwise - * @part json - * @part xml - * @see \Codeception\Module\REST::grabHttpHeader() - */ - public function grabHttpHeader($name, $first = null) { - return $this->getScenario()->runStep(new \Codeception\Step\Action('grabHttpHeader', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Adds HTTP authentication via username/password. - * - * @param $username - * @param $password - * @part json - * @part xml - * @see \Codeception\Module\REST::amHttpAuthenticated() - */ - public function amHttpAuthenticated($username, $password) { - return $this->getScenario()->runStep(new \Codeception\Step\Condition('amHttpAuthenticated', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Adds Digest authentication via username/password. - * - * @param $username - * @param $password - * @part json - * @part xml - * @see \Codeception\Module\REST::amDigestAuthenticated() - */ - public function amDigestAuthenticated($username, $password) { - return $this->getScenario()->runStep(new \Codeception\Step\Condition('amDigestAuthenticated', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Adds Bearer authentication via access token. - * - * @param $accessToken - * @part json - * @part xml - * @see \Codeception\Module\REST::amBearerAuthenticated() - */ - public function amBearerAuthenticated($accessToken) { - return $this->getScenario()->runStep(new \Codeception\Step\Condition('amBearerAuthenticated', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Sends a POST request to given uri. - * - * Parameters and files (as array of filenames) can be provided. - * - * @param $url - * @param array|\JsonSerializable $params - * @param array $files - * @part json - * @part xml - * @see \Codeception\Module\REST::sendPOST() - */ - public function sendPOST($url, $params = null, $files = null) { - return $this->getScenario()->runStep(new \Codeception\Step\Action('sendPOST', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Sends a HEAD request to given uri. - * - * @param $url - * @param array $params - * @part json - * @part xml - * @see \Codeception\Module\REST::sendHEAD() - */ - public function sendHEAD($url, $params = null) { - return $this->getScenario()->runStep(new \Codeception\Step\Action('sendHEAD', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Sends an OPTIONS request to given uri. - * - * @param $url - * @param array $params - * @part json - * @part xml - * @see \Codeception\Module\REST::sendOPTIONS() - */ - public function sendOPTIONS($url, $params = null) { - return $this->getScenario()->runStep(new \Codeception\Step\Action('sendOPTIONS', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Sends a GET request to given uri. - * - * @param $url - * @param array $params - * @part json - * @part xml - * @see \Codeception\Module\REST::sendGET() - */ - public function sendGET($url, $params = null) { - return $this->getScenario()->runStep(new \Codeception\Step\Action('sendGET', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Sends PUT request to given uri. - * - * @param $url - * @param array $params - * @param array $files - * @part json - * @part xml - * @see \Codeception\Module\REST::sendPUT() - */ - public function sendPUT($url, $params = null, $files = null) { - return $this->getScenario()->runStep(new \Codeception\Step\Action('sendPUT', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Sends PATCH request to given uri. - * - * @param $url - * @param array $params - * @param array $files - * @part json - * @part xml - * @see \Codeception\Module\REST::sendPATCH() - */ - public function sendPATCH($url, $params = null, $files = null) { - return $this->getScenario()->runStep(new \Codeception\Step\Action('sendPATCH', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Sends DELETE request to given uri. - * - * @param $url - * @param array $params - * @param array $files - * @part json - * @part xml - * @see \Codeception\Module\REST::sendDELETE() - */ - public function sendDELETE($url, $params = null, $files = null) { - return $this->getScenario()->runStep(new \Codeception\Step\Action('sendDELETE', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Sends LINK request to given uri. - * - * @param $url - * @param array $linkEntries (entry is array with keys "uri" and "link-param") - * - * @link http://tools.ietf.org/html/rfc2068#section-19.6.2.4 - * - * @author samva.ua@gmail.com - * @part json - * @part xml - * @see \Codeception\Module\REST::sendLINK() - */ - public function sendLINK($url, $linkEntries) { - return $this->getScenario()->runStep(new \Codeception\Step\Action('sendLINK', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Sends UNLINK request to given uri. - * - * @param $url - * @param array $linkEntries (entry is array with keys "uri" and "link-param") - * @link http://tools.ietf.org/html/rfc2068#section-19.6.2.4 - * @author samva.ua@gmail.com - * @part json - * @part xml - * @see \Codeception\Module\REST::sendUNLINK() - */ - public function sendUNLINK($url, $linkEntries) { - return $this->getScenario()->runStep(new \Codeception\Step\Action('sendUNLINK', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks whether last response was valid JSON. - * This is done with json_last_error function. - * - * @part json - * Conditional Assertion: Test won't be stopped on fail - * @see \Codeception\Module\REST::seeResponseIsJson() - */ - public function canSeeResponseIsJson() { - return $this->getScenario()->runStep(new \Codeception\Step\ConditionalAssertion('seeResponseIsJson', func_get_args())); - } - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks whether last response was valid JSON. - * This is done with json_last_error function. - * - * @part json - * @see \Codeception\Module\REST::seeResponseIsJson() - */ - public function seeResponseIsJson() { - return $this->getScenario()->runStep(new \Codeception\Step\Assertion('seeResponseIsJson', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks whether the last response contains text. - * - * @param $text - * @part json - * @part xml - * Conditional Assertion: Test won't be stopped on fail - * @see \Codeception\Module\REST::seeResponseContains() - */ - public function canSeeResponseContains($text) { - return $this->getScenario()->runStep(new \Codeception\Step\ConditionalAssertion('seeResponseContains', func_get_args())); - } - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks whether the last response contains text. - * - * @param $text - * @part json - * @part xml - * @see \Codeception\Module\REST::seeResponseContains() - */ - public function seeResponseContains($text) { - return $this->getScenario()->runStep(new \Codeception\Step\Assertion('seeResponseContains', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks whether last response do not contain text. - * - * @param $text - * @part json - * @part xml - * Conditional Assertion: Test won't be stopped on fail - * @see \Codeception\Module\REST::dontSeeResponseContains() - */ - public function cantSeeResponseContains($text) { - return $this->getScenario()->runStep(new \Codeception\Step\ConditionalAssertion('dontSeeResponseContains', func_get_args())); - } - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks whether last response do not contain text. - * - * @param $text - * @part json - * @part xml - * @see \Codeception\Module\REST::dontSeeResponseContains() - */ - public function dontSeeResponseContains($text) { - return $this->getScenario()->runStep(new \Codeception\Step\Assertion('dontSeeResponseContains', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks whether the last JSON response contains provided array. - * The response is converted to array with json_decode($response, true) - * Thus, JSON is represented by associative array. - * This method matches that response array contains provided array. - * - * Examples: - * - * ``` php - * seeResponseContainsJson(array('name' => 'john')); - * - * // response {user: john, profile: { email: john@gmail.com }} - * $I->seeResponseContainsJson(array('email' => 'john@gmail.com')); - * - * ?> - * ``` - * - * This method recursively checks if one array can be found inside of another. - * - * @param array $json - * @part json - * Conditional Assertion: Test won't be stopped on fail - * @see \Codeception\Module\REST::seeResponseContainsJson() - */ - public function canSeeResponseContainsJson($json = null) { - return $this->getScenario()->runStep(new \Codeception\Step\ConditionalAssertion('seeResponseContainsJson', func_get_args())); - } - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks whether the last JSON response contains provided array. - * The response is converted to array with json_decode($response, true) - * Thus, JSON is represented by associative array. - * This method matches that response array contains provided array. - * - * Examples: - * - * ``` php - * seeResponseContainsJson(array('name' => 'john')); - * - * // response {user: john, profile: { email: john@gmail.com }} - * $I->seeResponseContainsJson(array('email' => 'john@gmail.com')); - * - * ?> - * ``` - * - * This method recursively checks if one array can be found inside of another. - * - * @param array $json - * @part json - * @see \Codeception\Module\REST::seeResponseContainsJson() - */ - public function seeResponseContainsJson($json = null) { - return $this->getScenario()->runStep(new \Codeception\Step\Assertion('seeResponseContainsJson', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Returns current response so that it can be used in next scenario steps. - * - * Example: - * - * ``` php - * grabResponse(); - * $I->sendPUT('/user', array('id' => $user_id, 'name' => 'davert')); - * ?> - * ``` - * - * @version 1.1 - * @return string - * @part json - * @part xml - * @see \Codeception\Module\REST::grabResponse() - */ - public function grabResponse() { - return $this->getScenario()->runStep(new \Codeception\Step\Action('grabResponse', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Returns data from the current JSON response using [JSONPath](http://goessner.net/articles/JsonPath/) as selector. - * JsonPath is XPath equivalent for querying Json structures. Try your JsonPath expressions [online](http://jsonpath.curiousconcept.com/). - * Even for a single value an array is returned. - * - * This method **require [`flow/jsonpath` > 0.2](https://github.com/FlowCommunications/JSONPath/) library to be installed**. - * - * Example: - * - * ``` php - * grabDataFromJsonResponse('$..users[0].id'); - * $I->sendPUT('/user', array('id' => $firstUser[0], 'name' => 'davert')); - * ?> - * ``` - * - * @param $jsonPath - * @return array - * @version 2.0.9 - * @throws \Exception - * @part json - * @see \Codeception\Module\REST::grabDataFromResponseByJsonPath() - */ - public function grabDataFromResponseByJsonPath($jsonPath) { - return $this->getScenario()->runStep(new \Codeception\Step\Action('grabDataFromResponseByJsonPath', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks if json structure in response matches the xpath provided. - * JSON is not supposed to be checked against XPath, yet it can be converted to xml and used with XPath. - * This assertion allows you to check the structure of response json. - * * - * ```json - * { "store": { - * "book": [ - * { "category": "reference", - * "author": "Nigel Rees", - * "title": "Sayings of the Century", - * "price": 8.95 - * }, - * { "category": "fiction", - * "author": "Evelyn Waugh", - * "title": "Sword of Honour", - * "price": 12.99 - * } - * ], - * "bicycle": { - * "color": "red", - * "price": 19.95 - * } - * } - * } - * ``` - * - * ```php - * seeResponseJsonMatchesXpath('//store/book/author'); - * // first book in store has author - * $I->seeResponseJsonMatchesXpath('//store/book[1]/author'); - * // at least one item in store has price - * $I->seeResponseJsonMatchesXpath('/store//price'); - * ?> - * ``` - * @part json - * @version 2.0.9 - * Conditional Assertion: Test won't be stopped on fail - * @see \Codeception\Module\REST::seeResponseJsonMatchesXpath() - */ - public function canSeeResponseJsonMatchesXpath($xpath) { - return $this->getScenario()->runStep(new \Codeception\Step\ConditionalAssertion('seeResponseJsonMatchesXpath', func_get_args())); - } - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks if json structure in response matches the xpath provided. - * JSON is not supposed to be checked against XPath, yet it can be converted to xml and used with XPath. - * This assertion allows you to check the structure of response json. - * * - * ```json - * { "store": { - * "book": [ - * { "category": "reference", - * "author": "Nigel Rees", - * "title": "Sayings of the Century", - * "price": 8.95 - * }, - * { "category": "fiction", - * "author": "Evelyn Waugh", - * "title": "Sword of Honour", - * "price": 12.99 - * } - * ], - * "bicycle": { - * "color": "red", - * "price": 19.95 - * } - * } - * } - * ``` - * - * ```php - * seeResponseJsonMatchesXpath('//store/book/author'); - * // first book in store has author - * $I->seeResponseJsonMatchesXpath('//store/book[1]/author'); - * // at least one item in store has price - * $I->seeResponseJsonMatchesXpath('/store//price'); - * ?> - * ``` - * @part json - * @version 2.0.9 - * @see \Codeception\Module\REST::seeResponseJsonMatchesXpath() - */ - public function seeResponseJsonMatchesXpath($xpath) { - return $this->getScenario()->runStep(new \Codeception\Step\Assertion('seeResponseJsonMatchesXpath', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks if json structure in response matches [JsonPath](http://goessner.net/articles/JsonPath/). - * JsonPath is XPath equivalent for querying Json structures. Try your JsonPath expressions [online](http://jsonpath.curiousconcept.com/). - * This assertion allows you to check the structure of response json. - * - * This method **require [`flow/jsonpath` > 0.2](https://github.com/FlowCommunications/JSONPath/) library to be installed**. - * - * ```json - * { "store": { - * "book": [ - * { "category": "reference", - * "author": "Nigel Rees", - * "title": "Sayings of the Century", - * "price": 8.95 - * }, - * { "category": "fiction", - * "author": "Evelyn Waugh", - * "title": "Sword of Honour", - * "price": 12.99 - * } - * ], - * "bicycle": { - * "color": "red", - * "price": 19.95 - * } - * } - * } - * ``` - * - * ```php - * seeResponseJsonMatchesJsonPath('$.store.book[*].author'); - * // first book in store has author - * $I->seeResponseJsonMatchesJsonPath('$.store.book[0].author'); - * // at least one item in store has price - * $I->seeResponseJsonMatchesJsonPath('$.store..price'); - * ?> - * ``` - * - * @part json - * @version 2.0.9 - * Conditional Assertion: Test won't be stopped on fail - * @see \Codeception\Module\REST::seeResponseJsonMatchesJsonPath() - */ - public function canSeeResponseJsonMatchesJsonPath($jsonPath) { - return $this->getScenario()->runStep(new \Codeception\Step\ConditionalAssertion('seeResponseJsonMatchesJsonPath', func_get_args())); - } - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks if json structure in response matches [JsonPath](http://goessner.net/articles/JsonPath/). - * JsonPath is XPath equivalent for querying Json structures. Try your JsonPath expressions [online](http://jsonpath.curiousconcept.com/). - * This assertion allows you to check the structure of response json. - * - * This method **require [`flow/jsonpath` > 0.2](https://github.com/FlowCommunications/JSONPath/) library to be installed**. - * - * ```json - * { "store": { - * "book": [ - * { "category": "reference", - * "author": "Nigel Rees", - * "title": "Sayings of the Century", - * "price": 8.95 - * }, - * { "category": "fiction", - * "author": "Evelyn Waugh", - * "title": "Sword of Honour", - * "price": 12.99 - * } - * ], - * "bicycle": { - * "color": "red", - * "price": 19.95 - * } - * } - * } - * ``` - * - * ```php - * seeResponseJsonMatchesJsonPath('$.store.book[*].author'); - * // first book in store has author - * $I->seeResponseJsonMatchesJsonPath('$.store.book[0].author'); - * // at least one item in store has price - * $I->seeResponseJsonMatchesJsonPath('$.store..price'); - * ?> - * ``` - * - * @part json - * @version 2.0.9 - * @see \Codeception\Module\REST::seeResponseJsonMatchesJsonPath() - */ - public function seeResponseJsonMatchesJsonPath($jsonPath) { - return $this->getScenario()->runStep(new \Codeception\Step\Assertion('seeResponseJsonMatchesJsonPath', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Opposite to seeResponseJsonMatchesJsonPath - * - * @param array $jsonPath - * @part json - * Conditional Assertion: Test won't be stopped on fail - * @see \Codeception\Module\REST::dontSeeResponseJsonMatchesJsonPath() - */ - public function cantSeeResponseJsonMatchesJsonPath($jsonPath) { - return $this->getScenario()->runStep(new \Codeception\Step\ConditionalAssertion('dontSeeResponseJsonMatchesJsonPath', func_get_args())); - } - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Opposite to seeResponseJsonMatchesJsonPath - * - * @param array $jsonPath - * @part json - * @see \Codeception\Module\REST::dontSeeResponseJsonMatchesJsonPath() - */ - public function dontSeeResponseJsonMatchesJsonPath($jsonPath) { - return $this->getScenario()->runStep(new \Codeception\Step\Assertion('dontSeeResponseJsonMatchesJsonPath', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Opposite to seeResponseContainsJson - * - * @part json - * @param array $json - * Conditional Assertion: Test won't be stopped on fail - * @see \Codeception\Module\REST::dontSeeResponseContainsJson() - */ - public function cantSeeResponseContainsJson($json = null) { - return $this->getScenario()->runStep(new \Codeception\Step\ConditionalAssertion('dontSeeResponseContainsJson', func_get_args())); - } - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Opposite to seeResponseContainsJson - * - * @part json - * @param array $json - * @see \Codeception\Module\REST::dontSeeResponseContainsJson() - */ - public function dontSeeResponseContainsJson($json = null) { - return $this->getScenario()->runStep(new \Codeception\Step\Assertion('dontSeeResponseContainsJson', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks if response is exactly the same as provided. - * - * @part json - * @part xml - * @param $response - * Conditional Assertion: Test won't be stopped on fail - * @see \Codeception\Module\REST::seeResponseEquals() - */ - public function canSeeResponseEquals($response) { - return $this->getScenario()->runStep(new \Codeception\Step\ConditionalAssertion('seeResponseEquals', func_get_args())); - } - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks if response is exactly the same as provided. - * - * @part json - * @part xml - * @param $response - * @see \Codeception\Module\REST::seeResponseEquals() - */ - public function seeResponseEquals($response) { - return $this->getScenario()->runStep(new \Codeception\Step\Assertion('seeResponseEquals', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks response code equals to provided value. - * - * @part json - * @part xml - * @param $code - * Conditional Assertion: Test won't be stopped on fail - * @see \Codeception\Module\REST::seeResponseCodeIs() - */ - public function canSeeResponseCodeIs($code) { - return $this->getScenario()->runStep(new \Codeception\Step\ConditionalAssertion('seeResponseCodeIs', func_get_args())); - } - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks response code equals to provided value. - * - * @part json - * @part xml - * @param $code - * @see \Codeception\Module\REST::seeResponseCodeIs() - */ - public function seeResponseCodeIs($code) { - return $this->getScenario()->runStep(new \Codeception\Step\Assertion('seeResponseCodeIs', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks that response code is not equal to provided value. - * - * @part json - * @part xml - * @param $code - * Conditional Assertion: Test won't be stopped on fail - * @see \Codeception\Module\REST::dontSeeResponseCodeIs() - */ - public function cantSeeResponseCodeIs($code) { - return $this->getScenario()->runStep(new \Codeception\Step\ConditionalAssertion('dontSeeResponseCodeIs', func_get_args())); - } - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks that response code is not equal to provided value. - * - * @part json - * @part xml - * @param $code - * @see \Codeception\Module\REST::dontSeeResponseCodeIs() - */ - public function dontSeeResponseCodeIs($code) { - return $this->getScenario()->runStep(new \Codeception\Step\Assertion('dontSeeResponseCodeIs', func_get_args())); - } -} diff --git a/tests/_support/_generated/FunctionalTesterActions.php b/tests/_support/_generated/FunctionalTesterActions.php deleted file mode 100644 index 86a9312..0000000 --- a/tests/_support/_generated/FunctionalTesterActions.php +++ /dev/null @@ -1,18 +0,0 @@ -getScenario()->runStep(new \Codeception\Step\Action('assertEquals', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks that two variables are not equal - * - * @param $expected - * @param $actual - * @param string $message - * @see \Codeception\Module\Asserts::assertNotEquals() - */ - public function assertNotEquals($expected, $actual, $message = null) { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertNotEquals', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks that two variables are same - * - * @param $expected - * @param $actual - * @param string $message - * - * @return mixed - * @see \Codeception\Module\Asserts::assertSame() - */ - public function assertSame($expected, $actual, $message = null) { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertSame', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks that two variables are not same - * - * @param $expected - * @param $actual - * @param string $message - * @see \Codeception\Module\Asserts::assertNotSame() - */ - public function assertNotSame($expected, $actual, $message = null) { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertNotSame', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks that actual is greater than expected - * - * @param $expected - * @param $actual - * @param string $message - * @see \Codeception\Module\Asserts::assertGreaterThan() - */ - public function assertGreaterThan($expected, $actual, $message = null) { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertGreaterThan', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * @deprecated - * @see \Codeception\Module\Asserts::assertGreaterThen() - */ - public function assertGreaterThen($expected, $actual, $message = null) { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertGreaterThen', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks that actual is greater or equal than expected - * - * @param $expected - * @param $actual - * @param string $message - * @see \Codeception\Module\Asserts::assertGreaterThanOrEqual() - */ - public function assertGreaterThanOrEqual($expected, $actual, $message = null) { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertGreaterThanOrEqual', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * @deprecated - * @see \Codeception\Module\Asserts::assertGreaterThenOrEqual() - */ - public function assertGreaterThenOrEqual($expected, $actual, $message = null) { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertGreaterThenOrEqual', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks that actual is less than expected - * - * @param $expected - * @param $actual - * @param string $message - * @see \Codeception\Module\Asserts::assertLessThan() - */ - public function assertLessThan($expected, $actual, $message = null) { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertLessThan', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks that actual is less or equal than expected - * - * @param $expected - * @param $actual - * @param string $message - * @see \Codeception\Module\Asserts::assertLessThanOrEqual() - */ - public function assertLessThanOrEqual($expected, $actual, $message = null) { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertLessThanOrEqual', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks that haystack contains needle - * - * @param $needle - * @param $haystack - * @param string $message - * @see \Codeception\Module\Asserts::assertContains() - */ - public function assertContains($needle, $haystack, $message = null) { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertContains', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks that haystack doesn't contain needle. - * - * @param $needle - * @param $haystack - * @param string $message - * @see \Codeception\Module\Asserts::assertNotContains() - */ - public function assertNotContains($needle, $haystack, $message = null) { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertNotContains', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks that string match with pattern - * - * @param string $pattern - * @param string $string - * @param string $message - * @see \Codeception\Module\Asserts::assertRegExp() - */ - public function assertRegExp($pattern, $string, $message = null) { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertRegExp', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks that string not match with pattern - * - * @param string $pattern - * @param string $string - * @param string $message - * @see \Codeception\Module\Asserts::assertNotRegExp() - */ - public function assertNotRegExp($pattern, $string, $message = null) { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertNotRegExp', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks that variable is empty. - * - * @param $actual - * @param string $message - * @see \Codeception\Module\Asserts::assertEmpty() - */ - public function assertEmpty($actual, $message = null) { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertEmpty', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks that variable is not empty. - * - * @param $actual - * @param string $message - * @see \Codeception\Module\Asserts::assertNotEmpty() - */ - public function assertNotEmpty($actual, $message = null) { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertNotEmpty', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks that variable is NULL - * - * @param $actual - * @param string $message - * @see \Codeception\Module\Asserts::assertNull() - */ - public function assertNull($actual, $message = null) { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertNull', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks that variable is not NULL - * - * @param $actual - * @param string $message - * @see \Codeception\Module\Asserts::assertNotNull() - */ - public function assertNotNull($actual, $message = null) { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertNotNull', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks that condition is positive. - * - * @param $condition - * @param string $message - * @see \Codeception\Module\Asserts::assertTrue() - */ - public function assertTrue($condition, $message = null) { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertTrue', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks that condition is negative. - * - * @param $condition - * @param string $message - * @see \Codeception\Module\Asserts::assertFalse() - */ - public function assertFalse($condition, $message = null) { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertFalse', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks if file exists - * - * @param string $filename - * @param string $message - * @see \Codeception\Module\Asserts::assertFileExists() - */ - public function assertFileExists($filename, $message = null) { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertFileExists', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Checks if file doesn't exist - * - * @param string $filename - * @param string $message - * @see \Codeception\Module\Asserts::assertFileNotExists() - */ - public function assertFileNotExists($filename, $message = null) { - return $this->getScenario()->runStep(new \Codeception\Step\Action('assertFileNotExists', func_get_args())); - } - - - /** - * [!] Method is generated. Documentation taken from corresponding module. - * - * Fails the test with message. - * - * @param $message - * @see \Codeception\Module\Asserts::fail() - */ - public function fail($message) { - return $this->getScenario()->runStep(new \Codeception\Step\Action('fail', func_get_args())); - } -} diff --git a/tests/brokerApi.suite.yml b/tests/brokerApi.suite.yml deleted file mode 100644 index fa84d78..0000000 --- a/tests/brokerApi.suite.yml +++ /dev/null @@ -1,7 +0,0 @@ -class_name: ServerApiTester -modules: - enabled: - - REST: - url: http://127.0.0.1:9001/examples/ajax-broker/ajax.php - depends: PhpBrowser - part: Json \ No newline at end of file diff --git a/tests/brokerApi/BrokerTesterCept.php b/tests/brokerApi/BrokerTesterCept.php deleted file mode 100644 index 86e7eea..0000000 --- a/tests/brokerApi/BrokerTesterCept.php +++ /dev/null @@ -1,54 +0,0 @@ -wantTo('login through broker and view user data'); -$I->sendServerRequest('getUserInfo'); -$I->seeResponseIsJson(); -$I->seeResponseCodeIs(200); -$I->seeResponseEquals('null'); - -$I->sendServerRequest('attach'); -$I->seeResponseIsJson(); - -$I->sendServerRequest('getUserInfo'); -$I->seeResponseIsJson(); -$I->seeResponseCodeIs(200); -$I->seeResponseEquals('null'); - - -$I->sendServerRequest('login', [ - 'password' => $username, - 'username' => $password -]); -$I->seeResponseCodeIs(200); -$I->seeResponseIsJson(['token' => $token]); - -$I->sendServerRequest('getUserInfo'); -$I->seeResponseCodeIs(200); -$I->seeResponseIsJson(); -$I->seeResponseContainsJson([ - 'fullname' => 'jackie', - 'email' => 'jackie@admin.com', - 'username' => 'admin' -]); - -$I->sendServerRequest('detach'); -$I->sendServerRequest('attach'); - -$I->sendServerRequest('getUserInfo'); -$I->seeResponseCodeIs(200); -$I->seeResponseIsJson(); -$I->seeResponseContainsJson([ - 'fullname' => 'jackie', - 'email' => 'jackie@admin.com', - 'username' => 'admin' -]); - -$I->sendServerRequest('logout'); -$I->seeResponseCodeIs(200); -$I->seeResponseIsJson('null'); \ No newline at end of file diff --git a/tests/brokerApi/_bootstrap.php b/tests/brokerApi/_bootstrap.php deleted file mode 100644 index 8a88555..0000000 --- a/tests/brokerApi/_bootstrap.php +++ /dev/null @@ -1,2 +0,0 @@ -wantTo("login at broker 1 and see I'm also logged in at broker 2"); + +// --- +$I->amGoingTo("login at Alice (broker 1)"); + +$I->amOnBroker(1); +$I->see('Alice'); +$I->see('Logged out'); + +$I->click('Login'); +$I->seeElement('form', ['action' => 'login.php']); +$I->submitForm('form', [ + 'username' => 'john', + 'password' => 'john123' +]); + +$I->see('Logged in'); +$I->see('John Doe'); +$I->see('john.doe@example.com'); + +// --- +$I->amGoingTo("visit Greg (broker 2)"); +$I->expect("john to be logged in through SSO"); + +$I->amOnBroker(2); +$I->see('Greg'); + +$I->see('Logged in'); +$I->see('John Doe'); +$I->see('john.doe@example.com'); + +// --- +$I->amGoingTo("logout at Greg (broker 2)"); + +$I->amOnBroker(2); +$I->see('Greg'); + +$I->click('Logout'); +$I->see('Logged out'); + +// --- +$I->amGoingTo("visit Alice (broker 1)"); +$I->expect("john to be logged out through SSO"); + +$I->amOnBroker(1); +$I->see('Alice'); + +$I->see('Logged out'); diff --git a/tests/serverApi.suite.yml b/tests/serverApi.suite.yml deleted file mode 100644 index 18d8b3b..0000000 --- a/tests/serverApi.suite.yml +++ /dev/null @@ -1,7 +0,0 @@ -class_name: ServerApiTester -modules: - enabled: - - REST: - url: http://127.0.0.1:9000/examples/server - depends: PhpBrowser - part: Json \ No newline at end of file diff --git a/tests/serverApi/ServerApiCept.php b/tests/serverApi/ServerApiCept.php deleted file mode 100644 index 3e98112..0000000 --- a/tests/serverApi/ServerApiCept.php +++ /dev/null @@ -1,57 +0,0 @@ -defaultArgs = [ - 'token' => $token, - 'broker' => $broker, 'checksum' => $checksum, - 'PHPSESSID' => 'SSO-ServerApi-hello_world-0949c41dd2c747f8e1d4bfd85dd2f4d8' -]; - -$I->wantTo('attach session and view user info and logout'); -$I->sendServerRequest('attach', ['PHPSESSID' => '']); -$I->seeResponseIsJson(); -$I->seeResponseCodeIs(200); -$I->seeResponseContainsJson(['token' => $token]); - -$I->sendServerRequest('userInfo'); -$I->seeResponseCodeIs(401); -$I->seeResponseIsJson(); -$I->seeResponseContainsJson(['error' => 'Not logged in']); - -$I->sendServerRequest('login', [ - 'password' => 'wrong', - 'username' => 'wrong' -]); - -$I->seeResponseCodeIs(401); -$I->seeResponseIsJson(['error' => 'Incorrect credentials']); - -$I->sendServerRequest('login', [ - 'password' => $username, - 'username' => $password -]); -$I->seeResponseCodeIs(200); -$I->seeResponseIsJson(['token' => $token]); - -$I->sendServerRequest('userInfo'); -$I->seeResponseCodeIs(200); -$I->seeResponseIsJson(); -$I->seeResponseContainsJson([ - 'fullname' => 'jackie', - 'email' => 'jackie@admin.com', - 'username' => 'admin' -]); - -$I->sendServerRequest('logout'); -$I->seeResponseCodeIs(200); -$I->seeResponseIsJson(); - -$I->sendServerRequest('userInfo'); -$I->seeResponseCodeIs(401); -$I->seeResponseIsJson(); -$I->seeResponseContainsJson(['error' => 'Not logged in']); \ No newline at end of file diff --git a/tests/serverApi/_bootstrap.php b/tests/serverApi/_bootstrap.php deleted file mode 100644 index 8a88555..0000000 --- a/tests/serverApi/_bootstrap.php +++ /dev/null @@ -1,2 +0,0 @@ -session = new \ArrayObject(); + $this->curl = $this->createMock(Curl::class); + + $this->broker = (new Broker('https://example.com/attach', 'foo', 'bar')) + ->withTokenIn($this->session) + ->withCurl($this->curl); + } + + public function testUrlValidationInConstruct() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage("Invalid SSO server URL 'example'"); + + new Broker('example', 'foo', 'bar'); + } + + public function testBrokerIdValidationInConstruct() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage("Invalid broker id 'foo-1': must be alphanumeric"); + + new Broker('https://example.com', 'foo-1', 'bar'); + } + + public function testGetBrokerId() + { + $this->assertEquals('foo', $this->broker->getBrokerId()); + } + + public function testGetAttachUrl() + { + $url = $this->broker->getAttachUrl(); + + $this->assertArrayHasKey('sso_token_foo', $this->session); + + $token = $this->session["sso_token_foo"]; + $checksum = $this->generateChecksum('attach', 'bar', $token); + + $this->assertEquals("https://example.com/attach?broker=foo&token=$token&checksum=$checksum", $url); + $this->assertFalse($this->broker->isAttached()); + } + + public function testGetAttachUrlWithParams() + { + $url = $this->broker->getAttachUrl([ + 'return_url' => 'http://broker.example.com/', + 'color' => 'red', + ]); + + $this->assertArrayHasKey('sso_token_foo', $this->session); + + $token = $this->session["sso_token_foo"]; + $checksum = $this->generateChecksum('attach', 'bar', $token); + + $expectedUrl = "https://example.com/attach?broker=foo&token=$token&checksum=$checksum&return_url=" + . urlencode('http://broker.example.com/') . '&color=red'; + $this->assertEquals($expectedUrl, $url); + } + + public function testVerify() + { + $this->session['sso_token_foo'] = '123456'; + + $this->assertFalse($this->broker->isAttached()); + + $code = $this->getVerificationCode('foo', '123456', 'abc123'); + $this->broker->verify($code); + + $this->assertArrayHasKey('sso_verify_foo', $this->session); + $this->assertEquals($code, $this->session['sso_verify_foo']); + $this->assertTrue($this->broker->isAttached()); + } + + public function testVerifyIsIdempotent() + { + $code = $this->getVerificationCode('foo', '123456', 'abc123'); + + $this->session['sso_token_foo'] = '123456'; + $this->session['sso_verify_foo'] = $code; + + $this->broker->verify($code); + + $this->assertArrayHasKey('sso_verify_foo', $this->session); + $this->assertEquals($code, $this->session['sso_verify_foo']); + } + + public function testVerifyIsImmutable() + { + $this->session['sso_token_foo'] = '123456'; + $this->session['sso_verify_foo'] = '000000'; + + $code = $this->getVerificationCode('foo', '123456', 'abc123'); + + $this->expectWarningMessage("SSO attach already verified"); + + $this->broker->verify($code); + + $this->assertArrayHasKey('sso_verify_foo', $this->session); + $this->assertEquals('000000', $this->session['sso_verify_foo']); + } + + public function testClearToken() + { + $this->session['sso_token_foo'] = '123456'; + $this->session['sso_verify_foo'] = $this->getVerificationCode('foo', '123456', 'abc123'); + + $this->assertTrue($this->broker->isAttached()); + + $this->broker->clearToken(); + + $this->assertFalse($this->broker->isAttached()); + $this->assertArrayNotHasKey('sso_token_foo', $this->session); + $this->assertArrayNotHasKey('sso_verify_foo', $this->session); + } +} diff --git a/tests/unit/Broker/RequestTest.php b/tests/unit/Broker/RequestTest.php new file mode 100644 index 0000000..c68327b --- /dev/null +++ b/tests/unit/Broker/RequestTest.php @@ -0,0 +1,198 @@ +session = new \ArrayObject([ + 'sso_token_foo' => '123456', + 'sso_verify_foo' => $this->getVerificationCode('foo', '123456', 'abc123'), + ]); + $this->curl = $this->createMock(Curl::class); + + $this->broker = (new Broker('https://example.com/attach', 'foo', 'bar')) + ->withTokenIn($this->session) + ->withCurl($this->curl); + } + + public function testGetBearerToken() + { + $this->assertTrue($this->broker->isAttached()); + + $bearer = $this->broker->getBearerToken(); + + $this->assertEquals( + $this->getBearerToken('foo', 'bar', '123456', 'abc123'), + $bearer + ); + } + + public function testGetBearerTokenWhenNotAttached() + { + unset($this->session['sso_verify_foo']); + + $this->assertFalse($this->broker->isAttached()); + + $this->expectException(NotAttachedException::class); + $this->expectExceptionMessage("The client isn't attached to the SSO server for this broker. " + . "Make sure that the 'sso_verify_foo' cookie is set."); + + $this->broker->getBearerToken(); + } + + + public function testGetRequest() + { + $headers = [ + 'Accept: application/json', + 'Authorization: Bearer ' . $this->getBearerToken('foo', 'bar', '123456', 'abc123') + ]; + $this->curl->expects($this->once())->method('request') + ->with('GET', 'https://example.com/info', $headers, '') + ->willReturn([ + 'httpCode' => 200, + 'contentType' => 'application/json; charset=utf-8', + 'body' => '{"name": "John", "email": "john@example.com"}', + ]); + + $info = $this->broker->request('GET', '/info'); + + $this->assertEquals(['name' => 'John', 'email' => 'john@example.com'], $info); + } + + public function testPostRequest() + { + $headers = [ + 'Accept: application/json', + 'Authorization: Bearer ' . $this->getBearerToken('foo', 'bar', '123456', 'abc123') + ]; + $this->curl->expects($this->once())->method('request') + ->with('POST', 'https://example.com/user', $headers, ['name' => 'John', 'color' => 'red']) + ->willReturn([ + 'httpCode' => 200, + 'contentType' => 'application/json; charset=utf-8', + 'body' => '{"name": "John", "email": "john@example.com"}', + ]); + + $info = $this->broker->request('POST', '/user', ['name' => 'John', 'color' => 'red']); + + $this->assertEquals(['name' => 'John', 'email' => 'john@example.com'], $info); + } + + public function testNoContent() + { + $headers = [ + 'Accept: application/json', + 'Authorization: Bearer ' . $this->getBearerToken('foo', 'bar', '123456', 'abc123') + ]; + $this->curl->expects($this->once())->method('request') + ->with('POST', 'https://example.com/go', $headers, '') + ->willReturn([ + 'httpCode' => 204, + 'contentType' => '', + 'body' => '', + ]); + + $info = $this->broker->request('POST', '/go'); + + $this->assertNull($info); + } + + public function testBadRequest() + { + $headers = [ + 'Accept: application/json', + 'Authorization: Bearer ' . $this->getBearerToken('foo', 'bar', '123456', 'abc123') + ]; + $this->curl->expects($this->once())->method('request') + ->with('GET', 'https://example.com/', $headers, '') + ->willReturn([ + 'httpCode' => 400, + 'contentType' => 'application/json', + 'body' => '{"error": "something is wrong"}', + ]); + + $this->expectException(RequestException::class); + $this->expectExceptionMessage("something is wrong"); + + $this->broker->request('GET', '/'); + } + + public function testInvalidContentType() + { + $headers = [ + 'Accept: application/json', + 'Authorization: Bearer ' . $this->getBearerToken('foo', 'bar', '123456', 'abc123') + ]; + $this->curl->expects($this->once())->method('request') + ->with('GET', 'https://example.com/', $headers, '') + ->willReturn([ + 'httpCode' => 200, + 'contentType' => 'text/html', + 'body' => '

Foo

', + ]); + + $this->expectException(RequestException::class); + $this->expectExceptionMessage("Expected 'application/json' response, got 'text/html'"); + + $this->broker->request('GET', '/'); + } + + public function testInvalidJson() + { + $headers = [ + 'Accept: application/json', + 'Authorization: Bearer ' . $this->getBearerToken('foo', 'bar', '123456', 'abc123') + ]; + $this->curl->expects($this->once())->method('request') + ->with('GET', 'https://example.com/', $headers, '') + ->willReturn([ + 'httpCode' => 200, + 'contentType' => 'application/json', + 'body' => 'not json', + ]); + + $this->expectException(RequestException::class); + $this->expectExceptionMessage("Invalid JSON response from server"); + + $this->broker->request('GET', '/'); + } +} diff --git a/tests/unit/Server/AttachTest.php b/tests/unit/Server/AttachTest.php new file mode 100644 index 0000000..3935549 --- /dev/null +++ b/tests/unit/Server/AttachTest.php @@ -0,0 +1,386 @@ +createCallbackMock( + $this->atLeastOnce(), + ['foo'], + ['secret' => 'bar', 'domains' => ['broker.example.com']] + ); + + $cache = $this->createMock(CacheInterface::class); + + $request = (new ServerRequest()) + ->withUri(new Uri("https://server.example.com/attach.php")) + ->withQueryParams([ + 'broker' => 'foo', + 'checksum' => $this->generateChecksum('attach', 'bar', '123456'), + 'token' => '123456', + 'return_url' => 'https://broker.example.com/attached' + ]) + ->withHeader('Referer', 'https://broker.example.com/login') + ->withHeader('Origin', 'https://broker.example.com/'); + + $session = $this->createMock(SessionInterface::class); + $logger = $this->createMock(LoggerInterface::class); + + $server = (new Server($callback, $cache)) + ->withSession($session) + ->withLogger($logger); + + $session->expects($this->once())->method('start')->id('start'); + $session->expects($this->any())->method('getId')->after('start')->willReturn('abc123'); + + $cache->expects($this->once())->method('get') + ->with('SSO-foo-123456') + ->willReturn(null); + $cache->expects($this->once())->method('set') + ->with('SSO-foo-123456', 'abc123') + ->willReturn(true); + + $logger->expects($this->once())->method('info') + ->with( + "Attached broker token to session", + ['broker' => 'foo', 'token' => '123456', 'session' => 'abc123'] + ); + + $code = $server->attach($request); + + $this->assertEquals( + $this->getVerificationCode('foo', '123456', 'abc123'), + $code + ); + } + + public function missingQueryParameterProvider() + { + return [ + 'broker' => ['broker'], + 'checksum' => ['checksum'], + 'token' => ['token'], + ]; + } + + /** + * @dataProvider missingQueryParameterProvider + */ + public function testMissingQueryParameter(string $key) + { + $callback = $this->createCallbackMock($this->never()); + + $cache = $this->createMock(CacheInterface::class); + + $queryParams = [ + 'broker' => 'foo', + 'checksum' => $this->generateChecksum('attach', 'bar', '123456'), + 'token' => '123456', + 'return_url' => 'https://return_url.example.com/' + ]; + + $request = (new ServerRequest()) + ->withUri(new Uri("https://server.example.com/attach.php")) + ->withQueryParams(array_without($queryParams, [$key])) + ->withHeader('Referer', 'https://referer.example.com/') + ->withHeader('Origin', 'https://origin.example.com/'); + + $session = $this->createMock(SessionInterface::class); + $logger = $this->createMock(LoggerInterface::class); + + $server = (new Server($callback, $cache)) + ->withSession($session) + ->withLogger($logger); + + $session->expects($this->never())->method('start'); + + $cache->expects($this->never())->method('get'); + $cache->expects($this->never())->method('set'); + + $this->expectException(BrokerException::class); + $this->expectExceptionMessage("Missing '$key' query parameter"); + + $server->attach($request); + } + + public function domainProvider() + { + return [ + 'return_url' => ['return_url', ['origin.example.com', 'referer.example.com']], + 'origin' => ['origin', ['referer.example.com', 'return_url.example.com']], + 'referer' => ['referer', ['origin.example.com', 'return_url.example.com']], + ]; + } + + /** + * @dataProvider domainProvider + */ + public function testInvalidDomain(string $type, array $domains) + { + $callback = $this->createCallbackMock( + $this->atLeastOnce(), + ['foo'], + ['secret' => 'bar', 'domains' => $domains] + ); + + $cache = $this->createMock(CacheInterface::class); + + $request = (new ServerRequest()) + ->withUri(new Uri("https://server.example.com/attach.php")) + ->withQueryParams([ + 'broker' => 'foo', + 'checksum' => $this->generateChecksum('attach', 'bar', '123456'), + 'token' => '123456', + 'return_url' => 'https://return_url.example.com/' + ]) + ->withHeader('Referer', 'https://referer.example.com/') + ->withHeader('Origin', 'https://origin.example.com/'); + + $session = $this->createMock(SessionInterface::class); + $logger = $this->createMock(LoggerInterface::class); + + $server = (new Server($callback, $cache)) + ->withSession($session) + ->withLogger($logger); + + $session->expects($this->never())->method('start'); + + $cache->expects($this->never())->method('get'); + $cache->expects($this->never())->method('set'); + + $logger->expects($this->once())->method('warning') + ->with( + "Domain of $type is not allowed for broker", + [$type => "https://$type.example.com/", 'broker' => 'foo', 'token' => '123456'] + ); + + $this->expectException(BrokerException::class); + $this->expectExceptionMessage("Domain of $type is not allowed"); + + $server->attach($request); + } + + public function testInvalidChecksum() + { + $callback = $this->createCallbackMock($this->atLeastOnce(), ['foo'], ['secret' => 'bar']); + + $cache = $this->createMock(CacheInterface::class); + + $request = (new ServerRequest()) + ->withUri(new Uri("https://server.example.com/attach.php")) + ->withQueryParams([ + 'broker' => 'foo', + 'checksum' => '0000000000', + 'token' => '123456' + ]); + + $session = $this->createMock(SessionInterface::class); + $logger = $this->createMock(LoggerInterface::class); + + $server = (new Server($callback, $cache)) + ->withSession($session) + ->withLogger($logger); + + $cache->expects($this->never())->method('get'); + $cache->expects($this->never())->method('set'); + + $checksum = $this->generateChecksum('attach', 'bar', '123456'); + $logger->expects($this->once())->method('warning') + ->with( + "Invalid attach checksum", + ['expected' => $checksum, 'received' => '0000000000', 'broker' => 'foo', 'token' => '123456'] + ); + + $this->expectException(BrokerException::class); + $this->expectExceptionMessage("Invalid attach checksum"); + + $server->attach($request); + } + + public function testUnknownBroker() + { + $callback = $this->createCallbackMock($this->atLeastOnce(), ['foo'], null); + + $cache = $this->createMock(CacheInterface::class); + + $request = (new ServerRequest()) + ->withUri(new Uri("https://server.example.com/attach.php")) + ->withQueryParams([ + 'broker' => 'foo', + 'checksum' => $this->generateChecksum('attach', 'bar', '123456'), + 'token' => '123456' + ]); + + $session = $this->createMock(SessionInterface::class); + $logger = $this->createMock(LoggerInterface::class); + + $server = (new Server($callback, $cache)) + ->withSession($session) + ->withLogger($logger); + + $cache->expects($this->never())->method('get'); + $cache->expects($this->never())->method('set'); + + $logger->expects($this->once())->method('warning') + ->with("Unknown broker", ['broker' => 'foo', 'token' => '123456']); + + $this->expectException(BrokerException::class); + $this->expectExceptionMessage("Broker is unknown or disabled"); + + $server->attach($request); + } + + public function testAlreadyAttached() + { + $callback = $this->createCallbackMock($this->atLeastOnce(), ['foo'], ['secret' => 'bar']); + + $cache = $this->createMock(CacheInterface::class); + + $request = (new ServerRequest()) + ->withUri(new Uri("https://server.example.com/attach.php")) + ->withQueryParams([ + 'broker' => 'foo', + 'checksum' => $this->generateChecksum('attach', 'bar', '123456'), + 'token' => '123456' + ]); + + $session = $this->createMock(SessionInterface::class); + $logger = $this->createMock(LoggerInterface::class); + + $server = (new Server($callback, $cache)) + ->withSession($session) + ->withLogger($logger); + + $session->expects($this->once())->method('start')->id('start'); + $session->expects($this->any())->method('getId')->after('start')->willReturn('abc123'); + + $cache->expects($this->once())->method('get') + ->with('SSO-foo-123456') + ->willReturn('xyz543'); + $cache->expects($this->never())->method('set'); + + $logger->expects($this->once())->method('warning') + ->with( + "Token is already attached", + ['broker' => 'foo', 'token' => '123456', 'attached_to' => 'xyz543', 'session' => 'abc123'] + ); + + $this->expectException(BrokerException::class); + $this->expectExceptionMessage("Token is already attached"); + + $server->attach($request); + } + + public function testAttachIsIdempotent() + { + $callback = $this->createCallbackMock( + $this->atLeastOnce(), + ['foo'], + ['secret' => 'bar', 'domains' => ['broker.example.com']] + ); + + $cache = $this->createMock(CacheInterface::class); + + $request = (new ServerRequest()) + ->withUri(new Uri("https://server.example.com/attach.php")) + ->withQueryParams([ + 'broker' => 'foo', + 'checksum' => $this->generateChecksum('attach', 'bar', '123456'), + 'token' => '123456', + 'return_url' => 'https://broker.example.com/attached' + ]) + ->withHeader('Referer', 'https://broker.example.com/login') + ->withHeader('Origin', 'https://broker.example.com/'); + + $session = $this->createMock(SessionInterface::class); + + $server = (new Server($callback, $cache)) + ->withSession($session); + + $session->expects($this->once())->method('start')->id('start'); + $session->expects($this->any())->method('getId')->after('start')->willReturn('abc123'); + + $cache->expects($this->once())->method('get') + ->with('SSO-foo-123456') + ->willReturn('abc123'); + $cache->expects($this->once())->method('set') + ->with('SSO-foo-123456', 'abc123') + ->willReturn(true); + + $code = $server->attach($request); + + $this->assertEquals( + $this->getVerificationCode('foo', '123456', 'abc123'), + $code + ); + } + + public function testCacheIssue() + { + $callback = $this->createCallbackMock($this->atLeastOnce(), ['foo'], ['secret' => 'bar']); + + $cache = $this->createMock(CacheInterface::class); + + $request = (new ServerRequest()) + ->withUri(new Uri("https://server.example.com/attach.php")) + ->withQueryParams([ + 'broker' => 'foo', + 'checksum' => $this->generateChecksum('attach', 'bar', '123456'), + 'token' => '123456' + ]); + + $session = $this->createMock(SessionInterface::class); + $logger = $this->createMock(LoggerInterface::class); + + $server = (new Server($callback, $cache)) + ->withSession($session) + ->withLogger($logger); + + $session->expects($this->once())->method('start')->id('start'); + $session->expects($this->any())->method('getId')->after('start')->willReturn('abc123'); + + $cache->expects($this->once())->method('get') + ->with('SSO-foo-123456') + ->willReturn(null); + $cache->expects($this->once())->method('set') + ->with('SSO-foo-123456', 'abc123') + ->willReturn(false); + + $logger->expects($this->once())->method('error') + ->with( + "Failed to attach bearer token to session id due to cache issue", + ['broker' => 'foo', 'token' => '123456', 'session' => 'abc123'] + ); + + $this->expectException(ServerException::class); + $this->expectExceptionMessage("Failed to attach bearer token to session id"); + + $server->attach($request); + } +} diff --git a/tests/unit/Server/BrokerSessionTest.php b/tests/unit/Server/BrokerSessionTest.php new file mode 100644 index 0000000..281247b --- /dev/null +++ b/tests/unit/Server/BrokerSessionTest.php @@ -0,0 +1,298 @@ +createCallbackMock($this->atLeastOnce(), ['foo'], ['secret' => 'bar']); + + $cache = $this->createMock(CacheInterface::class); + + $bearer = $this->getBearerToken('foo', 'bar', '123456', 'abc123'); + $request = (new ServerRequest()) + ->withUri(new Uri("https://server.example.com/attach.php")) + ->withHeader('Authorization', "Bearer $bearer"); + + $session = $this->createMock(SessionInterface::class); + $logger = $this->createMock(LoggerInterface::class); + + $server = (new Server($callback, $cache)) + ->withSession($session) + ->withLogger($logger); + + $cache->expects($this->once())->method('get') + ->with('SSO-foo-123456') + ->willReturn('abc123'); + + $session->expects($this->once())->method('isActive')->willReturn(false); + $session->expects($this->once())->method('resume')->with('abc123'); + + $logger->expects($this->once())->method('debug') + ->with( + "Broker request with session", + ['broker' => 'foo', 'token' => '123456', 'session' => 'abc123'] + ); + + $server->startBrokerSession($request); + } + + public function testSessionAlreadyStarted() + { + $callback = $this->createCallbackMock($this->never()); + + $cache = $this->createMock(CacheInterface::class); + + $bearer = $this->getBearerToken('foo', 'bar', '123456', 'abc123'); + $request = (new ServerRequest()) + ->withUri(new Uri("https://server.example.com/attach.php")) + ->withHeader('Authorization', "Bearer $bearer"); + + $session = $this->createMock(SessionInterface::class); + $logger = $this->createMock(LoggerInterface::class); + + $server = (new Server($callback, $cache)) + ->withSession($session) + ->withLogger($logger); + + $session->expects($this->once())->method('isActive')->willReturn(true); + $session->expects($this->never())->method('start'); + + $this->expectException(ServerException::class); + $this->expectExceptionMessage("Session is already started"); + + $server->startBrokerSession($request); + } + + public function testMissingAuthorizationHeader() + { + $callback = $this->createCallbackMock($this->never()); + + $cache = $this->createMock(CacheInterface::class); + + $request = (new ServerRequest()) + ->withUri(new Uri("https://server.example.com/attach.php")); + + $session = $this->createMock(SessionInterface::class); + $logger = $this->createMock(LoggerInterface::class); + + $server = (new Server($callback, $cache)) + ->withSession($session) + ->withLogger($logger); + + $session->expects($this->once())->method('isActive')->willReturn(false); + $session->expects($this->never())->method('start'); + + $logger->expects($this->once())->method('warning') + ->with("Broker didn't use bearer authentication: No 'Authorization' header"); + + $this->expectException(BrokerException::class); + $this->expectExceptionMessage("Broker didn't use bearer authentication"); + + $server->startBrokerSession($request); + } + + public function testNoBearerAuthorization() + { + $callback = $this->createCallbackMock($this->never()); + + $cache = $this->createMock(CacheInterface::class); + + $request = (new ServerRequest()) + ->withUri(new Uri("https://server.example.com/attach.php")) + ->withHeader("Authorization", "Basic dXNlcjpwYXNz"); + + $session = $this->createMock(SessionInterface::class); + $logger = $this->createMock(LoggerInterface::class); + + $server = (new Server($callback, $cache)) + ->withSession($session) + ->withLogger($logger); + + $session->expects($this->once())->method('isActive')->willReturn(false); + $session->expects($this->never())->method('start'); + + $logger->expects($this->once())->method('warning') + ->with("Broker didn't use bearer authentication: Basic authorization used"); + + $this->expectException(BrokerException::class); + $this->expectExceptionMessage("Broker didn't use bearer authentication"); + + $server->startBrokerSession($request); + } + + public function testInvalidBearerToken() + { + $callback = $this->createCallbackMock($this->never()); + + $cache = $this->createMock(CacheInterface::class); + + $request = (new ServerRequest()) + ->withUri(new Uri("https://server.example.com/attach.php")) + ->withHeader('Authorization', "Bearer 000000"); + + $session = $this->createMock(SessionInterface::class); + $logger = $this->createMock(LoggerInterface::class); + + $server = (new Server($callback, $cache)) + ->withSession($session) + ->withLogger($logger); + + $session->expects($this->once())->method('isActive')->willReturn(false); + $session->expects($this->never())->method('start'); + $session->expects($this->never())->method('resume'); + + $logger->expects($this->once())->method('warning') + ->with("Invalid bearer token", ['bearer' => '000000']); + + $this->expectException(BrokerException::class); + $this->expectExceptionCode(403); + $this->expectExceptionMessage("Invalid bearer token"); + + $server->startBrokerSession($request); + } + + public function testInvalidChecksum() + { + $callback = $this->createCallbackMock($this->atLeastOnce(), ['foo'], ['secret' => 'bar']); + + $cache = $this->createMock(CacheInterface::class); + + $request = (new ServerRequest()) + ->withUri(new Uri("https://server.example.com/attach.php")) + ->withHeader('Authorization', "Bearer SSO-foo-123456-000000"); + + $session = $this->createMock(SessionInterface::class); + $logger = $this->createMock(LoggerInterface::class); + + $server = (new Server($callback, $cache)) + ->withSession($session) + ->withLogger($logger); + + $cache->expects($this->once())->method('get') + ->with('SSO-foo-123456') + ->willReturn('abc123'); + + $session->expects($this->once())->method('isActive')->willReturn(false); + $session->expects($this->never())->method('start'); + + $bearer = $this->getBearerToken('foo', 'bar', '123456', 'abc123'); + + $logger->expects($this->once())->method('warning') + ->with( + "Invalid bearer checksum", + [ + 'expected' => str_replace('SSO-foo-123456-', '', $bearer), + 'received' => '000000', + 'broker' => 'foo', + 'token' => '123456', + 'verification_code' => $this->getVerificationCode('foo', '123456', 'abc123') + ] + ); + + $this->expectException(BrokerException::class); + $this->expectExceptionCode(403); + $this->expectExceptionMessage("Invalid bearer checksum"); + + $server->startBrokerSession($request); + } + + public function testUnattachedToken() + { + $callback = $this->createCallbackMock($this->any(), ['foo'], ['secret' => 'bar']); + + $cache = $this->createMock(CacheInterface::class); + + $bearer = $this->getBearerToken('foo', 'bar', '123456', 'abc123'); + + $request = (new ServerRequest()) + ->withUri(new Uri("https://server.example.com/attach.php")) + ->withHeader('Authorization', "Bearer $bearer"); + + $session = $this->createMock(SessionInterface::class); + $logger = $this->createMock(LoggerInterface::class); + + $server = (new Server($callback, $cache)) + ->withSession($session) + ->withLogger($logger); + + $cache->expects($this->once())->method('get') + ->with('SSO-foo-123456') + ->willReturn(null); + + $session->expects($this->once())->method('isActive')->willReturn(false); + $session->expects($this->never())->method('start'); + + $logger->expects($this->once())->method('warning') + ->with( + "Bearer token isn't attached to a client session", + ['broker' => 'foo', 'token' => '123456'] + ); + + $this->expectException(BrokerException::class); + $this->expectExceptionCode(403); + $this->expectExceptionMessage("Bearer token isn't attached to a client session"); + + $server->startBrokerSession($request); + } + + public function testUnknownBroker() + { + $callback = $this->createCallbackMock($this->atLeastOnce(), ['foo'], null); + + $cache = $this->createMock(CacheInterface::class); + + $bearer = $this->getBearerToken('foo', 'bar', '123456', 'abc123'); + + $request = (new ServerRequest()) + ->withUri(new Uri("https://server.example.com/attach.php")) + ->withHeader('Authorization', "Bearer $bearer"); + + $session = $this->createMock(SessionInterface::class); + $logger = $this->createMock(LoggerInterface::class); + + $server = (new Server($callback, $cache)) + ->withSession($session) + ->withLogger($logger); + + $cache->expects($this->once())->method('get') + ->with('SSO-foo-123456') + ->willReturn('abc123'); + + $session->expects($this->once())->method('isActive')->willReturn(false); + $session->expects($this->never())->method('start'); + + $logger->expects($this->once())->method('warning') + ->with("Unknown broker", ['broker' => 'foo', 'token' => '123456']); + + $this->expectException(BrokerException::class); + $this->expectExceptionCode(403); + $this->expectExceptionMessage("Broker is unknown or disabled"); + + $server->startBrokerSession($request); + } +} diff --git a/tests/unit/TokenTrait.php b/tests/unit/TokenTrait.php new file mode 100644 index 0000000..ed4d65a --- /dev/null +++ b/tests/unit/TokenTrait.php @@ -0,0 +1,28 @@ +getVerificationCode($broker, $token, $sessionId); + + return "SSO-{$broker}-{$token}-" . $this->generateChecksum("bearer:$code", $secret, $token); + } +}