From 56154d3614e41fab8ddef50a4ec327d286073401 Mon Sep 17 00:00:00 2001 From: Tim Otten Date: Fri, 5 Dec 2014 22:44:41 -0800 Subject: [PATCH] CRM-15578 - Attachment API - Add support for CRUD'ing files The File API is entirely inadequate to managing files. The Attachment API provides a pseudo-entity which represents a record in civicrm_file combined with a record in civicrm_entity_file as well as the underlying file content. (See also: discussions on civicrm-api mailing list circa early Dec 2014.) --- CRM/Core/DAO/permissions.php | 21 + Civi/API/Kernel.php | 28 + .../API/Subscriber/DynamicFKAuthorization.php | 262 ++++++++ Civi/Core/Container.php | 11 + api/v3/Attachment.php | 425 +++++++++++++ .../Subscriber/DynamicFKAuthorizationTest.php | 196 ++++++ tests/phpunit/api/v3/AttachmentTest.php | 568 ++++++++++++++++++ .../phpunit/api/v3/SyntaxConformanceTest.php | 1 + 8 files changed, 1512 insertions(+) create mode 100644 Civi/API/Subscriber/DynamicFKAuthorization.php create mode 100644 api/v3/Attachment.php create mode 100644 tests/phpunit/Civi/API/Subscriber/DynamicFKAuthorizationTest.php create mode 100644 tests/phpunit/api/v3/AttachmentTest.php diff --git a/CRM/Core/DAO/permissions.php b/CRM/Core/DAO/permissions.php index 66ef77def0..0b26d9966f 100644 --- a/CRM/Core/DAO/permissions.php +++ b/CRM/Core/DAO/permissions.php @@ -66,6 +66,10 @@ function _civicrm_api3_permissions($entity, $action, &$params) { 'default' => array('administer CiviCRM'), ); + $permissions['attachment'] = array( + 'default' => array('access CiviCRM', 'access AJAX API'), + ); + // Contact permissions $permissions['contact'] = array( 'create' => array( @@ -229,6 +233,23 @@ function _civicrm_api3_permissions($entity, $action, &$params) { $permissions['group_nesting'] = $permissions['group']; $permissions['group_organization'] = $permissions['group']; + // CiviMail Permissions + $permissions['mailing'] = array( + 'get' => array( + 'access CiviCRM', + 'access CiviMail', + ), + 'delete' => array( + 'access CiviCRM', + 'access CiviMail', + 'delete in CiviMail', + ), + 'default' => array( + 'access CiviCRM', + 'access CiviMail', + ), + ); + // Membership permissions $permissions['membership'] = array( 'get' => array( diff --git a/Civi/API/Kernel.php b/Civi/API/Kernel.php index 59f5e6c597..76a91df225 100644 --- a/Civi/API/Kernel.php +++ b/Civi/API/Kernel.php @@ -112,6 +112,34 @@ class Kernel { } } + /** + * Determine if a hypothetical API call would be authorized. + * + * @param string $entity + * type of entities to deal with + * @param string $action + * create, get, delete or some special action name. + * @param array $params + * array to be passed to function + * @param null $extra + * @return bool TRUE if authorization would succeed + * @throws \Exception + */ + public function runAuthorize($entity, $action, $params, $extra = NULL) { + $apiProvider = NULL; + $apiRequest = Request::create($entity, $action, $params, $extra); + + try { + $this->boot(); + list($apiProvider, $apiRequest) = $this->resolve($apiRequest); + $this->authorize($apiProvider, $apiRequest); + return true; + } + catch (\Civi\API\Exception\UnauthorizedException $e) { + return false; + } + } + public function boot() { require_once ('api/v3/utils.php'); require_once 'api/Exception.php'; diff --git a/Civi/API/Subscriber/DynamicFKAuthorization.php b/Civi/API/Subscriber/DynamicFKAuthorization.php new file mode 100644 index 0000000000..ff121808b9 --- /dev/null +++ b/Civi/API/Subscriber/DynamicFKAuthorization.php @@ -0,0 +1,262 @@ + array( + array('onApiAuthorize', Events::W_EARLY), + ), + ); + } + + /** + * @var \Civi\API\Kernel + */ + protected $kernel; + + /** + * @var string, the entity for which we want to manage permissions + */ + protected $entityName; + + /** + * @var array the actions for which we want to manage permissions + */ + protected $actions; + + /** + * @var string, SQL; a query which looks up the related entity + * + * ex: "SELECT if(cf.id,1,0) as is_valid, cef.entity_table, cef.entity_id + * FROM civicrm_file cf + * INNER JOIN civicrm_entity_file cef ON cf.id = cef.file_id + * WHERE cf.id = %1" + * + * Note: %1 is a parameter + * Note: There are three parameters + * - is_valid: "1" if %1 identifies an actual record; otherwise "0" + * - entity_table: NULL or the name of a related table + * - entity_id: NULL or the ID of a row in the related table + */ + protected $lookupDelegateSql; + + /** + * @var array list of related tables for which FKs are allowed + */ + protected $allowedDelegates; + + /** + * @param \Civi\API\Kernel $kernel + * @param string $entityName the entity for which we want to manage permissions (e.g. "File" or "Note") + * @param array $actions the actions for which we want to manage permissions (e.g. "create", "get", "delete") + * @param string $lookupDelegateSql see docblock in DynamicFKAuthorization::$lookupDelegateSql + * @param array|NULL $allowedDelegates e.g. "civicrm_mailing","civicrm_activity"; NULL to allow any + */ + function __construct($kernel, $entityName, $actions, $lookupDelegateSql, $allowedDelegates = NULL) { + $this->kernel = $kernel; + $this->entityName = $entityName; + $this->actions = $actions; + $this->lookupDelegateSql = $lookupDelegateSql; + $this->allowedDelegates = $allowedDelegates; + } + + /** + * @param \Civi\API\Event\AuthorizeEvent $event + * @throws \API_Exception + * @throws \Civi\API\Exception\UnauthorizedException + */ + public function onApiAuthorize(\Civi\API\Event\AuthorizeEvent $event) { + $apiRequest = $event->getApiRequest(); + if ($apiRequest['version'] == 3 && $apiRequest['entity'] == $this->entityName && in_array(strtolower($apiRequest['action']), $this->actions)) { + if (/*!$isTrusted */ + empty($apiRequest['params']['id']) && empty($apiRequest['params']['entity_table']) + ) { + throw new \API_Exception("Mandatory key(s) missing from params array: 'id' or 'entity_table'"); + } + + if (isset($apiRequest['params']['id'])) { + list($isValidId, $entityTable, $entityId) = $this->getDelegate($apiRequest['params']['id']); + if ($isValidId && $entityTable && $entityId) { + $this->authorizeDelegate($apiRequest['action'], $entityTable, $entityId, $apiRequest); + $this->preventReassignment($apiRequest['params']['id'], $entityTable, $entityId, $apiRequest); + return; + } + elseif ($isValidId) { + throw new \API_Exception("Failed to match record to related entity"); + } elseif (!$isValidId && strtolower($apiRequest['action']) == 'get') { + // This matches will be an empty set; doesn't make a difference if we reject or accept + // To pass SyntaxConformanceTest, we won't veto "get" on empty-set + return; + } + } + + if (isset($apiRequest['params']['entity_table'])) { + $this->authorizeDelegate( + $apiRequest['action'], + $apiRequest['params']['entity_table'], + \CRM_Utils_Array::value('entity_id', $apiRequest['params'], NULL), + $apiRequest + ); + return; + } + + throw new \API_Exception("Failed to run permission check"); + } + } + + /** + * @param string $action e.g. "create" + * @param string $entityTable e.g. "civicrm_mailing" + * @param int|NULL $entityId + * @param array $apiRequest + * @throws \API_Exception + * @throws \Civi\API\Exception\UnauthorizedException + */ + public function authorizeDelegate($action, $entityTable, $entityId, $apiRequest) { + $entity = $this->getDelegatedEntityName($entityTable); + if (!$entity) { + throw new \API_Exception("Failed to run permission check: Unrecognized target entity ($entityTable)"); + } + + if ($this->isTrusted($apiRequest)) { + return; + } + + $params = array('check_permissions' => 1); + if ($entityId) { + $params['id'] = $entityId; + } + + if (!$this->kernel->runAuthorize($entity, $this->getDelegatedAction($action), $params)) { + throw new \Civi\API\Exception\UnauthorizedException("Authorization failed on ($entity,$entityId)"); + } + } + + /** + * If the request attempts to change the entity_table/entity_id of an existing record, then generate an error. + * + * @param int $fileId the main record being changed + * @param string $entityTable the saved FK + * @param int $entityId the saved FK + * @param array $apiRequest + * @throws \API_Exception + */ + public function preventReassignment($fileId, $entityTable, $entityId, $apiRequest) { + if (strtolower($apiRequest['action']) == 'create' && $fileId && !$this->isTrusted($apiRequest)) { + if (isset($apiRequest['params']['entity_table']) && $entityTable != $apiRequest['params']['entity_table']) { + throw new \API_Exception("Cannot modify entity_table"); + } + if (isset($apiRequest['params']['entity_id']) && $entityId != $apiRequest['params']['entity_id']) { + throw new \API_Exception("Cannot modify entity_id"); + } + } + } + + /** + * @param string $entityTable e.g. "civicrm_mailing" or "civicrm_activity" + * @return string|NULL e.g. "Mailing" or "Activity" + */ + public function getDelegatedEntityName($entityTable) { + if ($this->allowedDelegates === NULL || in_array($entityTable, $this->allowedDelegates)) { + $className = \CRM_Core_DAO_AllCoreTables::getClassForTable($entityTable); + if ($className) { + $entityName = \CRM_Core_DAO_AllCoreTables::getBriefName($className); + if ($entityName) { + return $entityName; + } + } + } + return NULL; + } + + /** + * @param string $action e.g. "create" ("When running *create* on a file...") + * @return string e.g. "create" ("Check for *create* permission on the mailing to which it is attached.") + */ + public function getDelegatedAction($action) { + switch ($action) { + case 'get': + // reading attachments requires reading the other entity + return 'get'; + break; + case 'create': + case 'delete': + // creating/updating/deleting attachments requires editing the other entity + return 'create'; + default: + return $action; + } + } + + /** + * @param int $id + * @return array (0 => bool $isValid, 1 => string $entityTable, 2 => int $entityId) + */ + public function getDelegate($id) { + $query = \CRM_Core_DAO::executeQuery($this->lookupDelegateSql, array( + 1 => array($id, 'Positive') + )); + if ($query->fetch()) { + return array($query->is_valid, $query->entity_table, $query->entity_id); + } + else { + return array(FALSE, NULL, NULL); + } + } + + /** + * @param array $apiRequest + * @return bool + */ + public function isTrusted($apiRequest) { + // isn't this redundant? + return empty($apiRequest['params']['check_permissions']) or $apiRequest['params']['check_permissions'] == FALSE; + } + +} diff --git a/Civi/Core/Container.php b/Civi/Core/Container.php index 709bb3c0d0..2a8464547f 100644 --- a/Civi/Core/Container.php +++ b/Civi/Core/Container.php @@ -124,6 +124,17 @@ class Container { $reflectionProvider = new \Civi\API\Provider\ReflectionProvider($kernel); $dispatcher->addSubscriber($reflectionProvider); + $dispatcher->addSubscriber(new \Civi\API\Subscriber\DynamicFKAuthorization( + $kernel, + 'Attachment', + array('create', 'get', 'delete'), + 'SELECT if(cf.id,1,0) as is_valid, cef.entity_table, cef.entity_id + FROM civicrm_file cf + LEFT JOIN civicrm_entity_file cef ON cf.id = cef.file_id + WHERE cf.id = %1', + array('civicrm_activity', 'civicrm_mailing') + )); + $kernel->setApiProviders(array( $reflectionProvider, $magicFunctionProvider, diff --git a/api/v3/Attachment.php b/api/v3/Attachment.php new file mode 100644 index 0000000000..fe61be8d29 --- /dev/null +++ b/api/v3/Attachment.php @@ -0,0 +1,425 @@ + 'civicrm_activity', + * 'entity_id' => 123, + * 'name' => 'README.txt', + * 'mime_type' => 'text/plain', + * 'content' => 'Please to read the README', + * )); + * $attachment = $result['values'][$result['id']]; + * echo sprintf("View %s", $attachment['url'], $attachment['name']); + * @endcode + * + * @code + * $result = civicrm_api3('Attachment', 'create', array( + * 'entity_table' => 'civicrm_activity', + * 'entity_id' => 123, + * 'name' => 'README.txt', + * 'mime_type' => 'text/plain', + * 'options' => array( + * 'move-file' => '/tmp/upload1a2b3c4d', + * ), + * )); + * $attachment = $result['values'][$result['id']]; + * echo sprintf("View %s", $attachment['url'], $attachment['name']); + * @endcode + * + * Notes: + * - File content is not returned by default. One must specify 'return => content'. + * - Features which deal with local file system (e.g. passing "options.move-file" + * or returning a "path") are only valid when executed as a local API (ie + * "check_permissions"==false) + * + * @package CiviCRM_APIv3 + * @subpackage API_Attachment + * @copyright CiviCRM LLC (c) 2004-2014 + * $Id: $ + * + */ + +/** + * Adjust metadata for "create" action + * + * @param array $spec list of fields + */ +function _civicrm_api3_attachment_create_spec(&$spec) { + $spec = array_merge($spec, _civicrm_api3_attachment_getfields()); + $spec['name']['api.required'] = 1; + $spec['mime_type']['api.required'] = 1; + $spec['entity_table']['api.required'] = 1; + $spec['entity_id']['api.required'] = 1; + $spec['upload_date']['api.default'] = 'now'; +} + +/** + * Create an attachment + * + * @param array $params + * @return array of newly created file property values. + * @access public + * @throws API_Exception validation errors + */ +function civicrm_api3_attachment_create($params) { + $config = CRM_Core_Config::singleton(); + list($id, $file, $entityFile, $name, $content, $moveFile, $isTrusted, $returnContent) = _civicrm_api3_attachment_parse_params($params); + + $fileDao = new CRM_Core_BAO_File(); + $entityFileDao = new CRM_Core_DAO_EntityFile(); + + if ($id) { + $fileDao->id = $id; + if (!$fileDao->find(TRUE)) { + throw new API_Exception("Invalid ID"); + } + + $entityFileDao->file_id = $id; + if (!$entityFileDao->find(TRUE)) { + throw new API_Exception("Cannot modify orphaned file"); + } + } + + if (!$id && !is_string($content) && !is_string($moveFile)) { + throw new API_Exception("Mandatory key(s) missing from params array: 'id' or 'content' or 'options.move-file'"); + } + if (!$isTrusted && $moveFile) { + throw new API_Exception("options.move-file is only supported on secure calls"); + } + if (is_string($content) && is_string($moveFile)) { + throw new API_Exception("'content' and 'options.move-file' are mutually exclusive"); + } + if ($id && !$isTrusted && isset($file['upload_date']) && $file['upload_date'] != CRM_Utils_Date::isoToMysql($fileDao->upload_date)) { + throw new API_Exception("Cannot modify upload_date" . var_export(array($file['upload_date'], $fileDao->upload_date, CRM_Utils_Date::isoToMysql($fileDao->upload_date)), TRUE)); + } + if ($id && $name && $name != CRM_Utils_File::cleanFileName($fileDao->uri)) { + throw new API_Exception("Cannot modify name"); + } + + $fileDao->copyValues($file); + if (!$id) { + $fileDao->uri = CRM_Utils_File::makeFileName($name); + } + $fileDao->save(); + + $entityFileDao->copyValues($entityFile); + $entityFileDao->file_id = $fileDao->id; + $entityFileDao->save(); + + $path = $config->customFileUploadDir . DIRECTORY_SEPARATOR . $fileDao->uri; + if (is_string($content)) { + file_put_contents($path, $content); + } + elseif (is_string($moveFile)) { + rename($moveFile, $path); + } + + $result = array( + $fileDao->id => _civicrm_api3_attachment_format_result($fileDao, $entityFileDao, $returnContent, $isTrusted) + ); + return civicrm_api3_create_success($result, $params, 'Attachment', 'create'); +} + +/** + * Adjust metadata for "create" action + * + * @param array $spec list of fields + */ +function _civicrm_api3_attachment_get_spec(&$spec) { + $spec = array_merge($spec, _civicrm_api3_attachment_getfields()); +} + +/** + * @param array $params + * @return array per APIv3 + * @throws API_Exception validation errors + */ +function civicrm_api3_attachment_get($params) { + list($id, $file, $entityFile, $name, $content, $moveFile, $isTrusted, $returnContent) = _civicrm_api3_attachment_parse_params($params); + + $dao = __civicrm_api3_attachment_find($params, $id, $file, $entityFile, $isTrusted); + $result = array(); + while ($dao->fetch()) { + $result[$dao->id] = _civicrm_api3_attachment_format_result($dao, $dao, $returnContent, $isTrusted); + } + return civicrm_api3_create_success($result, $params, 'Attachment', 'create'); +} + +function _civicrm_api3_attachment_delete_spec(&$spec) { + unset($spec['id']['api.required']); + $entityFileFields = CRM_Core_DAO_EntityFile::fields(); + $spec['entity_table'] = $entityFileFields['entity_table']; + $spec['entity_table']['title'] = CRM_Utils_Array::value('title', $spec['entity_table'], 'Entity Table') . ' (write-once)'; + $spec['entity_id'] = $entityFileFields['entity_id']; + $spec['entity_id']['title'] = CRM_Utils_Array::value('title', $spec['entity_id'], 'Entity ID') . ' (write-once)'; +} + +/** + * @param $params + * @return array + * @throws API_Exception + */ +function civicrm_api3_attachment_delete($params) { + if (!empty($params['id'])) { + // ok + } + elseif (!empty($params['entity_table']) && !empty($params['entity_id'])) { + // ok + } + else { + throw new API_Exception("Mandatory key(s) missing from params array: id or entity_table+entity_table"); + } + + $config = CRM_Core_Config::singleton(); + list($id, $file, $entityFile, $name, $content, $moveFile, $isTrusted, $returnContent) = _civicrm_api3_attachment_parse_params($params); + $dao = __civicrm_api3_attachment_find($params, $id, $file, $entityFile, $isTrusted); + + $filePaths = array(); + $fileIds = array(); + while ($dao->fetch()) { + $filePaths [] = $config->customFileUploadDir . DIRECTORY_SEPARATOR . $dao->uri; + $fileIds[] = $dao->id; + } + + if (!empty($fileIds)) { + $idString = implode(',', array_filter($fileIds, 'is_numeric')); + CRM_Core_DAO::executeQuery("DELETE FROM civicrm_entity_file WHERE file_id in ($idString)"); + CRM_Core_DAO::executeQuery("DELETE FROM civicrm_file WHERE id in ($idString)"); + } + + // unlink is non-transactional, so we do this as the last step -- just in case the other steps produce errors + if (!empty($filePaths)) { + foreach ($filePaths as $filePath) { + unlink($filePath); + } + } + + $result = array(); + return civicrm_api3_create_success($result, $params, 'Attachment', 'create'); +} + +/** + * @param array $params + * @param int|null $id the user-supplied ID of the attachment record + * @param array $file the user-supplied vales for the file (mime_type, description, upload_date) + * @param array $entityFile the user-supllied values of the entity-file (entity_table, entity_id) + * @param bool $isTrusted + * @return CRM_Core_DAO + * @throws API_Exception + */ +function __civicrm_api3_attachment_find($params, $id, $file, $entityFile, $isTrusted) { + foreach (array('name', 'content', 'path', 'url') as $unsupportedFilter) { + if (!empty($params[$unsupportedFilter])) { + throw new API_Exception("Get by $unsupportedFilter is not currently supported"); + } + } + + $select = CRM_Utils_SQL_Select::from('civicrm_file cf') + ->join('cef', 'INNER JOIN civicrm_entity_file cef ON cf.id = cef.file_id') + ->select(array( + 'cf.id', + 'cf.uri', + 'cf.mime_type', + 'cf.description', + 'cf.upload_date', + 'cef.entity_table', + 'cef.entity_id', + )); + + if ($id) { + $select->where('cf.id = #id', array('#id' => $id)); + } + // recall: $file is filtered by parse_params + foreach ($file as $key => $value) { + $select->where('cf.!field = @value', array( + '!field' => $key, + '@value' => $value, + )); + } + // recall: $entityFile is filtered by parse_params + foreach ($entityFile as $key => $value) { + $select->where('cef.!field = @value', array( + '!field' => $key, + '@value' => $value, + )); + } + if (!$isTrusted) { + // FIXME ACLs: Add any JOIN or WHERE clauses needed to enforce access-controls for the target entity. + // + // The target entity is identified by "cef.entity_table" (aka $entityFile['entity_table']) and "cef.entity_id". + // + // As a simplification, we *require* the "get" actions to filter on a single "entity_table" which should + // avoid the complexity of matching ACL's against multiple entity types. + } + + $dao = CRM_Core_DAO::executeQuery($select->toSQL()); + return $dao; +} + +/** + * @param array $params + * @return array (0 => int $id, 1 => array $file, 2 => array $entityFile, 3 => string $name, 4 => string $content, 5 => string $moveFile, 6 => $isTrusted, 7 => bool $returnContent) + * - array $file: whitelisted fields that can pass through directly to civicrm_file + * - array $entityFile: whitelisted fields that can pass through directly to civicrm_entity_file + * - string $name: the printable name + * - string $moveFile: the full path to a local file whose content should be loaded + * - bool $isTrusted: whether we trust the requester to do sketchy things (like moving files or reassigning entities) + * - bool $returnContent: whether we are expected to return the full content of the file + * @throws API_Exception validation errors + */ +function _civicrm_api3_attachment_parse_params($params) { + $id = CRM_Utils_Array::value('id', $params, NULL); + if ($id && !is_numeric($id)) { + throw new API_Exception("Malformed id"); + } + + $file = array(); + foreach (array('mime_type', 'description', 'upload_date') as $field) { + if (array_key_exists($field, $params)) { + $file[$field] = $params[$field]; + } + } + + $entityFile = array(); + foreach (array('entity_table', 'entity_id') as $field) { + if (array_key_exists($field, $params)) { + $entityFile[$field] = $params[$field]; + } + } + + $name = NULL; + if (array_key_exists('name', $params)) { + if ($params['name'] != basename($params['name']) || preg_match(':[/\\\\]:', $params['name'])) { + throw new API_Exception('Malformed name'); + } + $name = $params['name']; + } + + $content = NULL; + if (isset($params['content'])) { + $content = $params['content']; + } + + $moveFile = NULL; + if (isset($params['options']['move-file'])) { + $moveFile = $params['options']['move-file']; + } + elseif (isset($params['options.move-file'])) { + $moveFile = $params['options.move-file']; + } + + $isTrusted = empty($params['check_permissions']); + + $returns = isset($params['return']) ? $params['return'] : array(); + $returns = is_array($returns) ? $returns : array($returns); + $returnContent = in_array('content', $returns); + + return array($id, $file, $entityFile, $name, $content, $moveFile, $isTrusted, $returnContent); +} + +/** + * @param CRM_Core_DAO_File $fileDao maybe "File" or "File JOIN EntityFile" + * @param CRM_Core_DAO_EntityFile $entityFileDao maybe "EntityFile" or "File JOIN EntityFile" + * @param bool $returnContent whether to return the full content of the file + * @param bool $isTrusted whether the current request is trusted to perform file-specific operations + * @return array + */ +function _civicrm_api3_attachment_format_result($fileDao, $entityFileDao, $returnContent, $isTrusted) { + $config = CRM_Core_Config::singleton(); + $path = $config->customFileUploadDir . DIRECTORY_SEPARATOR . $fileDao->uri; + + $result = array( + 'id' => $fileDao->id, + 'name' => CRM_Utils_File::cleanFileName($fileDao->uri), + 'mime_type' => $fileDao->mime_type, + 'description' => $fileDao->description, + 'upload_date' => is_numeric($fileDao->upload_date) ? CRM_Utils_Date::mysqlToIso($fileDao->upload_date) : $fileDao->upload_date, + 'entity_table' => $entityFileDao->entity_table, + 'entity_id' => $entityFileDao->entity_id, + ); + $result['url'] = CRM_Utils_System::url( + 'civicrm/file', 'reset=1&id=' . $result['id'] . '&eid=' . $result['entity_id'], + TRUE, + NULL, + FALSE, + TRUE + ); + if ($isTrusted) { + $result['path'] = $path; + } + if ($returnContent) { + $result['content'] = file_get_contents($path); + } + return $result; +} + +/** + * @return array list of fields (indexed by name) + */ +function _civicrm_api3_attachment_getfields() { + $fileFields = CRM_Core_DAO_File::fields(); + $entityFileFields = CRM_Core_DAO_EntityFile::fields(); + + $spec = array(); + $spec['id'] = $fileFields['id']; + $spec['name'] = array( + 'title' => 'Name (write-once)', + 'description' => 'The logical file name (not searchable)', + 'type' => CRM_Utils_Type::T_STRING, + ); + $spec['mime_type'] = $fileFields['mime_type']; + $spec['description'] = $fileFields['description']; + $spec['upload_date'] = $fileFields['upload_date']; + $spec['entity_table'] = $entityFileFields['entity_table']; + $spec['entity_table']['title'] = CRM_Utils_Array::value('title', $spec['entity_table'], 'Entity Table') . ' (write-once)'; // would be hard to securely handle changes + $spec['entity_id'] = $entityFileFields['entity_id']; + $spec['entity_id']['title'] = CRM_Utils_Array::value('title', $spec['entity_id'], 'Entity ID') . ' (write-once)'; // would be hard to securely handle changes + $spec['url'] = array( + 'title' => 'URL (read-only)', + 'description' => 'URL for downloading the file (not searchable, expire-able)', + 'type' => CRM_Utils_Type::T_STRING, + ); + $spec['path'] = array( + 'title' => 'Path (read-only)', + 'description' => 'Local file path (not searchable, local-only)', + 'type' => CRM_Utils_Type::T_STRING, + ); + $spec['content'] = array( + 'title' => 'Content', + 'description' => 'File content (not searchable, not returned by default)', + 'type' => CRM_Utils_Type::T_STRING, + ); + + return $spec; +} diff --git a/tests/phpunit/Civi/API/Subscriber/DynamicFKAuthorizationTest.php b/tests/phpunit/Civi/API/Subscriber/DynamicFKAuthorizationTest.php new file mode 100644 index 0000000000..8ef96bfd1f --- /dev/null +++ b/tests/phpunit/Civi/API/Subscriber/DynamicFKAuthorizationTest.php @@ -0,0 +1,196 @@ + self::FILE_WIDGET_ID, 'entity_table' => 'fake_widget', 'entity_id' => self::WIDGET_ID), + array('id' => self::FILE_FORBIDDEN_ID, 'entity_table' => 'fake_forbidden', 'entity_id' => self::FORBIDDEN_ID), + ) + ); + + \CRM_Core_DAO_AllCoreTables::registerEntityType('Widget', 'CRM_Fake_DAO_Widget', 'fake_widget'); + $widgetProvider = new \Civi\API\Provider\StaticProvider(3, 'Widget', + array('id', 'title'), + array(), + array( + array('id' => self::WIDGET_ID, 'title' => 'my widget'), + ) + ); + + \CRM_Core_DAO_AllCoreTables::registerEntityType('Forbidden', 'CRM_Fake_DAO_Forbidden', 'fake_forbidden'); + $forbiddenProvider = new \Civi\API\Provider\StaticProvider( + 3, + 'Forbidden', + array('id', 'label'), + array( + 'create' => \CRM_Core_Permission::ALWAYS_DENY_PERMISSION, + 'get' => \CRM_Core_Permission::ALWAYS_DENY_PERMISSION, + 'delete' => \CRM_Core_Permission::ALWAYS_DENY_PERMISSION, + ), + array( + array('id' => self::FORBIDDEN_ID, 'label' => 'my forbidden'), + ) + ); + + $this->dispatcher = new EventDispatcher(); + $this->kernel = new Kernel($this->dispatcher); + $this->kernel + ->registerApiProvider($fileProvider) + ->registerApiProvider($widgetProvider) + ->registerApiProvider($forbiddenProvider); + $this->dispatcher->addSubscriber(new DynamicFKAuthorization( + $this->kernel, + 'FakeFile', + array('create', 'get'), + "select + case %1 + when " . self::FILE_WIDGET_ID . " then 1 + when " . self::FILE_FORBIDDEN_ID . " then 1 + else 0 + end as is_valid, + case %1 + when " . self::FILE_WIDGET_ID . " then 'fake_widget' + when " . self::FILE_FORBIDDEN_ID . " then 'fake_forbidden' + else null + end as entity_table, + case %1 + when " . self::FILE_WIDGET_ID . " then " . self::WIDGET_ID . " + when " . self::FILE_FORBIDDEN_ID . " then " . self::FORBIDDEN_ID . " + else null + end as entity_id + ", + array('fake_widget', 'fake_forbidden') + )); + } + + protected function tearDown() { + parent::tearDown(); + \CRM_Core_DAO_AllCoreTables::init(TRUE); + } + + function okDataProvider() { + $cases = array(); + + $cases[] = array('Widget', 'create', array('id' => self::WIDGET_ID)); + $cases[] = array('Widget', 'get', array('id' => self::WIDGET_ID)); + + $cases[] = array('FakeFile', 'create', array('id' => self::FILE_WIDGET_ID)); + $cases[] = array('FakeFile', 'get', array('id' => self::FILE_WIDGET_ID)); + $cases[] = array( + 'FakeFile', + 'create', + array('entity_table' => 'fake_widget', 'entity_id' => self::WIDGET_ID) + ); + $cases[] = array('FakeFile', 'get', array('entity_table' => 'fake_widget')); + + return $cases; + } + + function badDataProvider() { + $cases = array(); + + $cases[] = array('Forbidden', 'create', array('id' => self::FORBIDDEN_ID), '/Authorization failed/'); + $cases[] = array('Forbidden', 'get', array('id' => self::FORBIDDEN_ID), '/Authorization failed/'); + + $cases[] = array('FakeFile', 'create', array('id' => self::FILE_FORBIDDEN_ID), '/Authorization failed/'); + $cases[] = array('FakeFile', 'get', array('id' => self::FILE_FORBIDDEN_ID), '/Authorization failed/'); + + $cases[] = array('FakeFile', 'create', array('entity_table' => 'fake_forbidden'), '/Authorization failed/'); + $cases[] = array('FakeFile', 'get', array('entity_table' => 'fake_forbidden'), '/Authorization failed/'); + + $cases[] = array( + 'FakeFile', + 'create', + array('entity_table' => 'fake_forbidden', 'entity_id' => self::FORBIDDEN_ID), + '/Authorization failed/' + ); + $cases[] = array( + 'FakeFile', + 'get', + array('entity_table' => 'fake_forbidden', 'entity_id' => self::FORBIDDEN_ID), + '/Authorization failed/' + ); + + $cases[] = array('FakeFile', 'create', array(), "/Mandatory key\\(s\\) missing from params array: 'id' or 'entity_table/"); + $cases[] = array('FakeFile', 'get', array(), "/Mandatory key\\(s\\) missing from params array: 'id' or 'entity_table/"); + + $cases[] = array('FakeFile', 'create', array('entity_table' => 'unknown'), '/Unrecognized target entity/'); + $cases[] = array('FakeFile', 'get', array('entity_table' => 'unknown'), '/Unrecognized target entity/'); + + return $cases; + } + + /** + * @param $entity + * @param $action + * @param $params + * @dataProvider okDataProvider + */ + function testOk($entity, $action, $params) { + $params['version'] = 3; + $params['debug'] = 1; + $params['check_permissions'] = 1; + $result = $this->kernel->run($entity, $action, $params); + $this->assertFalse((bool) $result['is_error'], print_r(array( + '$entity' => $entity, + '$action' => $action, + '$params' => $params, + '$result' => $result, + ), TRUE)); + } + + /** + * @param $entity + * @param $action + * @param $params + * @dataProvider badDataProvider + */ + function testBad($entity, $action, $params, $expectedError) { + $params['version'] = 3; + $params['debug'] = 1; + $params['check_permissions'] = 1; + $result = $this->kernel->run($entity, $action, $params); + $this->assertTrue((bool) $result['is_error'], print_r(array( + '$entity' => $entity, + '$action' => $action, + '$params' => $params, + '$result' => $result, + ), TRUE)); + $this->assertRegExp($expectedError, $result['error_message']); + } +} diff --git a/tests/phpunit/api/v3/AttachmentTest.php b/tests/phpunit/api/v3/AttachmentTest.php new file mode 100644 index 0000000000..c920bd8875 --- /dev/null +++ b/tests/phpunit/api/v3/AttachmentTest.php @@ -0,0 +1,568 @@ +. + */ + +/** + * Include class definitions + */ +require_once 'CiviTest/CiviUnitTestCase.php'; + + +/** + * Test for the Attachment API + * + * @package CiviCRM_APIv3 + * @subpackage API_Contact + */ +class api_v3_AttachmentTest extends CiviUnitTestCase { + protected static $filePrefix = NULL; + + public static function getFilePrefix() { + if (!self::$filePrefix) { + self::$filePrefix = "test_" . CRM_Utils_String::createRandom(5, CRM_Utils_String::ALPHANUMERIC) . '_'; + } + return self::$filePrefix; + } + + + protected function setUp() { + parent::setUp(); + $this->useTransaction(TRUE); + + $this->cleanupFiles(); + file_put_contents($this->tmpFile('mytest.txt'), 'This comes from a file'); + } + + protected function tearDown() { + parent::tearDown(); + $this->cleanupFiles(); + \Civi\Core\Container::singleton(TRUE); + } + + public function okCreateProvider() { + $cases = array(); // array($entityClass, $createParams, $expectedContent) + + $cases[] = array( + 'CRM_Activity_DAO_Activity', + array( + 'name' => self::getFilePrefix() . 'exampleFromContent.txt', + 'mime_type' => 'text/plain', + 'description' => 'My test description', + 'content' => 'My test content', + ), + 'My test content', + ); + + $cases[] = array( + 'CRM_Activity_DAO_Activity', + array( + 'name' => self::getFilePrefix() . 'exampleWithEmptyContent.txt', + 'mime_type' => 'text/plain', + 'description' => 'My test description', + 'content' => '', + ), + '', + ); + + $cases[] = array( + 'CRM_Activity_DAO_Activity', + array( + 'name' => self::getFilePrefix() . 'exampleFromMove.txt', + 'mime_type' => 'text/plain', + 'description' => 'My test description', + 'options' => array( + 'move-file' => $this->tmpFile('mytest.txt'), + ) + ), + 'This comes from a file', + ); + + return $cases; + } + + public function badCreateProvider() { + $cases = array(); // array($entityClass, $createParams, $expectedError) + + $cases[] = array( + 'CRM_Activity_DAO_Activity', + array( + 'id' => 12345, + 'name' => self::getFilePrefix() . 'exampleFromContent.txt', + 'mime_type' => 'text/plain', + 'description' => 'My test description', + 'content' => 'My test content', + ), + '/Invalid ID/', + ); + $cases[] = array( + 'CRM_Activity_DAO_Activity', + array( + 'name' => self::getFilePrefix() . 'failedExample.txt', + 'mime_type' => 'text/plain', + 'description' => 'My test description', + ), + "/Mandatory key\\(s\\) missing from params array: 'id' or 'content' or 'options.move-file'/", + ); + $cases[] = array( + 'CRM_Activity_DAO_Activity', + array( + 'name' => self::getFilePrefix() . 'failedExample.txt', + 'mime_type' => 'text/plain', + 'description' => 'My test description', + 'content' => 'too much content', + 'options' => array( + 'move-file' => $this->tmpFile('too-much.txt') + ), + ), + "/'content' and 'options.move-file' are mutually exclusive/", + ); + $cases[] = array( + 'CRM_Activity_DAO_Activity', + array( + 'name' => 'inv/alid.txt', + 'mime_type' => 'text/plain', + 'description' => 'My test description', + 'content' => 'My test content', + ), + "/Malformed name/", + ); + $cases[] = array( + 'CRM_Core_DAO_Domain', + array( + 'name' => self::getFilePrefix() . 'exampleFromContent.txt', + 'mime_type' => 'text/plain', + 'description' => 'My test description', + 'content' => 'My test content', + ), + "/Unrecognized target entity/", + ); + + return $cases; + } + + public function badUpdateProvider() { + $cases = array(); // array($entityClass, $createParams, $updateParams, $expectedError) + + $readOnlyFields = array( + 'name' => 'newname.txt', + 'entity_table' => 'civicrm_domain', + 'entity_id' => 5, + 'upload_date' => '2010-11-12 13:14:15', + ); + foreach ($readOnlyFields as $readOnlyField => $newValue) { + $cases[] = array( + 'CRM_Activity_DAO_Activity', + array( + 'name' => self::getFilePrefix() . 'exampleFromContent.txt', + 'mime_type' => 'text/plain', + 'description' => 'My test description', + 'content' => 'My test content', + ), + array( + 'check_permissions' => 1, + $readOnlyField => $newValue, + ), + "/Cannot modify $readOnlyField/" + ); + } + + return $cases; + } + + public function okGetProvider() { + $cases = array(); // array($getParams, $expectedNames) + + // Each search runs in a DB which contains these attachments: + // Activity #123: example_123.txt (text/plain) and example_123.csv (text/csv) + // Activity #456: example_456.txt (text/plain) and example_456.csv (text/csv) + + $cases[] = array( + array('entity_table' => 'civicrm_activity'), + array( + self::getFilePrefix() . 'example_123.csv', + self::getFilePrefix() . 'example_123.txt', + self::getFilePrefix() . 'example_456.csv', + self::getFilePrefix() . 'example_456.txt', + ), + ); + $cases[] = array( + array('entity_table' => 'civicrm_activity', 'mime_type' => 'text/plain'), + array(self::getFilePrefix() . 'example_123.txt', self::getFilePrefix() . 'example_456.txt'), + ); + $cases[] = array( + array('entity_table' => 'civicrm_activity', 'entity_id' => '123'), + array(self::getFilePrefix() . 'example_123.txt', self::getFilePrefix() . 'example_123.csv'), + ); + $cases[] = array( + array('entity_table' => 'civicrm_activity', 'entity_id' => '456'), + array(self::getFilePrefix() . 'example_456.txt', self::getFilePrefix() . 'example_456.csv'), + ); + $cases[] = array( + array('entity_table' => 'civicrm_activity', 'entity_id' => '456', 'mime_type' => 'text/csv'), + array(self::getFilePrefix() . 'example_456.csv'), + ); + $cases[] = array( + array('entity_table' => 'civicrm_activity', 'entity_id' => '456', 'mime_type' => 'text/html'), + array(), + ); + $cases[] = array( + array('entity_table' => 'civicrm_activity', 'entity_id' => '999'), + array(), + ); + + return $cases; + } + + public function badGetProvider() { + $cases = array(); // array($getParams, $expectedNames) + + // Each search runs in a DB which contains these attachments: + // Activity #123: example_123.txt (text/plain) and example_123.csv (text/csv) + // Activity #456: example_456.txt (text/plain) and example_456.csv (text/csv) + + $cases[] = array( + array('check_permissions' => 1, 'mime_type' => 'text/plain'), + "/Mandatory key\\(s\\) missing from params array: 'id' or 'entity_table'/", + ); + $cases[] = array( + array('check_permissions' => 1, 'entity_id' => '123'), + "/Mandatory key\\(s\\) missing from params array: 'id' or 'entity_table'/", + ); + $cases[] = array( + array('check_permissions' => 1), + "/Mandatory key\\(s\\) missing from params array: 'id' or 'entity_table'/", + ); + $cases[] = array( + array('entity_table' => 'civicrm_activity', 'name' => 'example_456.csv'), + "/Get by name is not currently supported/", + ); + $cases[] = array( + array('entity_table' => 'civicrm_activity', 'content' => 'test'), + "/Get by content is not currently supported/", + ); + $cases[] = array( + array('entity_table' => 'civicrm_activity', 'path' => '/home/foo'), + "/Get by path is not currently supported/", + ); + $cases[] = array( + array('entity_table' => 'civicrm_activity', 'url' => '/index.php'), + "/Get by url is not currently supported/", + ); + + return $cases; + } + + /** + * Create an attachment using "content" and then "get" the attachment + * + * @param string $testEntityClass e.g. "CRM_Core_DAO_Activity" + * @param array $createParams + * @param string $expectedContent + * @dataProvider okCreateProvider + */ + public function testCreate($testEntityClass, $createParams, $expectedContent) { + $entity = CRM_Core_DAO::createTestObject($testEntityClass); + $entity_table = CRM_Core_DAO_AllCoreTables::getTableForClass($testEntityClass); + $this->assertTrue(is_numeric($entity->id)); + + $createResult = $this->callAPISuccess('Attachment', 'create', $createParams + array( + 'entity_table' => $entity_table, + 'entity_id' => $entity->id, + )); + $fileId = $createResult['id']; + $this->assertTrue(is_numeric($fileId)); + $this->assertEquals($entity_table, $createResult['values'][$fileId]['entity_table']); + $this->assertEquals($entity->id, $createResult['values'][$fileId]['entity_id']); + $this->assertEquals('My test description', $createResult['values'][$fileId]['description']); + $this->assertRegExp('/\d\d\d\d-\d\d-\d\d \d\d:\d\d:\d\d/', $createResult['values'][$fileId]['upload_date']); + $this->assertTrue(!isset($createResult['values'][$fileId]['content'])); + $this->assertTrue(!empty($createResult['values'][$fileId]['url'])); + $this->assertAttachmentExistence(TRUE, $createResult); + + $getResult = $this->callAPISuccess('Attachment', 'get', array( + 'entity_table' => $entity_table, + 'entity_id' => $entity->id, + )); + $this->assertEquals(1, $getResult['count']); + foreach (array('id', 'entity_table', 'entity_id', 'url') as $field) { + $this->assertEquals($createResult['values'][$fileId][$field], $getResult['values'][$fileId][$field], "Expect field $field to match"); + } + $this->assertTrue(!isset($getResult['values'][$fileId]['content'])); + + $getResult2 = $this->callAPISuccess('Attachment', 'get', array( + 'entity_table' => $entity_table, + 'entity_id' => $entity->id, + 'return' => array('content'), + )); + $this->assertEquals($expectedContent, $getResult2['values'][$fileId]['content']); + foreach (array('id', 'entity_table', 'entity_id', 'url') as $field) { + $this->assertEquals($createResult['values'][$fileId][$field], $getResult['values'][$fileId][$field], "Expect field $field to match"); + } + } + + /** + * @param $testEntityClass + * @param $createParams + * @param $expectedError + * @dataProvider badCreateProvider + */ + public function testCreateFailure($testEntityClass, $createParams, $expectedError) { + $entity = CRM_Core_DAO::createTestObject($testEntityClass); + $entity_table = CRM_Core_DAO_AllCoreTables::getTableForClass($testEntityClass); + $this->assertTrue(is_numeric($entity->id)); + + $createResult = $this->callAPIFailure('Attachment', 'create', $createParams + array( + 'entity_table' => $entity_table, + 'entity_id' => $entity->id, + )); + $this->assertRegExp($expectedError, $createResult['error_message']); + } + + /** + * @param $testEntityClass + * @param $createParams + * @param $updateParams + * @param $expectedError + * @dataProvider badUpdateProvider + */ + public function testCreateWithBadUpdate($testEntityClass, $createParams, $updateParams, $expectedError) { + $entity = CRM_Core_DAO::createTestObject($testEntityClass); + $entity_table = CRM_Core_DAO_AllCoreTables::getTableForClass($testEntityClass); + $this->assertTrue(is_numeric($entity->id)); + + $createResult = $this->callAPISuccess('Attachment', 'create', $createParams + array( + 'entity_table' => $entity_table, + 'entity_id' => $entity->id, + )); + $fileId = $createResult['id']; + $this->assertTrue(is_numeric($fileId)); + + $updateResult = $this->callAPIFailure('Attachment', 'create', $updateParams + array( + 'id' => $fileId, + )); + $this->assertRegExp($expectedError, $updateResult['error_message']); + } + + /** + * If one submits a weird file name, it should be automatically converted + * to something safe. + */ + public function testCreateWithWeirdName() { + $entity = CRM_Core_DAO::createTestObject('CRM_Activity_DAO_Activity'); + $this->assertTrue(is_numeric($entity->id)); + + $createResult = $this->callAPISuccess('Attachment', 'create', array( + 'name' => self::getFilePrefix() . 'weird:na"me.txt', + 'mime_type' => 'text/plain', + 'description' => 'My test description', + 'content' => 'My test content', + 'entity_table' => 'civicrm_activity', + 'entity_id' => $entity->id, + )); + $fileId = $createResult['id']; + $this->assertTrue(is_numeric($fileId)); + $this->assertEquals(self::getFilePrefix() . 'weird_na_me.txt', $createResult['values'][$fileId]['name']); + } + + /** + * @param $getParams + * @param $expectedNames + * @dataProvider okGetProvider + */ + public function testGet($getParams, $expectedNames) { + foreach (array(123, 456) as $entity_id) { + foreach (array('text/plain' => '.txt', 'text/csv' => '.csv') as $mime => $ext) { + $this->callAPISuccess('Attachment', 'create', array( + 'name' => self::getFilePrefix() . 'example_' . $entity_id . $ext, + 'mime_type' => $mime, + 'description' => 'My test description', + 'content' => 'My test content', + 'entity_table' => 'civicrm_activity', + 'entity_id' => $entity_id, + )); + } + } + + $getResult = $this->callAPISuccess('Attachment', 'get', $getParams); + $actualNames = array_values(CRM_Utils_Array::collect('name', $getResult['values'])); + sort($actualNames); + sort($expectedNames); + $this->assertEquals($expectedNames, $actualNames); + } + + /** + * @param $getParams + * @param $expectedError + * @dataProvider badGetProvider + */ + public function testGetError($getParams, $expectedError) { + foreach (array(123, 456) as $entity_id) { + foreach (array('text/plain' => '.txt', 'text/csv' => '.csv') as $mime => $ext) { + $this->callAPISuccess('Attachment', 'create', array( + 'name' => self::getFilePrefix() . 'example_' . $entity_id . $ext, + 'mime_type' => $mime, + 'description' => 'My test description', + 'content' => 'My test content', + 'entity_table' => 'civicrm_activity', + 'entity_id' => $entity_id, + )); + } + } + + $getResult = $this->callAPIFailure('Attachment', 'get', $getParams); + $this->assertRegExp($expectedError, $getResult['error_message']); + } + + /** + * Take the values from a "get", make a small change, and then send + * the full thing back in as an update ("create"). This ensures some + * consistency in the acceptable formats. + */ + public function testGetThenUpdate() { + $entity = CRM_Core_DAO::createTestObject('CRM_Activity_DAO_Activity'); + $this->assertTrue(is_numeric($entity->id)); + + $createResult = $this->callAPISuccess('Attachment', 'create', array( + 'name' => self::getFilePrefix() . 'getThenUpdate.txt', + 'mime_type' => 'text/plain', + 'description' => 'My test description', + 'content' => 'My test content', + 'entity_table' => 'civicrm_activity', + 'entity_id' => $entity->id, + )); + $fileId = $createResult['id']; + $this->assertTrue(is_numeric($fileId)); + $this->assertEquals(self::getFilePrefix() . 'getThenUpdate.txt', $createResult['values'][$fileId]['name']); + $this->assertAttachmentExistence(TRUE, $createResult); + + $getResult = $this->callAPISuccess('Attachment', 'get', array( + 'id' => $fileId, + )); + $this->assertTrue(is_array($getResult['values'][$fileId])); + + $updateParams = $getResult['values'][$fileId]; + $updateParams['description'] = 'new description'; + $this->callAPISuccess('Attachment', 'create', $updateParams); + $this->assertAttachmentExistence(TRUE, $createResult); + } + + /** + * Create an attachment and delete using its ID. Assert that the records are correctly created and destroyed + * in the DB and the filesystem. + */ + public function testDeleteByID() { + $entity = CRM_Core_DAO::createTestObject('CRM_Activity_DAO_Activity'); + $this->assertTrue(is_numeric($entity->id)); + + foreach (array('first', 'second') as $n) { + $createResults[$n] = $this->callAPISuccess('Attachment', 'create', array( + 'name' => self::getFilePrefix() . 'testDeleteByID.txt', + 'mime_type' => 'text/plain', + 'content' => 'My test content', + 'entity_table' => 'civicrm_activity', + 'entity_id' => $entity->id, + )); + $this->assertTrue(is_numeric($createResults[$n]['id'])); + $this->assertEquals(self::getFilePrefix() . 'testDeleteByID.txt', $createResults[$n]['values'][$createResults[$n]['id']]['name']); + } + $this->assertAttachmentExistence(TRUE, $createResults['first']); + $this->assertAttachmentExistence(TRUE, $createResults['second']); + + $this->callAPISuccess('Attachment', 'delete', array( + 'id' => $createResults['first']['id'], + )); + $this->assertAttachmentExistence(FALSE, $createResults['first']); + $this->assertAttachmentExistence(TRUE, $createResults['second']); + } + + /** + * Create an attachment and delete using its ID. Assert that the records are correctly created and destroyed + * in the DB and the filesystem. + */ + public function testDeleteByEntity() { + // create 2 entities (keepme,delme) -- each with 2 attachments (first,second) + foreach (array('keepme', 'delme') as $e) { + $entities[$e] = CRM_Core_DAO::createTestObject('CRM_Activity_DAO_Activity'); + $this->assertTrue(is_numeric($entities[$e]->id)); + foreach (array('first', 'second') as $n) { + $createResults[$e][$n] = $this->callAPISuccess('Attachment', 'create', array( + 'name' => self::getFilePrefix() . 'testDeleteByEntity.txt', + 'mime_type' => 'text/plain', + 'content' => 'My test content', + 'entity_table' => 'civicrm_activity', + 'entity_id' => $entities[$e]->id, + )); + $this->assertTrue(is_numeric($createResults[$e][$n]['id'])); + } + } + $this->assertAttachmentExistence(TRUE, $createResults['keepme']['first']); + $this->assertAttachmentExistence(TRUE, $createResults['keepme']['second']); + $this->assertAttachmentExistence(TRUE, $createResults['delme']['first']); + $this->assertAttachmentExistence(TRUE, $createResults['delme']['second']); + + $this->callAPISuccess('Attachment', 'delete', array( + 'entity_table' => 'civicrm_activity', + 'entity_id' => $entities[$e]->id, + )); + $this->assertAttachmentExistence(TRUE, $createResults['keepme']['first']); + $this->assertAttachmentExistence(TRUE, $createResults['keepme']['second']); + $this->assertAttachmentExistence(FALSE, $createResults['delme']['first']); + $this->assertAttachmentExistence(FALSE, $createResults['delme']['second']); + } + + protected function assertAttachmentExistence($exists, $apiResult) { + $fileId = $apiResult['id']; + $this->assertTrue(is_numeric($fileId)); + $this->assertEquals($exists, file_exists($apiResult['values'][$fileId]['path'])); + $this->assertDBQuery($exists ? 1 : 0, 'SELECT count(*) FROM civicrm_file WHERE id = %1', array( + 1 => array($fileId, 'Int') + )); + $this->assertDBQuery($exists ? 1 : 0, 'SELECT count(*) FROM civicrm_entity_file WHERE id = %1', array( + 1 => array($fileId, 'Int') + )); + } + + protected function tmpFile($name) { + $tmpDir = sys_get_temp_dir(); + $this->assertTrue($tmpDir && is_dir($tmpDir), 'Tmp dir must exist: ' . $tmpDir); + return $tmpDir . '/' . self::getFilePrefix() . $name; + } + + protected function cleanupFiles() { + $config = CRM_Core_Config::singleton(); + $dirs = array( + sys_get_temp_dir(), + $config->customFileUploadDir, + ); + foreach ($dirs as $dir) { + $files = (array) glob($dir . "/" . self::getFilePrefix() . "*"); + foreach ($files as $file) { + unlink($file); + } + + } + } +} diff --git a/tests/phpunit/api/v3/SyntaxConformanceTest.php b/tests/phpunit/api/v3/SyntaxConformanceTest.php index 807959c403..1c497ab0b1 100644 --- a/tests/phpunit/api/v3/SyntaxConformanceTest.php +++ b/tests/phpunit/api/v3/SyntaxConformanceTest.php @@ -303,6 +303,7 @@ class api_v3_SyntaxConformanceTest extends CiviUnitTestCase { */ public static function toBeSkipped_updatesingle($sequential = FALSE) { $entitiesWithout = array( + 'Attachment', // pseudo-entity; testUpdateSingleValueAlter doesn't introspect properly on it. Multiple magic fields 'Mailing', 'MailingGroup', 'MailingJob', -- 2.25.1