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..303d74c --- /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.4', '8.0', '8.1', '8.2', '8.3', '8.4'] + + 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..3643c94 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,14 @@ # Changelog +## 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.4 through 8.4 + ## 3.2.0 (2022-03-28) * updated ca-certs file diff --git a/README.md b/README.md index 4ade8b7..dd1e3ef 100644 --- a/README.md +++ b/README.md @@ -12,23 +12,25 @@ 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.4 through 8.4. + ## Getting started -Obtain the latest version of the Castle PHP bindings with: +Install the latest version with [Composer](https://getcomposer.org): ```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"); -``` - -Configure the library with your Castle API secret. +require_once 'vendor/autoload.php'; -```php Castle::setApiKey('YOUR_API_SECRET'); ``` @@ -71,6 +73,77 @@ 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', +]); +``` + +## 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. @@ -86,6 +159,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/composer.json b/composer.json index 0b1f73b..abba985 100644 --- a/composer.json +++ b/composer.json @@ -20,10 +20,14 @@ ] }, "require": { - "php": ">=7.2.0" + "php": ">=7.2.0", + "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..d19022a 100755 --- a/lib/Castle.php +++ b/lib/Castle.php @@ -25,3 +25,4 @@ function lcfirst( $str ) { require(dirname(__FILE__) . '/Castle/CurlTransport.php'); require(dirname(__FILE__) . '/Castle/RequestContext.php'); require(dirname(__FILE__) . '/Castle/Request.php'); +require(dirname(__FILE__) . '/Castle/Webhook.php'); diff --git a/lib/Castle/Castle.php b/lib/Castle/Castle.php index 7bbffd6..b62e181 100755 --- a/lib/Castle/Castle.php +++ b/lib/Castle/Castle.php @@ -2,7 +2,7 @@ abstract class Castle { - const VERSION = '3.2.0'; + const VERSION = '3.3.0'; const HEADER_COOKIE = 'Cookie'; const HEADER_USER_AGENT = 'User-Agent'; @@ -171,4 +171,210 @@ public static function risk(Array $attributes) } return new RestModel($response); } + + /** + * Lists API + */ + + /** + * Create a list + * @param Array $attributes 'name', 'color' and 'primary_field' are required + * @return Array + */ + public static function createList(Array $attributes) + { + return self::sendRequest('post', '/lists', $attributes); + } + + /** + * Fetch all lists + * @return Array + */ + public static function getAllLists() + { + return self::sendRequest('get', '/lists'); + } + + /** + * Fetch a single list + * @param String $listId + * @return Array + */ + public static function getList($listId) + { + 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); + } + + /** + * Delete a list + * @param String $listId + * @return Array + */ + public static function deleteList($listId) + { + return self::sendRequest('delete', self::listPath($listId)); + } + + /** + * Query lists + * @param Array $attributes + * @return Array + */ + public static function queryList(Array $attributes = array()) + { + return self::sendRequest('post', '/lists/query', $attributes); + } + + /** + * 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 + */ + + /** + * 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); + } + + private static function sendRequest($method, $path, $attributes = null) + { + $request = new Castle_Request(); + list($response, $request) = $request->send($method, $path, $attributes); + if ($request->rStatus == 204) { + $response = array(); + } + 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/Errors.php b/lib/Castle/Errors.php index 2284c9a..8c391b3 100755 --- a/lib/Castle/Errors.php +++ b/lib/Castle/Errors.php @@ -22,9 +22,12 @@ class Castle_CurlOptionError extends Castle_Error class Castle_ApiError extends Castle_Error { - public function __construct($msg, $type = null, $status = null) + public $type; + public $httpStatus; + + public function __construct($msg = '', $type = null, $status = null) { - parent::__construct($msg); + parent::__construct($msg === null ? '' : $msg); $this->type = $type; $this->httpStatus = $status; } @@ -59,3 +62,8 @@ class Castle_InvalidRequestTokenError extends Castle_InvalidParametersError { } + +class Castle_WebhookVerificationError extends Castle_Error +{ + +} diff --git a/lib/Castle/Request.php b/lib/Castle/Request.php index 50fe291..90029bc 100755 --- a/lib/Castle/Request.php +++ b/lib/Castle/Request.php @@ -15,8 +15,9 @@ public static function apiUrl($url='') public function handleApiError($response, $status) { - $type = $response['type']; - $msg = $response['message']; + $response = is_array($response) ? $response : array(); + $type = isset($response['type']) ? $response['type'] : null; + $msg = isset($response['message']) ? $response['message'] : null; switch ($status) { case 400: throw new Castle_BadRequest($msg, $type, $status); @@ -70,20 +71,40 @@ public function preFlightCheck() } } - 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(); } + 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 = ['/track', '/authenticate', '/impersonate', '/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(); diff --git a/lib/Castle/Webhook.php b/lib/Castle/Webhook.php new file mode 100644 index 0000000..608cb2e --- /dev/null +++ b/lib/Castle/Webhook.php @@ -0,0 +1,48 @@ + - + ./test - - - - - - ./lib/Castle - ./lib/RestModel - - ./lib/Castle/CookieStore.php - ./lib/Castle/CurlTransport.php - - - - \ No newline at end of file + diff --git a/test/Castle.php b/test/Castle.php index 5cadec5..a24ef8e 100644 --- a/test/Castle.php +++ b/test/Castle.php @@ -32,3 +32,4 @@ public function assertRequest($method, $url, $headers=null) require(dirname(__FILE__) . '/TestTransport.php'); require(dirname(__FILE__) . '/../lib/Castle/RequestContext.php'); require(dirname(__FILE__) . '/../lib/Castle/Request.php'); +require(dirname(__FILE__) . '/../lib/Castle/Webhook.php'); diff --git a/test/CastleTest.php b/test/CastleTest.php index 3d84b15..e5c06c7 100644 --- a/test/CastleTest.php +++ b/test/CastleTest.php @@ -85,4 +85,210 @@ public function testImpersonateReset() Castle::impersonate(array('user_id' => '1', 'reset' => true)); $this->assertRequest('delete', '/impersonate'); } + + 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->assertMatchesRegularExpression( + '/^\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, '{ "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'); + } } diff --git a/test/RequestContextTest.php b/test/RequestContextTest.php index 2b20008..fc6206b 100644 --- a/test/RequestContextTest.php +++ b/test/RequestContextTest.php @@ -30,7 +30,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('{"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.3.0"}}')); } /** diff --git a/test/WebhookTest.php b/test/WebhookTest.php new file mode 100644 index 0000000..385516f --- /dev/null +++ b/test/WebhookTest.php @@ -0,0 +1,48 @@ +apiSecret); + $_SERVER = array(); + } + + 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('')); + } +}