diff --git a/.circleci/config.yml b/.circleci/config.yml deleted file mode 100644 index 33edcf4..0000000 --- a/.circleci/config.yml +++ /dev/null @@ -1,28 +0,0 @@ ---- -version: 2.1 - -jobs: - build: - docker: - - image: cimg/php:7.2-browsers - steps: - - checkout - - run: sudo apt-get update - - run: sudo apt-get install php7.2-xdebug -y - - restore_cache: - keys: - - v2-dependencies-{{ checksum "composer.json" }} - - v2-dependencies- - - run: composer install --no-interaction - - save_cache: - key: v2-dependencies-{{ checksum "composer.json" }} - paths: - - ./vendor - - run: mkdir -p build/logs - - run: XDEBUG_MODE=coverage ./vendor/bin/phpunit -c phpunit.xml - - run: ./vendor/bin/php-coveralls -v - -workflows: - build_and_test: - jobs: - - build diff --git a/.coveralls.yml b/.coveralls.yml deleted file mode 100644 index 6d00a3d..0000000 --- a/.coveralls.yml +++ /dev/null @@ -1,2 +0,0 @@ -coverage_clover: build/logs/clover.xml -json_path: build/logs/coveralls-upload.json \ No newline at end of file diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..3b4bfaf --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,26 @@ +name: Lint + +on: + push: + branches: [master, develop] + pull_request: + +jobs: + lint: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.4' + coverage: none + tools: composer + + - name: Validate composer.json + run: composer validate --strict --no-check-lock + + - name: PHP syntax check + run: find lib test -name '*.php' -print0 | xargs -0 -n1 -P4 php -l diff --git a/.github/workflows/specs.yml b/.github/workflows/specs.yml new file mode 100644 index 0000000..a2fb30b --- /dev/null +++ b/.github/workflows/specs.yml @@ -0,0 +1,32 @@ +name: Specs + +on: + push: + branches: [master, develop] + pull_request: + +jobs: + specs: + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + php-version: ['7.2', '7.3', '7.4', '8.0', '8.1', '8.2', '8.3', '8.4', '8.5'] + + steps: + - uses: actions/checkout@v4 + + - name: Set up PHP ${{ matrix.php-version }} + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-version }} + extensions: curl, json + coverage: none + tools: composer + + - name: Install dependencies + run: composer update --no-interaction --no-progress --prefer-dist + + - name: Run tests + run: composer test diff --git a/.gitignore b/.gitignore index c315f07..c121360 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ package-lock.json composer.lock /node_modules/ /vendor/ +.phpunit.result.cache diff --git a/CHANGELOG.md b/CHANGELOG.md index 0539f7f..06b129d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,34 @@ # Changelog +## 4.0.0 +**BREAKING CHANGES:** + +* the library is now defined under the `Castle\` namespace (`Castle\Castle`, `Castle\Webhook`, `Castle\RequestContext`, `Castle\ApiError`, ...), which is the canonical API ([#40](https://github.com/castle/castle-php/issues/40)) +* removed the legacy `Castle::track`, `Castle::authenticate` and `Castle::impersonate` endpoints (and the `Castle_Authenticate` model); use `Castle::risk`, `Castle::filter` and `Castle::log` instead +* `Castle::risk` and `Castle::filter` now fail over to a configurable decision instead of throwing on network errors, timeouts and `5xx` responses; `Castle::log` returns the same response shape. `Castle::risk`/`filter`/`log` responses now include `failover` and `failover_reason` +* the default request timeout is now 1000 ms (previously 10 s), applied to both connection and transfer; configure it with `Castle::setRequestTimeout` +* removed `Castle::setCurlOpts` / `Castle::getCurlOpts` (and the `Castle\CurlOptionError` exception with its `Castle_CurlOptionError` alias); use `Castle::setRequestTimeout` to configure the connection and transfer timeout +* replaced the separate `Castle::$apiBase` / `Castle::$apiVersion` (and `Castle::getApiVersion` / `setApiVersion`) with a single `Castle::$baseUrl` (default `https://api.castle.io/v1`), configurable via `Castle::getBaseUrl` / `Castle::setBaseUrl` +* removed the configurable token/cookie store (`Castle::$tokenStore`, `Castle::$cookieStore`, `Castle::getTokenStore` / `getCookieStore` / `setTokenStore`) and the `Castle\CookieStore` class (with its `Castle_CookieStore` / `Castle_iCookieStore` aliases); these read the client id from the `__cid` cookie, which the slimmed request context no longer does + +Other changes: + +* slimmed the default request context built by `Castle\RequestContext::extract` down to `ip`, `headers` and `library`; the `client_id` and `user_agent` fields (and the `RequestContext::extractClientId`, `extractUserAgent` and `normalize` helpers) are removed, as the client id is carried by the `X-Castle-Client-Id` header / `__cid` cookie and resolved server-side +* added a configurable failover strategy (`Castle::setFailoverStrategy` with `Castle\Failover::ALLOW`/`DENY`/`CHALLENGE`/`THROW`) and the `Castle\InternalServerError` exception for `5xx` responses +* added do-not-track support: `Castle::disableTracking`, `Castle::enableTracking` and `Castle::tracked` +* added the Events API: `Castle::eventsSchema`, `Castle::queryEvents`, `Castle::groupEvents` +* the historic global class names (`Castle`, `Castle_*`, `RestModel`) are retained as aliases of their namespaced counterparts, so existing integrations keep working without changes; `catch` and `instanceof` work with either name +* additional PHP 8 compatibility fixes: declared `Castle_Resource::$model`, and avoided passing `null` to `Exception` and `json_decode` + +## 3.3.0 +* added the Lists API: `Castle::createList`, `Castle::getAllLists`, `Castle::getList`, `Castle::updateList`, `Castle::deleteList`, `Castle::queryList` +* added the List Items API: `Castle::createListItem`, `Castle::createListItems`, `Castle::getListItem`, `Castle::updateListItem`, `Castle::queryListItems`, `Castle::countListItems`, `Castle::archiveListItem`, `Castle::unarchiveListItem` +* added the Privacy API: `Castle::requestUserData`, `Castle::deleteUserData` +* added webhook signature verification: `Castle_Webhook::verify` and the `Castle_WebhookVerificationError` exception +* the request context is now attached automatically to `risk`, `filter` and `log` requests +* a `sent_at` timestamp is now attached automatically to `risk`, `filter` and `log` requests ([#23](https://github.com/castle/castle-php/issues/23)) +* improved PHP 8 compatibility: declared the `Castle_ApiError` properties and made API error handling tolerant of empty response bodies +* migrated CI to GitHub Actions and now test against PHP 7.2 through 8.5 + ## 3.2.0 (2022-03-28) * updated ca-certs file diff --git a/README.md b/README.md index 4ade8b7..2660eec 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,9 @@ -[![Latest Stable Version](https://poser.pugx.org/castle/castle-php/v/stable.svg)](https://packagist.org/packages/castle/castle-php) [![Total Downloads](https://poser.pugx.org/castle/castle-php/downloads.svg)](https://packagist.org/packages/castle/castle-php) [![License](https://poser.pugx.org/castle/castle-php/license.svg)](https://packagist.org/packages/castle/castle-php) - -[![Code Climate](https://codeclimate.com/github/castle/castle-php.png)](https://codeclimate.com/github/castle/castle-php) -[![Coverage Status](https://coveralls.io/repos/github/castle/castle-php/badge.svg?branch=fix%2Fcode-coverage)](https://coveralls.io/github/castle/castle-php?branch=fix%2Fcode-coverage) - # PHP SDK for Castle +[![Specs](https://github.com/castle/castle-php/actions/workflows/specs.yml/badge.svg)](https://github.com/castle/castle-php/actions/workflows/specs.yml) +[![Lint](https://github.com/castle/castle-php/actions/workflows/lint.yml/badge.svg)](https://github.com/castle/castle-php/actions/workflows/lint.yml) +[![Latest Stable Version](https://poser.pugx.org/castle/castle-php/v/stable.svg)](https://packagist.org/packages/castle/castle-php) + **[Castle](https://castle.io) analyzes user behavior in web and mobile apps to stop fraud before it happens.** @@ -12,37 +11,86 @@ See the [documentation](https://docs.castle.io) for how to use this SDK with the Castle APIs +## Requirements + +PHP 7.2 or newer, with the `curl` and `json` extensions. The library is tested +against PHP 7.2 through 8.5. + ## Getting started -Obtain the latest version of the Castle PHP bindings with: +Install the latest version with Composer: ```bash -git clone --single-branch --branch master https://github.com/castle/castle-php +composer require castle/castle-php ``` -To get started, add the following to your PHP script: +Then load Composer's autoloader and configure the library with your Castle API +secret: ```php -require_once("/path/to/castle-php/lib/Castle.php"); +require_once 'vendor/autoload.php'; + +Castle::setApiKey('YOUR_API_SECRET'); ``` -Configure the library with your Castle API secret. +## Namespaces + +As of 4.0 the library lives under the `Castle\` namespace, e.g. `Castle\Castle`, +`Castle\Webhook`, `Castle\RequestContext`, `Castle\ApiError`. This is the +canonical API: ```php +use Castle\Castle; +use Castle\Webhook; +use Castle\WebhookVerificationError; + Castle::setApiKey('YOUR_API_SECRET'); + +$verdict = Castle::filter([ + 'request_token' => $requestToken, + 'name' => '$registration', + 'user' => ['id' => '1234'], +]); + +try { + Webhook::verify(); +} catch (WebhookVerificationError $e) { + // reject the request +} ``` +### Backward compatibility + +The historic global class names (`Castle`, `Castle_*`, `RestModel`) are kept as +aliases of their namespaced counterparts, so existing integrations keep working +without changes. `Castle_ApiError` and `Castle\ApiError` are the same class, so +`instanceof` checks and `catch` blocks work with either name: + +```php +try { + Castle::risk([/* ... */]); +} catch (Castle_ApiError $e) { + // still catches the namespaced Castle\ApiError thrown by the library +} +``` + +New code should prefer the namespaced names; the global aliases are retained for +compatibility. + ## Optional Configurations -Set preferred connection and request timeouts: -valid options for setting are: -- `CURLOPT_CONNECTTIMEOUT` -- `CURLOPT_CONNECTTIMEOUT_MS` -- `CURLOPT_TIMEOUT` -- `CURLOPT_TIMEOUT_MS` +Set the per-request timeout in milliseconds (applied to both connection and +transfer). Defaults to `1000`: + +```php +Castle::setRequestTimeout(1500); +``` + +Set the failover strategy used when a `risk` or `filter` request cannot be +completed (see [Failover](#failover)): ```php -Castle::setCurlOpts($curlOpts) +Castle::setFailoverStrategy(Castle\Failover::ALLOW); ``` Set a specified list of request headers to include with event context (optional, not recommended): @@ -71,14 +119,139 @@ Castle_RequestContext['ip'] = '1.1.1.1' $context = Castle_RequestContext::extractJson(); ``` +## Lists + +Manage [lists](https://docs.castle.io) and their items: + +```php +$list = Castle::createList([ + 'name' => 'Blocklist', + 'color' => '$red', + 'primary_field' => 'user.email', +]); + +$lists = Castle::getAllLists(); +$list = Castle::getList($list['id']); +Castle::updateList($list['id'], ['name' => 'Renamed']); +Castle::deleteList($list['id']); +Castle::queryList(['filters' => [['field' => 'name', 'op' => '$eq', 'value' => 'Blocklist']]]); +``` + +List items: + +```php +$item = Castle::createListItem($list['id'], [ + 'author' => 'user:123', + 'primary_value' => 'user@example.com', +]); + +Castle::getListItem($list['id'], $item['id']); +Castle::updateListItem($list['id'], $item['id'], ['comment' => 'Flagged for review']); +Castle::queryListItems($list['id'], ['filters' => []]); +Castle::countListItems($list['id'], ['filters' => []]); +Castle::archiveListItem($list['id'], $item['id']); +Castle::unarchiveListItem($list['id'], $item['id']); +Castle::createListItems($list['id'], ['items' => [/* ... */]]); +``` + +## Privacy + +Request or delete the data Castle stores for a user: + +```php +Castle::requestUserData([ + 'identifier' => 'user@example.com', + 'identifier_type' => '$email', +]); + +Castle::deleteUserData([ + 'identifier' => 'user@example.com', + 'identifier_type' => '$email', +]); +``` + +## Failover + +When a `risk` or `filter` request cannot be completed because of a network +error, timeout or a `5xx` response from the Castle API, the SDK returns a +synthetic decision instead of throwing, so your authentication flow keeps +working. The decision is controlled by the failover strategy: + +```php +Castle::setFailoverStrategy(Castle\Failover::ALLOW); // default +// Castle\Failover::DENY +// Castle\Failover::CHALLENGE +// Castle\Failover::THROW -- re-raise the underlying exception instead +``` + +A failed-over response carries `failover => true` and a `failover_reason`: + +```php +$verdict = Castle::risk([ + 'request_token' => $requestToken, + 'name' => '$login', + 'user' => ['id' => '1234'], +]); + +$verdict->failover; // true when the request failed over +$verdict->action; // 'allow', 'deny' or 'challenge' +$verdict->policy['action']; +``` + +Successful responses include `failover => false`. Client errors (`4xx`, such as +`422 invalid_request_token`) are never failed over and always raise. + +## Do-not-track + +Disable outbound tracking calls, for example in staging or test environments. +While disabled, `risk`, `filter` and `log` return an `allow` response without +contacting the API: + +```php +Castle::disableTracking(); +Castle::tracked(); // false +Castle::enableTracking(); +``` + +## Events (enterprise) + +Query event data: + +```php +Castle::eventsSchema(); +Castle::queryEvents(['filters' => [['field' => 'name', 'op' => '$eq', 'value' => '$login']]]); +Castle::groupEvents(['filters' => [], 'group_by' => 'name']); +``` + +## Webhooks + +Verify the authenticity of incoming Castle webhooks. By default the raw body is +read from `php://input` and the signature from the `X-Castle-Signature` header: + +```php +try { + Castle_Webhook::verify(); + // handle the webhook payload +} catch (Castle_WebhookVerificationError $e) { + http_response_code(404); +} +``` + +The body and signature can also be passed explicitly: + +```php +Castle_Webhook::verify($rawBody, $signatureHeader); +``` + ## Errors -Whenever something unexpected happens, an [exception](/lib/Castle/Errors.php) is thrown to indicate what went wrong. +Whenever something unexpected happens, an [exception](lib/Castle/Errors.php) is thrown to indicate what went wrong. | Name | Description | |:---------------------------------|:----------------| | `Castle_Error` | A generic error | | `Castle_RequestError` | A request failed. Probably due to a network error | | `Castle_ApiError` | An unexpected error for the Castle API | +| `Castle_InternalServerError` | The Castle API returned a `5xx` response (triggers failover) | | `Castle_ConfigurationError` | The Castle secret API key has not been set | | `Castle_UnauthorizedError` | Wrong Castle API secret key | | `Castle_BadRequest` | The request was invalid. For example if a challenge is created without the user having MFA enabled. | @@ -86,6 +259,13 @@ Whenever something unexpected happens, an [exception](/lib/Castle/Errors.php) is | `Castle_NotFoundError` | The resource requestd was not found. For example if a session has been revoked. | | `Castle_InvalidParametersError` | One or more of the supplied parameters are incorrect. Check the response for more information. | | `Castle_InvalidRequestTokenError` | The request token parameter is missing or invalid | +| `Castle_WebhookVerificationError` | An incoming webhook could not be verified against the `X-Castle-Signature` header | ## Running test suite -Execute `vendor/bin/phpunit test` to run the full test suite + +Install the dev dependencies and run the suite with: + +```bash +composer install +composer test +``` diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 0000000..960935a --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,14 @@ +# Releasing + +1. Create branch `release/X.Y.Z` from `develop`. +2. Update the `VERSION` constant in `lib/Castle/Castle.php` to the new version. +3. Update the `CHANGELOG.md` for the impending release. +4. `git commit -am "release X.Y.Z"` (where X.Y.Z is the new version). +5. Push to Github, make a PR to the `develop` branch, and when approved, merge. +6. Pull latest `develop`, merge it into `master`, and push `master` to `origin`. +7. Make a release on Github from the `master` branch, specify tag as `vX.Y.Z` to create a tag. + +[Packagist](https://packagist.org/packages/castle/castle-php) is configured to +auto-update from the repository, so pushing the `vX.Y.Z` tag publishes the new +version. If the webhook is not configured, trigger an update manually from the +package page on Packagist. diff --git a/composer.json b/composer.json index 0b1f73b..4717b71 100644 --- a/composer.json +++ b/composer.json @@ -17,13 +17,20 @@ "classmap": [ "lib/Castle", "lib/RestModel" + ], + "files": [ + "lib/aliases.php" ] }, "require": { - "php": ">=7.2.0" + "php": ">=7.2", + "ext-curl": "*", + "ext-json": "*" }, "require-dev": { - "phpunit/phpunit": "*", - "php-coveralls/php-coveralls": "*" + "phpunit/phpunit": "^8.5 || ^9.6" + }, + "scripts": { + "test": "phpunit -c phpunit.xml" } } diff --git a/lib/Castle.php b/lib/Castle.php index 9acaaa6..39b289d 100755 --- a/lib/Castle.php +++ b/lib/Castle.php @@ -16,12 +16,13 @@ function lcfirst( $str ) { } require(dirname(__FILE__) . '/Castle/Castle.php'); -require(dirname(__FILE__) . '/Castle/CookieStore.php'); require(dirname(__FILE__) . '/Castle/Errors.php'); +require(dirname(__FILE__) . '/Castle/Failover.php'); require(dirname(__FILE__) . '/RestModel/Resource.php'); require(dirname(__FILE__) . '/RestModel/Model.php'); -require(dirname(__FILE__) . '/Castle/Models/Context.php'); -require(dirname(__FILE__) . '/Castle/Models/Authenticate.php'); +require(dirname(__FILE__) . '/Castle/Context.php'); require(dirname(__FILE__) . '/Castle/CurlTransport.php'); require(dirname(__FILE__) . '/Castle/RequestContext.php'); require(dirname(__FILE__) . '/Castle/Request.php'); +require(dirname(__FILE__) . '/Castle/Webhook.php'); +require(dirname(__FILE__) . '/aliases.php'); diff --git a/lib/Castle/Castle.php b/lib/Castle/Castle.php index 7bbffd6..0e40e26 100755 --- a/lib/Castle/Castle.php +++ b/lib/Castle/Castle.php @@ -1,32 +1,42 @@ send('post', $path, $attributes); + if ($request->rStatus == 204) { + $response = array(); + } + $response = is_array($response) ? $response : array(); + $response['failover'] = false; + $response['failover_reason'] = null; + return new RestModel($response); + } catch (RequestError $e) { + return self::failoverResponseOrRaise(self::failoverUserId($attributes), $e); + } catch (InternalServerError $e) { + return self::failoverResponseOrRaise(self::failoverUserId($attributes), $e); + } } - public static function setTokenStore($serializerClass) + private static function failoverResponseOrRaise($userId, $exception) { - self::$tokenStore = $serializerClass; + if (self::$failoverStrategy === Failover::THROW) { + throw $exception; + } + return new RestModel(Failover::prepareResponse( + $userId, + self::$failoverStrategy, + get_class($exception) + )); } + private static function doNotTrackResponse($userId) + { + return new RestModel(Failover::prepareResponse( + $userId, + Failover::ALLOW, + 'Castle is set to do not track.' + )); + } + + private static function failoverUserId(array $attributes) + { + if (isset($attributes['user']) && is_array($attributes['user']) && + isset($attributes['user']['id'])) { + return $attributes['user']['id']; + } + if (isset($attributes['matching_user_id'])) { + return $attributes['matching_user_id']; + } + return null; + } + + /** + * Lists API + */ /** - * Authenticate an action - * @param String $attributes 'user_id' and 'event' are required - * @return Castle_Authenticate + * Create a list + * @param Array $attributes 'name', 'color' and 'primary_field' are required + * @return Array */ - public static function authenticate(Array $attributes) + public static function createList(array $attributes) { - $auth = new Castle_Authenticate($attributes); - $auth->save(); - return $auth; + return self::sendRequest('post', '/lists', $attributes); } - public static function impersonate($attributes) { - $request = new Castle_Request(); - if(isset($attributes['reset'])) { - $request->send('delete', '/impersonate', $attributes); - } else { - $request->send('post', '/impersonate', $attributes); - } + /** + * Fetch all lists + * @return Array + */ + public static function getAllLists() + { + return self::sendRequest('get', '/lists'); } /** - * Track a security event - * @param Array $attributes An array of attributes to track. The 'event' key - * is required - * @return None + * Fetch a single list + * @param String $listId + * @return Array */ - public static function track(Array $attributes) + public static function getList($listId) { - $request = new Castle_Request(); - $request->send('post', '/track', $attributes); + return self::sendRequest('get', self::listPath($listId)); } + /** + * Update a list + * @param String $listId + * @param Array $attributes + * @return Array + */ + public static function updateList($listId, array $attributes) + { + return self::sendRequest('put', self::listPath($listId), $attributes); + } /** - * Filter an action - * @param String $attributes 'request_token', 'event', 'context' are required, 'user' with 'id' and 'properties' are optional - * @return Castle_Log + * Delete a list + * @param String $listId + * @return Array */ - public static function filter(Array $attributes) + public static function deleteList($listId) { - $request = new Castle_Request(); - list($response, $request) = $request->send('post', '/filter', $attributes); - if ($request->rStatus == 204) { - $response = array(); - } - return new RestModel($response); + return self::sendRequest('delete', self::listPath($listId)); } /** - * Log events - * @param String $attributes 'request_token', 'event', 'status' and 'user' object with 'id' are required - * @return Castle_Log + * Query lists + * @param Array $attributes + * @return Array */ - public static function log(Array $attributes) + public static function queryList(array $attributes = array()) { - $request = new Castle_Request(); - $request->send('post', '/log', $attributes); + return self::sendRequest('post', '/lists/query', $attributes); } /** - * Risk - * @param String $attributes 'request_token', 'event', 'context', 'user' with 'id' are required, 'status', 'properties' are optional - * @return Castle_Risk + * List Items API + */ + + /** + * Create a list item + * @param String $listId + * @param Array $attributes 'author' and 'primary_value' are required + * @return Array + */ + public static function createListItem($listId, array $attributes) + { + return self::sendRequest('post', self::listItemsPath($listId), $attributes); + } + + /** + * Create a batch of list items + * @param String $listId + * @param Array $attributes 'items' is required + * @return Array + */ + public static function createListItems($listId, array $attributes) + { + return self::sendRequest('post', self::listItemsPath($listId) . '/batch', $attributes); + } + + /** + * Fetch a list item + * @param String $listId + * @param String $itemId + * @return Array + */ + public static function getListItem($listId, $itemId) + { + return self::sendRequest('get', self::listItemPath($listId, $itemId)); + } + + /** + * Update a list item + * @param String $listId + * @param String $itemId + * @param Array $attributes 'comment' is required + * @return Array + */ + public static function updateListItem($listId, $itemId, array $attributes) + { + return self::sendRequest('put', self::listItemPath($listId, $itemId), $attributes); + } + + /** + * Query the items of a list + * @param String $listId + * @param Array $attributes + * @return Array + */ + public static function queryListItems($listId, array $attributes = array()) + { + return self::sendRequest('post', self::listItemsPath($listId) . '/query', $attributes); + } + + /** + * Count the items of a list + * @param String $listId + * @param Array $attributes + * @return Array + */ + public static function countListItems($listId, array $attributes = array()) + { + return self::sendRequest('post', self::listItemsPath($listId) . '/count', $attributes); + } + + /** + * Archive a list item + * @param String $listId + * @param String $itemId + * @return Array + */ + public static function archiveListItem($listId, $itemId) + { + return self::sendRequest('delete', self::listItemPath($listId, $itemId) . '/archive'); + } + + /** + * Unarchive a list item + * @param String $listId + * @param String $itemId + * @return Array + */ + public static function unarchiveListItem($listId, $itemId) + { + return self::sendRequest('put', self::listItemPath($listId, $itemId) . '/unarchive'); + } + + /** + * Privacy API */ - public static function risk(Array $attributes) + + /** + * Request the data stored for a user + * @param Array $attributes 'identifier' and 'identifier_type' are required + * @return Array + */ + public static function requestUserData(array $attributes) + { + return self::sendRequest('post', '/privacy/users', $attributes); + } + + /** + * Delete the data stored for a user + * @param Array $attributes 'identifier' and 'identifier_type' are required + * @return Array + */ + public static function deleteUserData(array $attributes) + { + return self::sendRequest('delete', '/privacy/users', $attributes); + } + + /** + * Events API (enterprise) + */ + + /** + * Fetch the events schema + * @return Array + */ + public static function eventsSchema(array $attributes = array()) + { + return self::sendRequest('get', '/events/schema', $attributes); + } + + /** + * Query events + * @param Array $attributes + * @return Array + */ + public static function queryEvents(array $attributes) + { + return self::sendRequest('post', '/events/query', $attributes); + } + + /** + * Group events + * @param Array $attributes + * @return Array + */ + public static function groupEvents(array $attributes) + { + return self::sendRequest('post', '/events/group', $attributes); + } + + private static function sendRequest($method, $path, $attributes = null) { - $request = new Castle_Request(); - list($response, $request) = $request->send('post', '/risk', $attributes); + $request = new Request(); + list($response, $request) = $request->send($method, $path, $attributes); if ($request->rStatus == 204) { $response = array(); } - return new RestModel($response); + return $response; + } + + private static function listPath($listId) + { + return '/lists/' . rawurlencode($listId); + } + + private static function listItemsPath($listId) + { + return self::listPath($listId) . '/items'; + } + + private static function listItemPath($listId, $itemId) + { + return self::listItemsPath($listId) . '/' . rawurlencode($itemId); } } diff --git a/lib/Castle/Context.php b/lib/Castle/Context.php new file mode 100644 index 0000000..ecad92d --- /dev/null +++ b/lib/Castle/Context.php @@ -0,0 +1,16 @@ +hasOne(RestModel::class, 'location'); + } + + public function userAgent() + { + return $this->hasOne(RestModel::class, 'user_agent'); + } +} diff --git a/lib/Castle/CookieStore.php b/lib/Castle/CookieStore.php deleted file mode 100644 index e736bd7..0000000 --- a/lib/Castle/CookieStore.php +++ /dev/null @@ -1,40 +0,0 @@ -setResponse($curl); diff --git a/lib/Castle/Errors.php b/lib/Castle/Errors.php index 2284c9a..9fe5610 100755 --- a/lib/Castle/Errors.php +++ b/lib/Castle/Errors.php @@ -1,61 +1,71 @@ type = $type; $this->httpStatus = $status; } } -class Castle_BadRequest extends Castle_ApiError +class InternalServerError extends ApiError +{ + +} + +class BadRequest extends ApiError +{ + +} + +class UnauthorizedError extends ApiError { } -class Castle_UnauthorizedError extends Castle_ApiError +class ForbiddenError extends ApiError { } -class Castle_ForbiddenError extends Castle_ApiError +class NotFoundError extends ApiError { } -class Castle_NotFoundError extends Castle_ApiError +class InvalidParametersError extends ApiError { } -class Castle_InvalidParametersError extends Castle_ApiError +class InvalidRequestTokenError extends InvalidParametersError { } -class Castle_InvalidRequestTokenError extends Castle_InvalidParametersError +class WebhookVerificationError extends Error { } diff --git a/lib/Castle/Failover.php b/lib/Castle/Failover.php new file mode 100644 index 0000000..06f8a0d --- /dev/null +++ b/lib/Castle/Failover.php @@ -0,0 +1,36 @@ + array('action' => $strategy), + 'action' => $strategy, + 'user_id' => $userId, + 'failover' => true, + 'failover_reason' => $reason, + ); + } +} diff --git a/lib/Castle/Models/Authenticate.php b/lib/Castle/Models/Authenticate.php deleted file mode 100644 index 0841035..0000000 --- a/lib/Castle/Models/Authenticate.php +++ /dev/null @@ -1,6 +0,0 @@ -hasOne('RestModel', 'location'); - } - - public function userAgent() - { - return $this->hasOne('RestModel', 'user_agent'); - } -} diff --git a/lib/Castle/Request.php b/lib/Castle/Request.php index 50fe291..7821792 100755 --- a/lib/Castle/Request.php +++ b/lib/Castle/Request.php @@ -1,47 +1,51 @@ = 500 && $status <= 599) { + throw new InternalServerError($msg, $type, $status); + } + throw new ApiError($msg, $type, $status); } } public function handleRequestError($request) { - throw new Castle_RequestError("$request->rError: $request->rMessage"); + throw new RequestError("$request->rError: $request->rMessage"); } public function handleResponse($request) @@ -50,9 +54,9 @@ public function handleResponse($request) $this->handleRequestError($request); } - $response = json_decode($request->rBody, true); + $response = json_decode($request->rBody === null ? '' : $request->rBody, true); if (!empty($request->rBody) && $response === null) { - throw new Castle_ApiError('Invalid response from API', 'api_error', $request->rStatus); + throw new ApiError('Invalid response from API', 'api_error', $request->rStatus); } if ($request->rStatus < 200 || $request->rStatus >= 300) { @@ -66,30 +70,50 @@ public function preFlightCheck() { $key = Castle::getApiKey(); if (empty($key)) { - throw new Castle_ConfigurationError(); + throw new ConfigurationError(); } } - public function send($method, $url, $payload = 's') { + public function send($method, $url, $payload = array()) { + if (!is_array($payload)) { + $payload = array(); + } + if ( self::shouldHaveContext($url) && !array_key_exists('context', $payload)) { - $payload['context'] = Castle_RequestContext::extract(); + $payload['context'] = RequestContext::extract(); + } + + if ( self::shouldHaveSentAt($url) && !array_key_exists('sent_at', $payload)) { + $payload['sent_at'] = self::generateTimestamp(); } return $this->sendWithContext($url, $payload, $method); } private function shouldHaveContext($url) { - $WITH_CONTEXT = ['/track', '/authenticate', '/impersonate']; + $WITH_CONTEXT = ['/risk', '/filter', '/log']; return in_array($url, $WITH_CONTEXT); } + private function shouldHaveSentAt($url) { + $WITH_SENT_AT = ['/risk', '/filter', '/log']; + + return in_array($url, $WITH_SENT_AT); + } + + // ISO8601 timestamp (millisecond precision, UTC) marking when the request was sent. + public static function generateTimestamp() { + $date = new \DateTime('now', new \DateTimeZone('UTC')); + return $date->format('Y-m-d\TH:i:s.v\Z'); + } + public function sendWithContext($url, $payload, $method = 'post') { $this->preFlightCheck(); - $request = new Castle_RequestTransport(); + $request = new RequestTransport(); $request->send($method, self::apiUrl($url), $payload); return $this->handleResponse($request); diff --git a/lib/Castle/RequestContext.php b/lib/Castle/RequestContext.php index 338d715..94762b4 100644 --- a/lib/Castle/RequestContext.php +++ b/lib/Castle/RequestContext.php @@ -1,14 +1,14 @@ self::extractClientId(), 'ip' => self::extractIp(), 'headers' => self::extractHeaders(), - 'user_agent' => self::extractUserAgent(), 'library' => array( 'name' => 'castle-php', 'version' => Castle::VERSION @@ -59,36 +59,4 @@ public static function extractIp() } return null; } - - public static function extractUserAgent() - { - if (array_key_exists('HTTP_USER_AGENT', $_SERVER)) { - return $_SERVER['HTTP_USER_AGENT']; - } - return null; - } - - public static function extractClientId() - { - if (array_key_exists('HTTP_X_CASTLE_CLIENT_ID', $_SERVER)) { - return self::normalize($_SERVER['HTTP_X_CASTLE_CLIENT_ID']); - } else if (Castle::getCookieStore()->hasKey('__cid')) { - return self::normalize(Castle::getCookieStore()->read('__cid')); - } else { - // If the client_id is neither send in the header nor cookie - // we'll return the special value '?'. This doesn't have any effect on - // functionality. This is to prevent curl from removing empty headers - return '?'; - } - } - - public static function normalize($cid) - { - $cid = preg_replace("/[[:cntrl:][:space:]]/", '', $cid); - - // If we end up with an empty/invalid cid, we'll set it to the special - // value '_' to indicate there was a value but it was not valid. - // This is to prevent curl from removing empty headers - return empty($cid) ? '_' : $cid; - } } diff --git a/lib/Castle/Webhook.php b/lib/Castle/Webhook.php new file mode 100644 index 0000000..5b6fa62 --- /dev/null +++ b/lib/Castle/Webhook.php @@ -0,0 +1,50 @@ +getResourcePath($path); } - $request = new Castle_Request(); + $request = new Request(); list($response, $request) = $request->send($method, $url, $params); if ($request->rStatus == 204) { $response = array(); @@ -181,7 +183,7 @@ public function hasMany($model, $attrName = null) $attrName = self::pluralize($attrName); } $items = $this->getAttribute($attrName); - $resource = new Castle_Resource($model, $items); + $resource = new Resource($model, $items); $resource->setParent($this); return $resource; } @@ -218,7 +220,7 @@ public function save() $method = array_key_exists($this->idAttribute, $this->attributes) ? 'put' : 'post'; $response = $this->request($method, null, $this->attributes); if (!is_array($response)) { - throw new Castle_Error('Invalid response'); + throw new Error('Invalid response'); } $this->setAttributes($response); return $this; @@ -269,26 +271,26 @@ public static function snakeCase($input) { public static function all($params=null) { - $instance = new Castle_Resource(get_called_class()); + $instance = new Resource(get_called_class()); return $instance->all($params); } public static function create($attributes=null) { - $instance = new Castle_Resource(get_called_class()); + $instance = new Resource(get_called_class()); return $instance->create($attributes); } public static function destroy($id) { - $instance = new Castle_Resource(get_called_class()); + $instance = new Resource(get_called_class()); return $instance->destroy($id); } public static function find($id) { - $instance = new Castle_Resource(get_called_class()); + $instance = new Resource(get_called_class()); return $instance->find($id); } } diff --git a/lib/RestModel/Resource.php b/lib/RestModel/Resource.php index 51880b0..374dac6 100644 --- a/lib/RestModel/Resource.php +++ b/lib/RestModel/Resource.php @@ -1,11 +1,15 @@ model = $model; diff --git a/lib/aliases.php b/lib/aliases.php new file mode 100644 index 0000000..f15aa36 --- /dev/null +++ b/lib/aliases.php @@ -0,0 +1,102 @@ + 'Castle\\Castle', + 'Castle_Request' => 'Castle\\Request', + 'Castle_RequestContext' => 'Castle\\RequestContext', + 'Castle_RequestTransport' => 'Castle\\RequestTransport', + 'Castle_Webhook' => 'Castle\\Webhook', + 'Castle_Context' => 'Castle\\Context', + 'Castle_Failover' => 'Castle\\Failover', + 'RestModel' => 'Castle\\RestModel', + 'Castle_Resource' => 'Castle\\Resource', + 'Castle_Error' => 'Castle\\Error', + 'Castle_RequestError' => 'Castle\\RequestError', + 'Castle_ConfigurationError' => 'Castle\\ConfigurationError', + 'Castle_ApiError' => 'Castle\\ApiError', + 'Castle_InternalServerError' => 'Castle\\InternalServerError', + 'Castle_BadRequest' => 'Castle\\BadRequest', + 'Castle_UnauthorizedError' => 'Castle\\UnauthorizedError', + 'Castle_ForbiddenError' => 'Castle\\ForbiddenError', + 'Castle_NotFoundError' => 'Castle\\NotFoundError', + 'Castle_InvalidParametersError' => 'Castle\\InvalidParametersError', + 'Castle_InvalidRequestTokenError' => 'Castle\\InvalidRequestTokenError', + 'Castle_WebhookVerificationError' => 'Castle\\WebhookVerificationError', + ); + } +} + +spl_autoload_register(function ($class) { + $map = castle_legacy_alias_map(); + + if (!isset($map[$class])) { + return; + } + + $target = $map[$class]; + + // class_exists()/interface_exists() trigger the namespaced class to load (via + // Composer's classmap or the bundled require chain) before we alias it. + if (class_exists($target) || interface_exists($target)) { + class_alias($target, $class); + } +}); + +/** + * Eagerly alias the exception hierarchy. PHP does not autoload the class named + * in a `catch` clause, so without this a consumer's `catch (Castle_ApiError $e)` + * would silently fail to catch the namespaced exception thrown by the library. + * The exception classes have no dependencies, so aliasing them here only loads + * `Errors.php` and never the (test-overridable) transport or cookie classes. + */ +call_user_func(function () { + $eager = array( + 'Castle\\Error', + 'Castle\\RequestError', + 'Castle\\ConfigurationError', + 'Castle\\ApiError', + 'Castle\\InternalServerError', + 'Castle\\BadRequest', + 'Castle\\UnauthorizedError', + 'Castle\\ForbiddenError', + 'Castle\\NotFoundError', + 'Castle\\InvalidParametersError', + 'Castle\\InvalidRequestTokenError', + 'Castle\\WebhookVerificationError', + ); + + $legacyByTarget = array_flip(castle_legacy_alias_map()); + + foreach ($eager as $target) { + if (!isset($legacyByTarget[$target])) { + continue; + } + $legacy = $legacyByTarget[$target]; + if (class_exists($legacy, false)) { + continue; + } + if (class_exists($target)) { + class_alias($target, $legacy); + } + } +}); diff --git a/phpunit.xml b/phpunit.xml index 4a519d4..9d4c1d1 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -1,21 +1,8 @@ - + ./test - - - - - - ./lib/Castle - ./lib/RestModel - - ./lib/Castle/CookieStore.php - ./lib/Castle/CurlTransport.php - - - - \ No newline at end of file + diff --git a/test/AliasesTest.php b/test/AliasesTest.php new file mode 100644 index 0000000..47918a8 --- /dev/null +++ b/test/AliasesTest.php @@ -0,0 +1,56 @@ + $namespaced) { + $this->assertTrue( + class_exists($namespaced) || interface_exists($namespaced), + "Expected {$namespaced} to be available" + ); + $this->assertTrue( + class_exists($legacy) || interface_exists($legacy), + "Expected legacy {$legacy} to be available" + ); + $this->assertTrue( + is_a($legacy, $namespaced, true), + "Expected {$legacy} to be an alias of {$namespaced}" + ); + } + } + + public function testLegacyFacadeSharesNamespacedDefinition() + { + $this->assertSame(\Castle\Castle::VERSION, Castle::VERSION); + $this->assertTrue(is_a('Castle', 'Castle\\Castle', true)); + } + + public function testLegacyExceptionsAreNamespacedExceptions() + { + $error = new Castle_WebhookVerificationError('boom'); + $this->assertInstanceOf('Castle\\WebhookVerificationError', $error); + $this->assertInstanceOf('Castle\\Error', $error); + $this->assertEquals('boom', $error->getMessage()); + } + + public function testNamespacedApiErrorCarriesMetadata() + { + $error = new \Castle\BadRequest('bad', 'invalid', 400); + $this->assertInstanceOf('Castle_BadRequest', $error); + $this->assertInstanceOf('Castle_ApiError', $error); + $this->assertEquals('invalid', $error->type); + $this->assertEquals(400, $error->httpStatus); + } + + public function testNamespacedWebhookVerifies() + { + $secret = 'secretkey'; + Castle::setApiKey($secret); + $body = '{"type":"$incident.confirmed"}'; + $signature = base64_encode(hash_hmac('sha256', $body, $secret, true)); + + $this->assertTrue(\Castle\Webhook::verify($body, $signature)); + } +} diff --git a/test/AuthenticateTest.php b/test/AuthenticateTest.php deleted file mode 100644 index 4a379b8..0000000 --- a/test/AuthenticateTest.php +++ /dev/null @@ -1,17 +0,0 @@ -save(); - $this->assertRequest('post', '/authenticate'); - } -} diff --git a/test/Castle.php b/test/Castle.php index 5cadec5..858f892 100644 --- a/test/Castle.php +++ b/test/Castle.php @@ -1,5 +1,12 @@ assertEquals('secretkey', Castle::getApiKey()); } - public function testTrack() - { - Castle_RequestTransport::setResponse(204, ''); - Castle::track(array('event' => '$login.failed')); - $this->assertRequest('post', '/track'); - } - public function testFilter() { Castle_RequestTransport::setResponse(204, ''); @@ -61,28 +57,328 @@ public function testRisk() $this->assertRequest('post', '/risk'); } - public function testAuthenticate() + public function testRiskIncludesContext() + { + Castle_RequestTransport::setResponse(200, '{}'); + Castle::risk(Array( + 'request_token' => 'token', + 'name' => '$login', + 'user' => Array('id' => 'abc') + )); + $request = $this->assertRequest('post', '/risk'); + $this->assertArrayHasKey('context', $request['params']); + } + + public function testFilterIncludesContext() + { + Castle_RequestTransport::setResponse(200, '{}'); + Castle::filter(Array( + 'request_token' => 'token', + 'name' => '$registration', + 'user' => Array('id' => 'abc') + )); + $request = $this->assertRequest('post', '/filter'); + $this->assertArrayHasKey('context', $request['params']); + } + + public function testLogIncludesContext() + { + Castle_RequestTransport::setResponse(204, ''); + Castle::log(Array( + 'request_token' => 'token', + 'name' => '$login', + 'status' => '$succeeded', + 'user' => Array('id' => 'abc') + )); + $request = $this->assertRequest('post', '/log'); + $this->assertArrayHasKey('context', $request['params']); + } + + public function testRiskIncludesSentAt() + { + Castle_RequestTransport::setResponse(200, '{}'); + Castle::risk(Array( + 'request_token' => 'token', + 'name' => '$login', + 'user' => Array('id' => 'abc') + )); + $request = $this->assertRequest('post', '/risk'); + $this->assertArrayHasKey('sent_at', $request['params']); + $this->assertSame( + 1, + preg_match('/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/', $request['params']['sent_at']) + ); + } + + public function testLogIncludesSentAt() + { + Castle_RequestTransport::setResponse(204, ''); + Castle::log(Array( + 'request_token' => 'token', + 'name' => '$login', + 'status' => '$succeeded', + 'user' => Array('id' => 'abc') + )); + $request = $this->assertRequest('post', '/log'); + $this->assertArrayHasKey('sent_at', $request['params']); + } + + public function testSentAtIsNotOverwritten() + { + Castle_RequestTransport::setResponse(200, '{}'); + Castle::filter(Array( + 'request_token' => 'token', + 'name' => '$registration', + 'user' => Array('id' => 'abc'), + 'sent_at' => '2020-01-01T00:00:00.000Z' + )); + $request = $this->assertRequest('post', '/filter'); + $this->assertEquals('2020-01-01T00:00:00.000Z', $request['params']['sent_at']); + } + + public function testCreateList() + { + Castle_RequestTransport::setResponse(201, '{ "id": "list-id", "name": "blocklist" }'); + $list = Castle::createList(Array( + 'name' => 'blocklist', + 'color' => '$red', + 'primary_field' => 'user.email' + )); + $this->assertRequest('post', '/lists'); + $this->assertEquals('list-id', $list['id']); + } + + public function testGetAllLists() + { + Castle_RequestTransport::setResponse(200, '[{ "id": "list-id" }]'); + $lists = Castle::getAllLists(); + $this->assertRequest('get', '/lists'); + $this->assertEquals('list-id', $lists[0]['id']); + } + + public function testGetList() + { + Castle_RequestTransport::setResponse(200, '{ "id": "list-id" }'); + Castle::getList('list-id'); + $this->assertRequest('get', '/lists/list-id'); + } + + public function testUpdateList() + { + Castle_RequestTransport::setResponse(200, '{ "id": "list-id" }'); + Castle::updateList('list-id', array('name' => 'renamed')); + $this->assertRequest('put', '/lists/list-id'); + } + + public function testDeleteList() + { + Castle_RequestTransport::setResponse(204, ''); + Castle::deleteList('list-id'); + $this->assertRequest('delete', '/lists/list-id'); + } + + public function testQueryList() + { + Castle_RequestTransport::setResponse(200, '{ "total_count": 0, "items": [] }'); + Castle::queryList(array('filters' => array())); + $this->assertRequest('post', '/lists/query'); + } + + public function testCreateListItem() + { + Castle_RequestTransport::setResponse(201, '{ "id": "item-id" }'); + Castle::createListItem('list-id', array( + 'author' => 'user:123', + 'primary_value' => 'user@example.com' + )); + $this->assertRequest('post', '/lists/list-id/items'); + } + + public function testCreateListItems() { - Castle_RequestTransport::setResponse(201, '{ "status": "approve" }'); - $auth = Castle::authenticate(Array( - 'user_id' => '1', - 'event' => '$login.failed' + Castle_RequestTransport::setResponse(201, '{ "items": [] }'); + Castle::createListItems('list-id', array('items' => array())); + $this->assertRequest('post', '/lists/list-id/items/batch'); + } + + public function testGetListItem() + { + Castle_RequestTransport::setResponse(200, '{ "id": "item-id" }'); + Castle::getListItem('list-id', 'item-id'); + $this->assertRequest('get', '/lists/list-id/items/item-id'); + } + + public function testUpdateListItem() + { + Castle_RequestTransport::setResponse(200, '{ "id": "item-id" }'); + Castle::updateListItem('list-id', 'item-id', array('comment' => 'note')); + $this->assertRequest('put', '/lists/list-id/items/item-id'); + } + + public function testQueryListItems() + { + Castle_RequestTransport::setResponse(200, '{ "total_count": 0, "items": [] }'); + Castle::queryListItems('list-id', array('filters' => array())); + $this->assertRequest('post', '/lists/list-id/items/query'); + } + + public function testCountListItems() + { + Castle_RequestTransport::setResponse(200, '{ "count": 0 }'); + Castle::countListItems('list-id', array('filters' => array())); + $this->assertRequest('post', '/lists/list-id/items/count'); + } + + public function testArchiveListItem() + { + Castle_RequestTransport::setResponse(204, ''); + Castle::archiveListItem('list-id', 'item-id'); + $this->assertRequest('delete', '/lists/list-id/items/item-id/archive'); + } + + public function testUnarchiveListItem() + { + Castle_RequestTransport::setResponse(204, ''); + Castle::unarchiveListItem('list-id', 'item-id'); + $this->assertRequest('put', '/lists/list-id/items/item-id/unarchive'); + } + + public function testRequestUserData() + { + Castle_RequestTransport::setResponse(200, '{ "id": "req-id" }'); + Castle::requestUserData(array( + 'identifier' => 'user@example.com', + 'identifier_type' => '$email' + )); + $this->assertRequest('post', '/privacy/users'); + } + + public function testDeleteUserData() + { + Castle_RequestTransport::setResponse(200, '{ "id": "req-id" }'); + Castle::deleteUserData(array( + 'identifier' => 'user@example.com', + 'identifier_type' => '$email' + )); + $this->assertRequest('delete', '/privacy/users'); + } + + public function testEventsSchema() + { + Castle_RequestTransport::setResponse(200, '{ "events": [] }'); + Castle::eventsSchema(); + $this->assertRequest('get', '/events/schema'); + } + + public function testQueryEvents() + { + Castle_RequestTransport::setResponse(200, '{ "data": [] }'); + Castle::queryEvents(array('filters' => array())); + $this->assertRequest('post', '/events/query'); + } + + public function testGroupEvents() + { + Castle_RequestTransport::setResponse(200, '{ "data": [] }'); + Castle::groupEvents(array('filters' => array())); + $this->assertRequest('post', '/events/group'); + } + + public function testRiskSuccessIncludesFailoverFalse() + { + Castle_RequestTransport::setResponse(200, '{ "policy": { "action": "allow" } }'); + $risk = Castle::risk(array( + 'request_token' => 'token', + 'name' => '$login', + 'user' => array('id' => 'abc') + )); + $this->assertFalse($risk->failover); + $this->assertNull($risk->failover_reason); + } + + public function testRiskFailsOverOnRequestError() + { + Castle_RequestTransport::setError(); + $risk = Castle::risk(array( + 'request_token' => 'token', + 'name' => '$login', + 'user' => array('id' => 'abc') + )); + $this->assertTrue($risk->failover); + $this->assertEquals('allow', $risk->action); + $this->assertEquals('allow', $risk->policy['action']); + $this->assertEquals('abc', $risk->user_id); + $this->assertEquals('Castle\\RequestError', $risk->failover_reason); + } + + public function testRiskFailsOverOnServerError() + { + Castle_RequestTransport::setResponse(500, '{ "type": "server_error" }'); + $risk = Castle::risk(array( + 'request_token' => 'token', + 'name' => '$login', + 'user' => array('id' => 'abc') )); - $this->assertRequest('post', '/authenticate'); - $this->assertEquals($auth->status, 'approve'); + $this->assertTrue($risk->failover); + $this->assertEquals('allow', $risk->action); + $this->assertEquals('Castle\\InternalServerError', $risk->failover_reason); } - public function testImpersonate() + public function testFilterFailoverUsesMatchingUserId() { - Castle_RequestTransport::setResponse(204, ''); - Castle::impersonate(array('user_id' => '1')); - $this->assertRequest('post', '/impersonate'); + Castle::setFailoverStrategy('deny'); + Castle_RequestTransport::setError(); + $filter = Castle::filter(array( + 'request_token' => 'token', + 'name' => '$registration', + 'matching_user_id' => 'mu-1' + )); + $this->assertTrue($filter->failover); + $this->assertEquals('deny', $filter->action); + $this->assertEquals('mu-1', $filter->user_id); + } + + public function testFailoverThrowStrategyReraises() + { + Castle::setFailoverStrategy('throw'); + Castle_RequestTransport::setError(); + $this->expectException(\Castle\RequestError::class); + Castle::risk(array( + 'request_token' => 'token', + 'name' => '$login', + 'user' => array('id' => 'abc') + )); + } + + public function testClientErrorsAreNotFailedOver() + { + Castle_RequestTransport::setResponse(422, '{ "type": "invalid_request_token" }'); + $this->expectException(\Castle\InvalidRequestTokenError::class); + Castle::risk(array( + 'request_token' => 'token', + 'name' => '$login', + 'user' => array('id' => 'abc') + )); + } + + public function testDoNotTrackReturnsAllowWithoutRequest() + { + Castle::disableTracking(); + $risk = Castle::risk(array( + 'request_token' => 'token', + 'name' => '$login', + 'user' => array('id' => 'abc') + )); + $this->assertTrue($risk->failover); + $this->assertEquals('allow', $risk->action); + $this->assertEquals('abc', $risk->user_id); + $this->assertEquals('Castle is set to do not track.', $risk->failover_reason); + $this->assertNull(Castle_RequestTransport::getLastRequest()); } - public function testImpersonateReset() + public function testSetFailoverStrategyRejectsUnknown() { - Castle_RequestTransport::setResponse(204, ''); - Castle::impersonate(array('user_id' => '1', 'reset' => true)); - $this->assertRequest('delete', '/impersonate'); + $this->expectException(\Castle\ConfigurationError::class); + Castle::setFailoverStrategy('bogus'); } } diff --git a/test/CookieStore.php b/test/CookieStore.php deleted file mode 100644 index b9491bd..0000000 --- a/test/CookieStore.php +++ /dev/null @@ -1,35 +0,0 @@ - '1ccf8dee-904b-4d20-8a88-55ded468bcc5', 'ip' => '8.8.8.8', 'headers' => array( 'User-Agent' => 'TestAgent', 'X-Castle-Client-Id' => '1ccf8dee-904b-4d20-8a88-55ded468bcc5' ), - 'user_agent' => 'TestAgent', 'library' => array( 'name' => 'castle-php', 'version' => Castle::VERSION @@ -30,7 +28,7 @@ public function contextProvider() { } public function contextJsonProvider() { - return array(array('{"client_id":"1ccf8dee-904b-4d20-8a88-55ded468bcc5","ip":"8.8.8.8","headers":{"User-Agent":"TestAgent","X-Castle-Client-Id":"1ccf8dee-904b-4d20-8a88-55ded468bcc5"},"user_agent":"TestAgent","library":{"name":"castle-php","version":"3.2.0"}}')); + return array(array('{"ip":"8.8.8.8","headers":{"User-Agent":"TestAgent","X-Castle-Client-Id":"1ccf8dee-904b-4d20-8a88-55ded468bcc5"},"library":{"name":"castle-php","version":"4.0.0"}}')); } /** @@ -54,60 +52,6 @@ public function testExtractJson($expected) { $this->assertEquals($expected, $actual); } - public function testExtractClientIDFromCookie() { - unset($_SERVER['HTTP_X_CASTLE_CLIENT_ID']); - $expected = $testUUID = '85B126D3-C706-4DBA-A352-883EFBCA9203'; - - $cookies = Castle::getCookieStore(); - $cookies->write('__cid', $testUUID); - - $actual = Castle_RequestContext::extractClientId(); - - $this->assertEquals($expected, $actual); - } - - public function testExtractClientIDInvalidFromCookie() { - unset($_SERVER['HTTP_X_CASTLE_CLIENT_ID']); - $expected = '_'; - $testInvalidID = " \t\n\r\0\x0B"; - - $cookies = Castle::getCookieStore(); - $cookies->write('__cid', $testInvalidID); - - $actual = Castle_RequestContext::extractClientId(); - - $this->assertEquals($expected, $actual); - } - - public function testExtractClientIDFromHeader() { - $expected = $testUUID = '85B126D3-C706-4DBA-A352-883EFBCA9203'; - - $_SERVER['HTTP_X_CASTLE_CLIENT_ID'] = $testUUID; - - $actual = Castle_RequestContext::extractClientId(); - - $this->assertEquals($expected, $actual); - } - - public function testExtractClientIDInvalidFromHeader() { - $expected = '_'; - $testInvalidID = " \t\n\r\0\x0B"; - - $_SERVER['HTTP_X_CASTLE_CLIENT_ID'] = $testInvalidID; - - $actual = Castle_RequestContext::extractClientId(); - - $this->assertEquals($expected, $actual); - } - - public function testExtractClientIDNoClientID() { - unset($_SERVER['HTTP_X_CASTLE_CLIENT_ID']); - $expected = '?'; - $actual = Castle_RequestContext::extractClientId(); - - $this->assertEquals($expected, $actual); - } - public function testExtractIp() { $expected = '8.8.8.8'; $actual = Castle_RequestContext::extractIp(); diff --git a/test/RequestTest.php b/test/RequestTest.php index d19f2ac..cf4a1fb 100644 --- a/test/RequestTest.php +++ b/test/RequestTest.php @@ -8,7 +8,6 @@ public static function setUpBeforeClass(): void $_SERVER['HTTP_USER_AGENT'] = 'TestAgent'; $_SERVER['REMOTE_ADDR'] = '8.8.8.8'; Castle::setApiKey('secretkey'); - Castle::setCurlOpts(array()); Castle::setUseAllowlist(false); } @@ -23,18 +22,6 @@ public function tearDown(): void Castle_RequestTransport::setResponse(); } - public function testCastleCurlOptions() - { - // Will not throw. - Castle::setCurlOpts(array(CURLOPT_CONNECTTIMEOUT => 1, - CURLOPT_CONNECTTIMEOUT_MS => 1000, - CURLOPT_TIMEOUT => 1, - CURLOPT_TIMEOUT_MS => 1000)); - // Will throw. - $this->expectException(Castle_CurlOptionError::class); - Castle::setCurlOpts(array(CURLOPT_USERAGENT => "BadBrowser/6.6.6b")); - } - public function testInvalidResponse() { Castle_RequestTransport::setResponse(200, '{invalid'); diff --git a/test/TestTransport.php b/test/TestTransport.php index 4e89933..e69ef83 100644 --- a/test/TestTransport.php +++ b/test/TestTransport.php @@ -1,6 +1,8 @@ $url ); $params = array_pop(self::$params); + if (isset($params['error'])) { + $this->rError = $params['error']; + $this->rMessage = $params['message']; + $this->rStatus = 0; + $this->rBody = null; + $this->rHeaders = array(); + return; + } $this->rBody = $params['body']; $this->rStatus = $params['code']; $this->rHeaders = $params['headers']; @@ -58,4 +68,11 @@ public static function setResponse($code=200, $body='', $headers=array()) { 'headers' => $headers ); } + + public static function setError($errno=28, $message='Operation timed out') { + self::$params[]= array( + 'error' => $errno, + 'message' => $message + ); + } } diff --git a/test/WebhookTest.php b/test/WebhookTest.php new file mode 100644 index 0000000..4dcffd5 --- /dev/null +++ b/test/WebhookTest.php @@ -0,0 +1,50 @@ +apiSecret); + // Keep REQUEST_TIME_FLOAT so php-timer (PHPUnit 8.5 on PHP 7.2) can still + // report timing after this suite resets the server globals. + $_SERVER = array('REQUEST_TIME_FLOAT' => microtime(true)); + } + + private function sign($body) + { + return base64_encode(hash_hmac('sha256', $body, $this->apiSecret, true)); + } + + public function testVerifyValidSignature() + { + $body = '{"type":"$incident.confirmed"}'; + $this->assertTrue(Castle_Webhook::verify($body, $this->sign($body))); + } + + public function testVerifyReadsSignatureFromHeader() + { + $body = '{"type":"$incident.confirmed"}'; + $_SERVER['HTTP_X_CASTLE_SIGNATURE'] = $this->sign($body); + $this->assertTrue(Castle_Webhook::verify($body)); + } + + public function testVerifyInvalidSignature() + { + $this->expectException(Castle_WebhookVerificationError::class); + Castle_Webhook::verify('{"type":"$incident.confirmed"}', 'invalid-signature'); + } + + public function testVerifyMissingSignature() + { + $this->expectException(Castle_WebhookVerificationError::class); + Castle_Webhook::verify('{"type":"$incident.confirmed"}'); + } + + public function testVerifyEmptyBody() + { + $this->expectException(Castle_WebhookVerificationError::class); + Castle_Webhook::verify('', $this->sign('')); + } +}