. * or see https://www.gnu.org/ */ /** * \file test/phpunit/FunctionsLibDragDropTest.php * \ingroup test * \brief PHPUnit test of the directory resolution used by the drag and drop of a file on a card. * \remarks To run this script as CLI: phpunit FunctionsLibDragDropTest.php */ global $conf,$user,$langs,$db; require_once dirname(__FILE__).'/../../htdocs/master.inc.php'; require_once dirname(__FILE__).'/../../htdocs/product/class/product.class.php'; require_once dirname(__FILE__).'/../../htdocs/societe/class/societe.class.php'; require_once dirname(__FILE__).'/../../htdocs/contact/class/contact.class.php'; require_once dirname(__FILE__).'/CommonClassTest.class.php'; if (!defined('NOREQUIREUSER')) { define('NOREQUIREUSER', '1'); } if (!defined('NOREQUIREDB')) { define('NOREQUIREDB', '1'); } if (!defined('NOREQUIRESOC')) { define('NOREQUIRESOC', '1'); } if (!defined('NOREQUIRETRAN')) { define('NOREQUIRETRAN', '1'); } if (!defined('NOCSRFCHECK')) { define('NOCSRFCHECK', '1'); } if (!defined('NOTOKENRENEWAL')) { define('NOTOKENRENEWAL', '1'); } if (!defined('NOREQUIREMENU')) { define('NOREQUIREMENU', '1'); } if (!defined('NOREQUIREHTML')) { define('NOREQUIREHTML', '1'); } if (!defined('NOREQUIREAJAX')) { define('NOREQUIREAJAX', '1'); } if (!defined("NOLOGIN")) { define("NOLOGIN", '1'); } /** * Class for PHPUnit tests of the directory resolution used by the drag and drop of a file on a card * * @backupGlobals disabled * @backupStaticAttributes enabled * @remarks backupGlobals must be disabled to have db,conf,user and lang not erased. */ class FunctionsLibDragDropTest extends CommonClassTest { /** * The string returned by getMultidirOutput() when it does not know the module of the object. * It is a relative path, so writing into it creates files under the web root. */ const SENTINEL = 'error-diroutput-not-defined-for-this-object='; /** * The elements equipped with the drag and drop of a file on their card by this work. * Their 'dir_output' must always be usable to forge an absolute path. * * @return array> */ public static function providerEquippedElements() { $elements = array( 'contact', 'product', 'societe', 'action', 'expedition', 'reception', 'don', 'expensereport', 'holiday', 'mo', 'partnership', 'stocktransfer', 'productlot', 'resource', 'workstation', 'job', 'position', 'skill', 'evaluation', 'knowledgerecord', 'conferenceorbooth', 'asset', 'payment', 'payment_supplier', 'payment_various', 'salary', 'chargesociales', 'project_task', ); $out = array(); foreach ($elements as $element) { $out[$element] = array($element); } return $out; } // // getMultidirOutput() // /** * getMultidirOutput() answers a sentinel string, not an empty string, when it does not know the module. * Every caller must reject it, so the contract is asserted here once. * * @return void */ public function testGetMultidirOutputReturnsARelativeSentinelOnFailure() { $object = new stdClass(); $object->element = 'anelementthatdoesnotexist'; $object->id = 1; $object->entity = 1; $dir = getMultidirOutput($object, 'anelementthatdoesnotexist'); $this->assertSame(self::SENTINEL.'anelementthatdoesnotexist', $dir, 'The failure of getMultidirOutput must be reported by the sentinel string'); $this->assertStringStartsNotWith('/', $dir, 'The sentinel is a relative path, so it must never be used to forge a path'); // The 'temp' mode has its own sentinel $this->assertSame('error-dirtemp-not-defined-for-this-object=anelementthatdoesnotexist', getMultidirTemp($object, 'anelementthatdoesnotexist')); // And a bad mode has a third one $this->assertSame('error-bad-value-for-mode', getMultidirOutput($object, 'anelementthatdoesnotexist', 0, 'notamode')); } /** * The sentinel is the answer for a majority of the elements equipped with the drag and drop, so any * caller that forges a path from getMultidirOutput() alone is broken. This test documents the list. * * @return void */ public function testGetMultidirOutputIsUnableToResolveMostEquippedElements() { $nbresolved = 0; $nbsentinel = 0; foreach (self::providerEquippedElements() as $row) { $element = $row[0]; if ($element == 'project_task') { continue; // Needs a real object, it calls fetchProject() } $object = new stdClass(); $object->element = $element; $object->id = 1; $object->entity = 1; $dir = getMultidirOutput($object, $element); if (strpos((string) $dir, self::SENTINEL) === 0) { $nbsentinel++; } else { $nbresolved++; $this->assertStringStartsWith(DOL_DATA_ROOT, (string) $dir, 'A resolved directory must be inside DOL_DATA_ROOT for the element '.$element); } } $this->assertGreaterThan(0, $nbsentinel, 'getMultidirOutput is expected to fail on some equipped elements, the fallback on getElementProperties is required'); $this->assertGreaterThan(0, $nbresolved, 'getMultidirOutput is expected to resolve some equipped elements'); } /** * A module with a directory for the current entity only must not answer an undefined index (so a path * relative to the web root) when the object belongs to another entity. * * @return void */ public function testGetMultidirOutputFallsBackOnTheCurrentEntity() { global $conf; $object = new Product($conf->db); $object->id = 1; $object->ref = 'AREF'; $object->entity = 99; // No directory is declared for this entity $this->assertArrayNotHasKey(99, $conf->product->multidir_output, 'The fixture requires no directory declared for the entity 99'); $dir = getMultidirOutput($object, 'product'); $this->assertSame($conf->product->multidir_output[$conf->entity], $dir, 'The directory of the current entity must be used as a fallback'); $this->assertStringStartsWith(DOL_DATA_ROOT, $dir, 'The fallback must not answer a path relative to the web root'); // Same fallback for the temporary directory $dirtemp = getMultidirTemp($object, 'product'); $this->assertStringStartsWith(DOL_DATA_ROOT, $dirtemp, 'The temporary directory must not be relative either'); } /** * The subdirectory of a partnership and of a stock transfer must be appended, because it is the one * read by their "Attached files" tab (see partnership_document.php and stocktransfer_document.php). * * @return void */ public function testGetMultidirOutputAppendsTheSubDirectoryOfTheTab() { global $conf; $expected = array( 'partnership' => '/partnership/partnership', 'stocktransfer' => '/stocktransfer/stocktransfer', 'knowledgerecord' => '/knowledgemanagement/knowledgerecord', 'expedition' => '/expedition/sending', ); $asserted = 0; foreach ($expected as $element => $suffix) { if (!isset($conf->{explode('/', ltrim($suffix, '/'))[0]})) { continue; } $object = new stdClass(); $object->element = $element; $object->id = 1; $object->entity = $conf->entity; $dir = getMultidirOutput($object, $element); if (strpos((string) $dir, self::SENTINEL) === 0) { continue; // Module not enabled on this instance } $this->assertSame(DOL_DATA_ROOT.$suffix, $dir, 'Wrong directory for the element '.$element); $asserted++; } if (!$asserted) { // None of the modules of the elements above is enabled, so the test asserted nothing. Say it // instead of reporting a green test that checked nothing. $this->markTestSkipped('None of the modules partnership, stocktransfer, knowledgemanagement and expedition is enabled'); } } // // get_exdir() // /** * get_exdir() is the reference implementation used both by the "Attached files" tabs and by FileUpload * to forge the directory of an object. Its fallbacks must be asserted, they are load bearing. * * @return void */ public function testGetExdirFallbacks() { global $db; $object = new Product($db); $object->id = 42; $object->ref = 'MYREF'; // Nominal case: the ref is used $this->assertSame('MYREF', get_exdir(0, 0, 0, 1, $object, 'product')); // The trailing slash is added when $withoutslash is 0. FileUpload appends its own '/', so it must // call get_exdir() with $withoutslash = 1 to avoid a double slash into the path. $this->assertSame('MYREF/', get_exdir(0, 0, 0, 0, $object, 'product')); // The id is used as a fallback when the ref is empty (a draft object with no numbering yet) $object->ref = ''; $this->assertSame('42', get_exdir(0, 0, 0, 1, $object, 'product'), 'The id must be used when the ref is empty'); $object->ref = null; $this->assertSame('42', get_exdir(0, 0, 0, 1, $object, 'product'), 'The id must be used when the ref is null'); $object->ref = '0'; $this->assertSame('42', get_exdir(0, 0, 0, 1, $object, 'product'), 'A ref "0" is empty for php, the id is used'); // An object with neither a ref nor an id gives the directory '0', because the id is cast to an int // and then to a string. This is a shared directory for every unsaved object, so a caller must never // forge a path from an object it did not load: FileUpload throws 'objectnotfound' before this point. $empty = new Product($db); $empty->id = 0; $empty->ref = ''; $this->assertSame('0', get_exdir(0, 0, 0, 1, $empty, 'product'), 'An object with no id and no ref falls back on the directory "0"'); // The ref is a user input, it must be sanitized: no directory traversal, no separator $object->ref = '../../etc'; $this->assertStringNotContainsString('..', get_exdir(0, 0, 0, 1, $object, 'product'), 'A ref must never allow a directory traversal'); $object->ref = 'A/B'; $this->assertStringNotContainsString('/', get_exdir(0, 0, 0, 1, $object, 'product'), 'A ref must never introduce a sub directory'); // The modulepart is read from the object when it is not given $object->ref = 'MYREF'; $this->assertSame('MYREF', get_exdir(0, 0, 0, 1, $object), 'The modulepart must be deduced from the object'); } /** * A thirdparty stores its documents into a directory named after its id, because its ref is a company * name and two thirdparties may share the same name. * * @return void */ public function testGetExdirUsesTheIdForAThirdparty() { global $db; $thirdparty = new Societe($db); $thirdparty->id = 7; $thirdparty->ref = 'My company'; $this->assertSame('7', get_exdir(0, 0, 0, 1, $thirdparty, 'societe'), 'The id must be used for a thirdparty'); $this->assertSame('7', get_exdir(0, 0, 0, 1, $thirdparty, 'thirdparty'), 'The id must be used for a thirdparty'); // The rule is on the class, not only on the modulepart: a contact of the module 'societe' keeps its ref $contact = new Contact($db); $contact->id = 8; $contact->ref = 'DOE'; $this->assertSame('DOE', get_exdir(0, 0, 0, 1, $contact, 'contact'), 'A contact is not a thirdparty, its ref is used'); } /** * A module storing its documents on 2 levels answers the level directories only, so the caller must * append the directory of the object itself. * * @return void */ public function testGetExdirForATwoLevelsModule() { global $db; $object = new Product($db); // Any object, only the id and the modulepart matter here $object->id = 42; $object->ref = 'MYREF'; $this->assertSame('2/4', get_exdir(0, 0, 0, 1, $object, 'invoice_supplier'), 'Two levels of directories are expected'); $this->assertSame('2/4', get_exdir(0, 0, 0, 1, $object, 'supplier_invoice'), 'The two aliases must answer the same directory'); $this->assertSame('2/4/', get_exdir(0, 0, 0, 0, $object, 'invoice_supplier')); // The levels are built from the id, not from the ref $object->id = 1234; $this->assertSame('4/3', get_exdir(0, 0, 0, 1, $object, 'invoice_supplier')); } // // getElementProperties() // /** * The elements added or fixed by this work must answer the class that is really able to load them. * * @return void */ public function testGetElementPropertiesOfTheNewElements() { $expected = array( // element => array(module, classname, classpath, classfile, table_element) 'payment' => array('facture', 'Paiement', 'compta/paiement/class', 'paiement', 'paiement'), 'payment_supplier' => array('fournisseur', 'PaiementFourn', 'fourn/class', 'paiementfourn', 'paiementfourn'), 'payment_various' => array('bank', 'PaymentVarious', 'compta/bank/class', 'paymentvarious', 'payment_various'), 'stocktransfer' => array('stocktransfer', 'StockTransfer', 'product/stock/stocktransfer/class', 'stocktransfer', 'stocktransfer_stocktransfer'), 'job' => array('hrm', 'Job', 'hrm/class', 'job', 'hrm_job'), 'position' => array('hrm', 'Position', 'hrm/class', 'position', 'hrm_job_user'), 'skill' => array('hrm', 'Skill', 'hrm/class', 'skill', 'hrm_skill'), 'evaluation' => array('hrm', 'Evaluation', 'hrm/class', 'evaluation', 'hrm_evaluation'), ); foreach ($expected as $element => $values) { $prop = getElementProperties($element); $this->assertSame($element, $prop['element'], 'The element '.$element.' must not be rewritten by the myobject_mysubobject rule'); $this->assertSame($values[0], $prop['module'], 'Wrong module for the element '.$element); $this->assertSame($values[1], $prop['classname'], 'Wrong classname for the element '.$element); $this->assertSame($values[2], $prop['classpath'], 'Wrong classpath for the element '.$element); $this->assertSame($values[3], $prop['classfile'], 'Wrong classfile for the element '.$element); $this->assertSame($values[4], $prop['table_element'], 'Wrong table for the element '.$element); // The class must really exist, otherwise fetchObjectByElement() ends on a fatal error $file = DOL_DOCUMENT_ROOT.'/'.$prop['classpath'].'/'.$prop['classfile'].'.class.php'; $this->assertFileExists($file, 'The class file of the element '.$element.' does not exist'); require_once $file; $this->assertTrue(class_exists($prop['classname']), 'The class '.$prop['classname'].' of the element '.$element.' does not exist'); // And the table must exist too, restrictedArea() builds its sql on it. // The table of an optional module is only created when the module is enabled, so the check is // skipped otherwise: the mapping asserted above does not depend on the module being enabled. if (isModEnabled($values[0])) { $this->assertGreaterThan(0, $this->countTable($prop['table_element']), 'The table '.$prop['table_element'].' of the element '.$element.' is not readable'); } } } /** * Count the rows of a table, only to assert the table exists. * * @param string $table Table name without the prefix * @return int -1 if the table does not exist, 1 otherwise */ protected function countTable($table) { global $db; $sql = "SELECT COUNT(*) as nb FROM ".$db->prefix().$db->escape($table); $resql = $db->query($sql); if (!$resql) { return -1; } $db->free($resql); return 1; } /** * The 'dir_output' of an element must be the directory read by its "Attached files" tab, otherwise a * file uploaded by drag and drop is stored but never shown to the user. * * @return void */ public function testGetElementPropertiesDirOutputSubDirectory() { global $conf; $expected = array( 'contact' => array('societe', '/societe/contact', '/societe/temp/contact'), 'job' => array('hrm', '/hrm/job', '/hrm/temp/job'), 'position' => array('hrm', '/hrm/position', '/hrm/temp/position'), 'skill' => array('hrm', '/hrm/skill', '/hrm/temp/skill'), 'evaluation' => array('hrm', '/hrm/evaluation', '/hrm/temp/evaluation'), 'conferenceorbooth' => array('eventorganization', '/eventorganization/conferenceorbooth', '/eventorganization/temp/conferenceorbooth'), ); foreach ($expected as $element => $values) { if (!isset($conf->{$values[0]})) { $this->markTestSkipped('The module '.$values[0].' is not enabled, the fixture is not usable'); } $prop = getElementProperties($element); $this->assertSame(DOL_DATA_ROOT.$values[1], $prop['dir_output'], 'Wrong dir_output for the element '.$element); $this->assertSame(DOL_DATA_ROOT.$values[2], $prop['dir_temp'], 'Wrong dir_temp for the element '.$element.', the sub directory applies to it too'); } } /** * When the module of an element is disabled, its directory must be an empty string and NOT the sub * directory alone: a caller would then read or write into '/contact', at the root of the file system. * This is the regression the guard on $dir_output prevents, and it can only be seen with a module off. * * @return void */ public function testGetElementPropertiesDirOutputWhenTheModuleIsDisabled() { global $conf; $elements = array('contact' => 'societe', 'job' => 'hrm', 'position' => 'hrm', 'skill' => 'hrm', 'evaluation' => 'hrm', 'conferenceorbooth' => 'eventorganization'); foreach ($elements as $element => $module) { $savconfmodule = isset($conf->$module) ? $conf->$module : null; $savmodules = $conf->modules; // Simulate the module being disabled: no entry into $conf and no entry into $conf->modules unset($conf->$module); unset($conf->modules[$module]); try { $prop = getElementProperties($element); $this->assertSame('', $prop['dir_output'], 'The dir_output of the element '.$element.' must be empty when the module '.$module.' is disabled, not the sub directory alone'); $this->assertSame('', $prop['dir_temp'], 'The dir_temp of the element '.$element.' must be empty when the module '.$module.' is disabled'); } finally { if ($savconfmodule !== null) { $conf->$module = $savconfmodule; } $conf->modules = $savmodules; } } } /** * The directory of any equipped element is either empty or inside DOL_DATA_ROOT. It is never a path * at the root of the file system, and never the sentinel of getMultidirOutput(). * * @dataProvider providerEquippedElements * * @param string $element Element to check * @return void */ public function testGetElementPropertiesDirOutputIsAlwaysSafe($element) { $prop = getElementProperties($element); foreach (array('dir_output', 'dir_temp') as $key) { $dir = (string) $prop[$key]; $this->assertStringNotContainsString(self::SENTINEL, $dir, 'The '.$key.' of the element '.$element.' must never be the sentinel'); $this->assertTrue( $dir === '' || strpos($dir, DOL_DATA_ROOT) === 0, 'The '.$key.' of the element '.$element.' must be empty or inside DOL_DATA_ROOT, got "'.$dir.'"' ); } } }