From 66ea266254f36e0c41bea02df9bf64160fef1a0e Mon Sep 17 00:00:00 2001 From: Tim Otten Date: Fri, 27 Mar 2015 20:27:05 -0700 Subject: [PATCH] CRM-16173 - WhitelistSubscriber - Filtering for API requests (entity/action/params) --- Civi/API/Provider/StaticProvider.php | 8 +- Civi/API/Subscriber/WhitelistSubscriber.php | 121 ++++++ Civi/API/WhitelistRule.php | 282 ++++++++++++ api/v3/utils.php | 2 +- .../Subscriber/WhitelistSubscriberTest.php | 404 ++++++++++++++++++ 5 files changed, 809 insertions(+), 8 deletions(-) create mode 100644 Civi/API/Subscriber/WhitelistSubscriber.php create mode 100644 Civi/API/WhitelistRule.php create mode 100644 tests/phpunit/Civi/API/Subscriber/WhitelistSubscriberTest.php diff --git a/Civi/API/Provider/StaticProvider.php b/Civi/API/Provider/StaticProvider.php index d9d6beede0..b1f7cd2337 100644 --- a/Civi/API/Provider/StaticProvider.php +++ b/Civi/API/Provider/StaticProvider.php @@ -135,13 +135,7 @@ class StaticProvider extends AdhocProvider { * @throws \API_Exception */ public function doGet($apiRequest) { - $id = @$apiRequest['params']['id']; - if ($id && isset($this->records[$id])) { - return civicrm_api3_create_success(array($id => $this->records[$id])); - } - else { - return civicrm_api3_create_success(array()); - } + return _civicrm_api3_basic_array_get($apiRequest['entity'], $apiRequest['params'], $this->records, 'id', $this->fields); } /** diff --git a/Civi/API/Subscriber/WhitelistSubscriber.php b/Civi/API/Subscriber/WhitelistSubscriber.php new file mode 100644 index 0000000000..74296a527a --- /dev/null +++ b/Civi/API/Subscriber/WhitelistSubscriber.php @@ -0,0 +1,121 @@ + array('onApiAuthorize', Events::W_EARLY), + Events::RESPOND => array('onApiRespond', Events::W_MIDDLE), + ); + } + + /** + * Array(WhitelistRule). + * + * @var array + */ + protected $rules; + + /** + * Array (scalar $reqId => WhitelistRule $rule). + * + * @var array + */ + protected $activeRules; + + /** + * @param array $rules + * Array of WhitelistRule. + * @see WhitelistRule + */ + public function __construct($rules) { + $this->rules = array(); + foreach ($rules as $rule) { + /** @var WhitelistRule $rule */ + if ($rule->isValid()) { + $this->rules[] = $rule; + } + else { + throw new \CRM_Core_Exception("Invalid rule"); + } + } + } + + /** + * Determine which, if any, whitelist rules apply this request. + * Reject unauthorized requests. + * + * @param AuthorizeEvent $event + * @throws \CRM_Core_Exception + */ + public function onApiAuthorize(AuthorizeEvent $event) { + $apiRequest = $event->getApiRequest(); + if (empty($apiRequest['params']['check_permissions']) || $apiRequest['params']['check_permissions'] !== 'whitelist') { + return; + } + foreach ($this->rules as $rule) { + if (TRUE === $rule->matches($apiRequest)) { + $this->activeRules[$apiRequest['id']] = $rule; + return; + } + } + throw new \CRM_Core_Exception('The request does not match any active API authorizations.'); + } + + /** + * Apply any filtering rules based on the chosen whitelist rule. + * @param RespondEvent $event + */ + public function onApiRespond(RespondEvent $event) { + $apiRequest = $event->getApiRequest(); + $id = $apiRequest['id']; + if (isset($this->activeRules[$id])) { + $event->setResponse($this->activeRules[$id]->filter($apiRequest, $event->getResponse())); + unset($this->activeRules[$id]); + } + } + +} diff --git a/Civi/API/WhitelistRule.php b/Civi/API/WhitelistRule.php new file mode 100644 index 0000000000..428e119457 --- /dev/null +++ b/Civi/API/WhitelistRule.php @@ -0,0 +1,282 @@ + 'Contact', + * 'actions' => array('get','getsingle'), + * 'required' => array('contact_type' => 'Organization'), + * 'fields' => array('id', 'display_name', 'sort_name', 'created_date'), + * )); + * @endcode + * + * This rule would allow API requests that attempt to get contacts of type "Organization", + * but only a handful of fields ('id', 'display_name', 'sort_name', 'created_date') + * can be filtered or returned. + * + * Class WhitelistRule + * @package Civi\API\Subscriber + */ +class WhitelistRule { + + static $IGNORE_FIELDS = array( + 'check_permissions', + 'debug', + 'offset', + 'option_offset', + 'option_limit', + 'option_sort', + 'options', + 'return', + 'rowCount', + 'sequential', + 'sort', + 'version', + ); + + /** + * Create a batch of rules from an array. + * + * @param array $rules + * @return array + */ + public static function createAll($rules) { + $whitelist = array(); + foreach ($rules as $rule) { + $whitelist[] = new WhitelistRule($rule); + } + return $whitelist; + } + + /** + * @var int + */ + public $version; + + /** + * Entity name or '*' (all entities) + * + * @var string + */ + public $entity; + + /** + * List of actions which match, or '*' (all actions) + * + * @var string|array + */ + public $actions; + + /** + * List of key=>value pairs that *must* appear in $params. + * + * If there are no required fields, use an empty array. + * + * @var array + */ + public $required; + + /** + * List of fields which may be optionally inputted or returned, or '*" (all fields) + * + * @var array + */ + public $fields; + + public function __construct($ruleSpec) { + $this->version = $ruleSpec['version']; + + if ($ruleSpec['entity'] === '*') { + $this->entity = '*'; + } + else { + $this->entity = Request::normalizeEntityName($ruleSpec['entity'], $ruleSpec['version']); + } + + if ($ruleSpec['actions'] === '*') { + $this->actions = '*'; + } + else { + $this->actions = array(); + foreach ((array) $ruleSpec['actions'] as $action) { + $this->actions[] = Request::normalizeActionName($action, $ruleSpec['version']); + } + } + + $this->required = $ruleSpec['required']; + $this->fields = $ruleSpec['fields']; + } + + /** + * @return bool + */ + public function isValid() { + if (empty($this->version)) { + return FALSE; + } + if (empty($this->entity)) { + return FALSE; + } + if (!is_array($this->actions) && $this->actions !== '*') { + return FALSE; + } + if (!is_array($this->fields) && $this->fields !== '*') { + return FALSE; + } + if (!is_array($this->required)) { + return FALSE; + } + return TRUE; + } + + /** + * @param array $apiRequest + * Parsed API request. + * @return string|TRUE + * If match, return TRUE. Otherwise, return a string with an error code. + */ + public function matches($apiRequest) { + if (!$this->isValid()) { + return 'invalid'; + } + + if ($this->version != $apiRequest['version']) { + return 'version'; + } + if ($this->entity !== '*' && $this->entity !== $apiRequest['entity']) { + return 'entity'; + } + if ($this->actions !== '*' && !in_array($apiRequest['action'], $this->actions)) { + return 'action'; + } + + // These params *must* be included for the API request to proceed. + foreach ($this->required as $param => $value) { + if (!isset($apiRequest['params'][$param])) { + return 'required-missing-' . $param; + } + if ($value !== '*' && $apiRequest['params'][$param] != $value) { + return 'required-wrong-' . $param; + } + } + + // These params *may* be included at the caller's discretion + if ($this->fields !== '*') { + $activatedFields = array_keys($apiRequest['params']); + $activatedFields = preg_grep('/^api\./', $activatedFields, PREG_GREP_INVERT); + if ($apiRequest['action'] == 'get') { + // Kind'a silly we need to (re(re))parse here for each rule; would be more + // performant if pre-parsed by Request::create(). + $options = _civicrm_api3_get_options_from_params($apiRequest['params'], TRUE, $apiRequest['entity'], 'get'); + $return = \CRM_Utils_Array::value('return', $options, array()); + $activatedFields = array_merge($activatedFields, array_keys($return)); + } + + $unknowns = array_diff( + $activatedFields, + array_keys($this->required), + $this->fields, + self::$IGNORE_FIELDS + ); + + if (!empty($unknowns)) { + return 'unknown-' . implode(',', $unknowns); + } + } + + return TRUE; + } + + /** + * Ensure that the return values comply with the whitelist's + * "fields" policy. + * + * Most API's follow a convention where the result includes + * a 'values' array (which in turn is a list of records). Unfortunately, + * some don't. If the API result doesn't meet our expectation, + * then we probably don't know what's going on, so we abort the + * request. + * + * This will probably break some of the layered-sugar APIs (like + * getsingle, getvalue). Just use the meat-and-potatoes API instead. + * Or craft a suitably targeted patch. + * + * @param array $apiRequest + * API request. + * @param array $apiResult + * API result. + * @return array + * Modified API result. + * @throws \API_Exception + */ + public function filter($apiRequest, $apiResult) { + if ($this->fields === '*') { + return $apiResult; + } + if (isset($apiResult['values']) && empty($apiResult['values'])) { + // No data; filtering doesn't matter. + return $apiResult; + } + if (is_array($apiResult['values'])) { + $firstRow = \CRM_Utils_Array::first($apiResult['values']); + if (is_array($firstRow)) { + $fields = $this->filterFields(array_keys($firstRow)); + $apiResult['values'] = \CRM_Utils_Array::filterColumns($apiResult['values'], $fields); + return $apiResult; + } + } + throw new \API_Exception(sprintf('Filtering failed for %s.%s. Unrecognized result format.', $apiRequest['entity'], $apiRequest['action'])); + } + + /** + * Determine which elements in $keys are acceptable under + * the whitelist policy. + * + * @param array $keys + * List of possible keys. + * @return array + * List of acceptable keys. + */ + protected function filterFields($keys) { + $r = array(); + foreach ($keys as $key) { + if (in_array($key, $this->fields)) { + $r[] = $key; + } + elseif (preg_match('/^api\./', $key)) { + $r[] = $key; + } + } + return $r; + } + +} diff --git a/api/v3/utils.php b/api/v3/utils.php index c1d78b2e99..6231715f80 100644 --- a/api/v3/utils.php +++ b/api/v3/utils.php @@ -2300,7 +2300,7 @@ function _civicrm_api3_basic_array_get($entity, $params, $records, $idCol, $fiel if ($k == 'id') { $k = $idCol; } - if (in_array($k, $fields) && $record[$k] !== $v) { + if (in_array($k, $fields) && $record[$k] != $v) { $match = FALSE; break; } diff --git a/tests/phpunit/Civi/API/Subscriber/WhitelistSubscriberTest.php b/tests/phpunit/Civi/API/Subscriber/WhitelistSubscriberTest.php new file mode 100644 index 0000000000..9cfc9e3eb5 --- /dev/null +++ b/tests/phpunit/Civi/API/Subscriber/WhitelistSubscriberTest.php @@ -0,0 +1,404 @@ + array( + 'id' => 1, + 'widget_type' => 'foo', + 'provider' => 'george jetson', + 'title' => 'first widget', + 'comments' => 'this widget is the bomb', + ), + 2 => array( + 'id' => 2, + 'widget_type' => 'bar', + 'provider' => 'george jetson', + 'title' => 'second widget', + 'comments' => 'this widget is a bomb', + ), + 3 => array( + 'id' => 3, + 'widget_type' => 'foo', + 'provider' => 'cosmo spacely', + 'title' => 'third widget', + 'comments' => 'omg, that thing is a bomb! widgets are bombs! get out!', + ), + 8 => array( + 'id' => 8, + 'widget_type' => 'bax', + 'provider' => 'cosmo spacely', + 'title' => 'fourth widget', + 'comments' => 'todo: rebuild garage', + ), + ); + + $recs['sprocket'] = array( + 1 => array( + 'id' => 1, + 'sprocket_type' => 'whiz', + 'provider' => 'cosmo spacely', + 'title' => 'first sprocket', + 'comment' => 'this sprocket is so good i could eat it up', + 'widget_id' => 2, + ), + 5 => array( + 'id' => 5, + 'sprocket_type' => 'bang', + 'provider' => 'george jetson', + 'title' => 'second sprocket', + 'comment' => 'this green sprocket was made by soylent', + 'widget_id' => 2, + ), + 7 => array( + 'id' => 7, + 'sprocket_type' => 'quux', + 'provider' => 'cosmo spacely', + 'title' => 'third sprocket', + 'comment' => 'sprocket green is people! sprocket green is people!', + 'widget_id' => 3, + ), + 8 => array( + 'id' => 8, + 'sprocket_type' => 'baz', + 'provider' => 'george jetson', + 'title' => 'fourth sprocket', + 'comment' => 'see also: cooking.com/hannibal/1981420-sprocket-fava', + 'widget_id' => 3, + ), + ); + + return $recs; + } + + public function restrictionCases() { + $calls = $rules = array(); + $recs = $this->getFixtures(); + + $calls['Widget.get-all'] = array( + 'entity' => 'Widget', + 'action' => 'get', + 'params' => array('version' => 3), + 'expectedResults' => $recs['widget'], + ); + $calls['Widget.get-foo'] = array( + 'entity' => 'Widget', + 'action' => 'get', + 'params' => array('version' => 3, 'widget_type' => 'foo'), + 'expectedResults' => array(1 => $recs['widget'][1], 3 => $recs['widget'][3]), + ); + $calls['Widget.get-spacely'] = array( + 'entity' => 'Widget', + 'action' => 'get', + 'params' => array('version' => 3, 'provider' => 'cosmo spacely'), + 'expectedResults' => array(3 => $recs['widget'][3], 8 => $recs['widget'][8]), + ); + $calls['Widget.get-spacely=>title'] = array( + 'entity' => 'Widget', + 'action' => 'get', + 'params' => array('version' => 3, 'provider' => 'cosmo spacely', 'return' => array('title')), + 'expectedResults' => array( + 3 => array('id' => 3, 'title' => 'third widget'), + 8 => array('id' => 8, 'title' => 'fourth widget'), + ), + ); + $calls['Widget.get-spacely-foo'] = array( + 'entity' => 'Widget', + 'action' => 'get', + 'params' => array('version' => 3, 'provider' => 'cosmo spacely', 'widget_type' => 'foo'), + 'expectedResults' => array(3 => $recs['widget'][3]), + ); + $calls['Sprocket.get-all'] = array( + 'entity' => 'Sprocket', + 'action' => 'get', + 'params' => array('version' => 3), + 'expectedResults' => $recs['sprocket'], + ); + $calls['Widget.get-bar=>title + Sprocket.get=>provider'] = array( + 'entity' => 'Widget', + 'action' => 'get', + 'params' => array( + 'version' => 3, + 'widget_type' => 'bar', + 'return' => array('title'), + 'api.Sprocket.get' => array( + 'widget_id' => '$value.id', + 'return' => array('provider'), + ), + ), + 'expectedResults' => array( + 2 => array( + 'id' => 2, + 'title' => 'second widget', + 'api.Sprocket.get' => array( + 'count' => 2, + 'version' => 3, + 'values' => array( + 0 => array('id' => 1, 'provider' => 'cosmo spacely'), + 1 => array('id' => 5, 'provider' => 'george jetson'), + ), + // This is silly: + 'undefined_fields' => array('entity_id', 'entity_table', 'widget_id', 'api.has_parent'), + ), + ), + ), + ); + + $rules['*.*'] = array( + 'version' => 3, + 'entity' => '*', + 'actions' => '*', + 'required' => array(), + 'fields' => '*', + ); + $rules['Widget.*'] = array( + 'version' => 3, + 'entity' => 'Widget', + 'actions' => '*', + 'required' => array(), + 'fields' => '*', + ); + $rules['Sprocket.*'] = array( + 'version' => 3, + 'entity' => 'Sprocket', + 'actions' => '*', + 'required' => array(), + 'fields' => '*', + ); + $rules['Widget.get'] = array( + 'version' => 3, + 'entity' => 'Widget', + 'actions' => 'get', + 'required' => array(), + 'fields' => '*', + ); + $rules['Sprocket.get'] = array( + 'version' => 3, + 'entity' => 'Sprocket', + 'actions' => 'get', + 'required' => array(), + 'fields' => '*', + ); + $rules['Sprocket.get=>title,misc'] = array( + 'version' => 3, + 'entity' => 'Sprocket', + 'actions' => 'get', + 'required' => array(), + // To call api.Sprocket.get via chaining, you must accept superfluous fields. + // It would be a mistake for the whitelist mechanism to approve these + // automatically, so instead we have to enumerate them. Ideally, ChainSubscriber + // wouldn't generate superfluous fields. + 'fields' => array('id', 'title', 'widget_id', 'entity_id', 'entity_table'), + ); + $rules['Sprocket.get=>provider,misc'] = array( + 'version' => 3, + 'entity' => 'Sprocket', + 'actions' => 'get', + 'required' => array(), + // To call api.Sprocket.get via chaining, you must accept superfluous fields. + // It would be a mistake for the whitelist mechanism to approve these + // automatically, so instead we have to enumerate them. Ideally, ChainSubscriber + // wouldn't generate superfluous fields. + 'fields' => array('id', 'provider', 'widget_id', 'entity_id', 'entity_table'), + ); + $rules['Widget.get-foo'] = array( + 'version' => 3, + 'entity' => 'Widget', + 'actions' => 'get', + 'required' => array('widget_type' => 'foo'), + 'fields' => '*', + ); + $rules['Widget.get-spacely'] = array( + 'version' => 3, + 'entity' => 'Widget', + 'actions' => 'get', + 'required' => array('provider' => 'cosmo spacely'), + 'fields' => '*', + ); + $rules['Widget.get-bar=>title'] = array( + 'version' => 3, + 'entity' => 'Widget', + 'actions' => 'get', + 'required' => array('widget_type' => 'bar'), + 'fields' => array('id', 'title'), + ); + $rules['Widget.get-spacely=>title'] = array( + 'version' => 3, + 'entity' => 'Widget', + 'actions' => 'get', + 'required' => array('provider' => 'cosmo spacely'), + 'fields' => array('id', 'title'), + ); + $rules['Widget.get-spacely=>widget_type'] = array( + 'version' => 3, + 'entity' => 'Widget', + 'actions' => 'get', + 'required' => array('provider' => 'cosmo spacely'), + 'fields' => array('id', 'widget_type'), + ); + $rules['Widget.getcreate'] = array( + 'version' => 3, + 'entity' => 'Widget', + 'actions' => array('get', 'create'), + 'required' => array(), + 'fields' => '*', + ); + $rules['Widget.create'] = array( + 'version' => 3, + 'entity' => 'Widget', + 'actions' => 'create', + 'required' => array(), + 'fields' => '*', + ); + + $c = array(); + + $c[] = array($calls['Widget.get-all'], array($rules['*.*']), TRUE); + $c[] = array($calls['Widget.get-all'], array($rules['Widget.*']), TRUE); + $c[] = array($calls['Widget.get-all'], array($rules['Widget.get']), TRUE); + $c[] = array($calls['Widget.get-all'], array($rules['Widget.create']), FALSE); + $c[] = array($calls['Widget.get-all'], array($rules['Widget.getcreate']), TRUE); + $c[] = array($calls['Widget.get-all'], array($rules['Sprocket.*']), FALSE); + + $c[] = array($calls['Sprocket.get-all'], array($rules['*.*']), TRUE); + $c[] = array($calls['Sprocket.get-all'], array($rules['Sprocket.*']), TRUE); + $c[] = array($calls['Sprocket.get-all'], array($rules['Widget.*']), FALSE); + $c[] = array($calls['Sprocket.get-all'], array($rules['Widget.get']), FALSE); + + $c[] = array($calls['Widget.get-spacely'], array($rules['Widget.*']), TRUE); + $c[] = array($calls['Widget.get-spacely'], array($rules['Widget.get-spacely']), TRUE); + $c[] = array($calls['Widget.get-spacely'], array($rules['Widget.get-foo']), FALSE); + $c[] = array($calls['Widget.get-spacely'], array($rules['Widget.get-foo'], $rules['Sprocket.*']), FALSE); + $c[] = array( + // we do a broad get, but 'fields' filtering kicks in and restricts the results + array_merge($calls['Widget.get-spacely'], array( + 'expectedResults' => $calls['Widget.get-spacely=>title']['expectedResults'], + )), + array($rules['Widget.get-spacely=>title']), + TRUE, + ); + + $c[] = array($calls['Widget.get-foo'], array($rules['Widget.*']), TRUE); + $c[] = array($calls['Widget.get-foo'], array($rules['Widget.get-foo']), TRUE); + $c[] = array($calls['Widget.get-foo'], array($rules['Widget.get-spacely']), FALSE); + + $c[] = array($calls['Widget.get-spacely=>title'], array($rules['*.*']), TRUE); + $c[] = array($calls['Widget.get-spacely=>title'], array($rules['Widget.*']), TRUE); + $c[] = array($calls['Widget.get-spacely=>title'], array($rules['Widget.get-spacely']), TRUE); + $c[] = array($calls['Widget.get-spacely=>title'], array($rules['Widget.get-spacely=>title']), TRUE); + + // We request returning title field, but the rule doesn't allow title to be returned. + // Need it to fail so that control could pass to another rule which does allow it. + $c[] = array($calls['Widget.get-spacely=>title'], array($rules['Widget.get-spacely=>widget_type']), FALSE); + + // One rule would allow, one would be irrelevant. The order of the two rules shouldn't matter. + $c[] = array( + $calls['Widget.get-spacely=>title'], + array($rules['Widget.get-spacely=>widget_type'], $rules['Widget.get-spacely=>title']), + TRUE, + ); + $c[] = array( + $calls['Widget.get-spacely=>title'], + array($rules['Widget.get-spacely=>title'], $rules['Widget.get-spacely=>widget_type']), + TRUE, + ); + + $c[] = array($calls['Widget.get-bar=>title + Sprocket.get=>provider'], array($rules['*.*']), TRUE); + $c[] = array($calls['Widget.get-bar=>title + Sprocket.get=>provider'], array($rules['Widget.get-bar=>title'], $rules['Sprocket.get']), TRUE); + $c[] = array($calls['Widget.get-bar=>title + Sprocket.get=>provider'], array($rules['Widget.get'], $rules['Sprocket.get=>title,misc']), FALSE); + $c[] = array($calls['Widget.get-bar=>title + Sprocket.get=>provider'], array($rules['Widget.get'], $rules['Sprocket.get=>provider,misc']), TRUE); + $c[] = array($calls['Widget.get-bar=>title + Sprocket.get=>provider'], array($rules['Widget.get-foo'], $rules['Sprocket.get']), FALSE); + $c[] = array($calls['Widget.get-bar=>title + Sprocket.get=>provider'], array($rules['Widget.get']), FALSE); + + return $c; + } + + protected function setUp() { + parent::setUp(); + } + + /** + * @param array $apiRequest + * Array(entity=>$,action=>$,params=>$,expectedResults=>$). + * @param array $rules + * Whitelist - list of allowed API calls/patterns. + * @param bool $expectSuccess + * TRUE if the call should succeed. + * Success implies that the 'expectedResults' are returned. + * Failure implies that the standard error message is returned. + * @dataProvider restrictionCases + */ + public function testEach($apiRequest, $rules, $expectSuccess) { + \CRM_Core_DAO_AllCoreTables::init(TRUE); + + $recs = $this->getFixtures(); + + \CRM_Core_DAO_AllCoreTables::registerEntityType('Widget', 'CRM_Fake_DAO_Widget', 'fake_widget'); + $widgetProvider = new \Civi\API\Provider\StaticProvider(3, 'Widget', + array('id', 'widget_type', 'provider', 'title'), + array(), + $recs['widget'] + ); + + \CRM_Core_DAO_AllCoreTables::registerEntityType('Sprocket', 'CRM_Fake_DAO_Sprocket', 'fake_sprocket'); + $sprocketProvider = new \Civi\API\Provider\StaticProvider( + 3, + 'Sprocket', + array('id', 'sprocket_type', 'widget_id', 'provider', 'title', 'comment'), + array(), + $recs['sprocket'] + ); + + $whitelist = WhitelistRule::createAll($rules); + + $dispatcher = new EventDispatcher(); + $kernel = new Kernel($dispatcher); + $kernel->registerApiProvider($sprocketProvider); + $kernel->registerApiProvider($widgetProvider); + $dispatcher->addSubscriber(new WhitelistSubscriber($whitelist)); + $dispatcher->addSubscriber(new ChainSubscriber()); + + $apiRequest['params']['debug'] = 1; + $apiRequest['params']['check_permissions'] = 'whitelist'; + $result = $kernel->run($apiRequest['entity'], $apiRequest['action'], $apiRequest['params']); + + if ($expectSuccess) { + $this->assertAPISuccess($result); + $this->assertTrue(is_array($apiRequest['expectedResults'])); + $this->assertTreeEquals($apiRequest['expectedResults'], $result['values']); + } + else { + $this->assertAPIFailure($result); + $this->assertRegExp('/The request does not match any active API authorizations./', $result['error_message']); + } + } + +} -- 2.25.1