diff --git a/application/menunode.class.inc.php b/application/menunode.class.inc.php index d76616e6e..3a5006aa9 100644 --- a/application/menunode.class.inc.php +++ b/application/menunode.class.inc.php @@ -103,7 +103,7 @@ class ApplicationMenu { self::$sFavoriteSiloQuery = $sOQL; } - + /** * Get the query used to limit the list of displayed organizations in the drop-down menu * @return string The OQL query returning a list of Organization objects @@ -536,7 +536,7 @@ EOF return -1; } - + /** * Retrieves the currently active menu (if any, otherwise the first menu is the default) * @return string The Id of the currently active menu @@ -544,7 +544,7 @@ EOF public static function GetActiveNodeId() { $oAppContext = new ApplicationContext(); - $sMenuId = $oAppContext->GetCurrentValue('menu', null); + $sMenuId = $oAppContext->GetCurrentValue('menu', null); if ($sMenuId === null) { $sMenuId = self::GetDefaultMenuId(); @@ -654,7 +654,7 @@ abstract class MenuNode /** * Stimulus to check: if the user can 'apply' this stimulus, then she/he can see this menu - */ + */ protected $m_aEnableStimuli; /** @@ -814,7 +814,7 @@ abstract class MenuNode { return false; } - + /** * Add a limiting display condition for the same menu node. The conditions will be combined with a AND * @param $oMenuNode MenuNode Another definition of the same menu node, with potentially different access restriction @@ -987,7 +987,7 @@ class TemplateMenuNode extends MenuNode * @var string */ protected $sTemplateFile; - + /** * Create a menu item based on a custom template and inserts it into the application's main menu * @param string $sMenuId Unique identifier of the menu (used to identify the menu for bookmarking, and for getting the labels from the dictionary) @@ -1058,7 +1058,7 @@ class OQLMenuNode extends MenuNode * @var bool|null */ protected $bSearchFormOpen; - + /** * Extra parameters to be passed to the display block to fine tune its appearence */ @@ -1091,7 +1091,7 @@ class OQLMenuNode extends MenuNode // Enhancement: we could set as the "enable" condition that the user has enough rights to "read" the objects // of the class specified by the OQL... } - + /** * Set some extra parameters to be passed to the display block to fine tune its appearence * @param array $aParams paramCode => value. See DisplayBlock::GetDisplay for the meaning of the parameters @@ -1111,7 +1111,7 @@ class OQLMenuNode extends MenuNode */ public function RenderContent(WebPage $oPage, $aExtraParams = array()) { - ContextTag::AddContext(ContextTag::TAG_OBJECT_SEARCH); + $oTag = new ContextTag(ContextTag::TAG_OBJECT_SEARCH); ApplicationMenu::CheckMenuIdEnabled($this->GetMenuId()); OQLMenuNode::RenderOQLSearch ( @@ -1120,7 +1120,7 @@ class OQLMenuNode extends MenuNode 'Menu_'.$this->GetMenuId(), $this->bSearch, // Search pane $this->bSearchFormOpen, // Search open - $oPage, + $oPage, array_merge($this->m_aParams, $aExtraParams), true ); @@ -1354,10 +1354,10 @@ class NewObjectMenuNode extends MenuNode { // Enable this menu, only if the current user has enough rights to create such an object, or an object of // any child class - + $aSubClasses = MetaModel::EnumChildClasses($this->sClass, ENUM_CHILD_CLASSES_ALL); // Including the specified class itself $bActionIsAllowed = false; - + foreach($aSubClasses as $sCandidateClass) { if (!MetaModel::IsAbstract($sCandidateClass) && (UserRights::IsActionAllowed($sCandidateClass, UR_ACTION_MODIFY) == UR_ALLOWED_YES)) @@ -1366,7 +1366,7 @@ class NewObjectMenuNode extends MenuNode break; // Enough for now } } - return $bActionIsAllowed; + return $bActionIsAllowed; } /** @@ -1508,7 +1508,7 @@ class DashboardMenuNode extends MenuNode throw new Exception("Error: failed to load dashboard file: '{$this->sDashboardFile}'"); } } - + } /** @@ -1549,7 +1549,7 @@ class ShortcutContainerMenuNode extends MenuNode $sName = $this->GetMenuId().'_'.$oShortcut->GetKey(); new ShortcutMenuNode($sName, $oShortcut, $this->GetIndex(), $fRank++); } - + // Complete the tree // parent::PopulateChildMenus(); diff --git a/core/cmdbsource.class.inc.php b/core/cmdbsource.class.inc.php index e05d4e94a..5feb077ba 100644 --- a/core/cmdbsource.class.inc.php +++ b/core/cmdbsource.class.inc.php @@ -3,7 +3,7 @@ // // This file is part of iTop. // -// iTop is free software; you can redistribute it and/or modify +// iTop is free software; you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. @@ -380,7 +380,7 @@ class CMDBSource public static function GetDBVendor() { $sDBVendor = static::ENUM_DB_VENDOR_MYSQL; - + $sVersionComment = static::GetServerVariable('version') . ' - ' . static::GetServerVariable('version_comment'); if(preg_match('/mariadb/i', $sVersionComment) === 1) { @@ -390,7 +390,7 @@ class CMDBSource { $sDBVendor = static::ENUM_DB_VENDOR_PERCONA; } - + return $sDBVendor; } @@ -934,7 +934,7 @@ class CMDBSource { throw new MySQLException('Failed to issue SQL query', array('query' => $sSql)); } - + while ($aRow = $oResult->fetch_array($iMode)) { $aData[] = $aRow; @@ -1088,7 +1088,7 @@ class CMDBSource if (!array_key_exists($iKey, $aTableInfo["Fields"])) return false; $aFieldData = $aTableInfo["Fields"][$iKey]; if (!array_key_exists("Key", $aFieldData)) return false; - return ($aFieldData["Key"] == "PRI"); + return ($aFieldData["Key"] == "PRI"); } public static function IsAutoIncrement($sTable, $sField) @@ -1099,7 +1099,7 @@ class CMDBSource $aFieldData = $aTableInfo["Fields"][$sField]; if (!array_key_exists("Extra", $aFieldData)) return false; //MyHelpers::debug_breakpoint($aFieldData); - return (strstr($aFieldData["Extra"], "auto_increment")); + return (strstr($aFieldData["Extra"], "auto_increment")); } public static function IsField($sTable, $sField) @@ -1366,13 +1366,13 @@ class CMDBSource public static function GetTableFieldsList($sTable) { assert(!empty($sTable)); - + $aTableInfo = self::GetTableInfo($sTable); if (empty($aTableInfo)) return array(); // #@# or an error ? return array_keys($aTableInfo["Fields"]); } - + // Cache the information about existing tables, and their fields private static $m_aTablesInfo = array(); private static function _TablesInfoCacheReset($sTableName = null) @@ -1505,7 +1505,7 @@ class CMDBSource { throw new MySQLException('Failed to issue SQL query', array('query' => $sSql)); } - + $aRows = array(); while ($aRow = $oResult->fetch_array(MYSQLI_ASSOC)) { @@ -1514,7 +1514,7 @@ class CMDBSource $oResult->free(); return $aRows; } - + /** * Returns the value of the specified server variable * @param string $sVarName Name of the server variable @@ -1530,7 +1530,7 @@ class CMDBSource /** * Returns the privileges of the current user * @return string privileges in a raw format - */ + */ public static function GetRawPrivileges() { try @@ -1556,8 +1556,8 @@ class CMDBSource /** * Determine the slave status of the server - * @return bool true if the server is slave - */ + * @return bool true if the server is slave + */ public static function IsSlaveServer() { try diff --git a/core/config.class.inc.php b/core/config.class.inc.php index f23e80e2a..2f50949b4 100644 --- a/core/config.class.inc.php +++ b/core/config.class.inc.php @@ -1193,6 +1193,30 @@ class Config 'source_of_value' => '', 'show_in_conf_sample' => false, ], + 'sessions_tracking.enabled' => [ + 'type' => 'bool', + 'description' => 'Whether or not the whole mechanism to track active sessions is enabled. See PHP session.gc_maxlifetime setting to configure session expiration.', + 'default' => false, + 'value' => '', + 'source_of_value' => '', + 'show_in_conf_sample' => false, + ], + 'sessions_tracking.gc_threshold' => [ + 'type' => 'integer', + 'description' => 'fallback in case cron is not active: probability in percent that session files are cleanup during any itop request (100 means always)', + 'default' => 1, + 'value' => '', + 'source_of_value' => '', + 'show_in_conf_sample' => false, + ], + 'sessions_tracking.gc_duration_in_seconds' => [ + 'type' => 'integer', + 'description' => 'fallback in case cron is not active: when a cleanup is triggered cleanup duration will not exceed this duration (in seconds).', + 'default' => 1, + 'value' => '', + 'source_of_value' => '', + 'show_in_conf_sample' => false, + ], 'transaction_storage' => [ 'type' => 'string', 'description' => 'The type of mechanism to use for storing the unique identifiers for transactions (Session|File).', diff --git a/core/log.class.inc.php b/core/log.class.inc.php index 29b359689..a8b920815 100644 --- a/core/log.class.inc.php +++ b/core/log.class.inc.php @@ -3,7 +3,7 @@ // // This file is part of iTop. // -// iTop is free software; you can redistribute it and/or modify +// iTop is free software; you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. @@ -576,6 +576,11 @@ class LogChannels public const DATATABLE = 'Datatable'; public const DEADLOCK = 'DeadLock'; + /** + * @var string Everything related to PHP sessions tracking + * @since 3.1.1 3.2.0 N°6901 + */ + public const SESSIONTRACKER = 'SessionTracker'; /** * @var string Everything related to the datamodel CRUD diff --git a/lib/composer/autoload_classmap.php b/lib/composer/autoload_classmap.php index 4989deb64..80e888e24 100644 --- a/lib/composer/autoload_classmap.php +++ b/lib/composer/autoload_classmap.php @@ -473,6 +473,8 @@ return array( 'Combodo\\iTop\\Service\\TemporaryObjects\\TemporaryObjectManager' => $baseDir . '/sources/Service/TemporaryObjects/TemporaryObjectManager.php', 'Combodo\\iTop\\Service\\TemporaryObjects\\TemporaryObjectRepository' => $baseDir . '/sources/Service/TemporaryObjects/TemporaryObjectRepository.php', 'Combodo\\iTop\\Service\\TemporaryObjects\\TemporaryObjectsEvents' => $baseDir . '/sources/Service/TemporaryObjects/TemporaryObjectsEvents.php', + 'Combodo\\iTop\\SessionTracker\\SessionGC' => $baseDir . '/sources/SessionTracker/SessionGC.php', + 'Combodo\\iTop\\SessionTracker\\SessionHandler' => $baseDir . '/sources/SessionTracker/SessionHandler.php', 'CompileCSSService' => $baseDir . '/application/compilecssservice.class.inc.php', 'Composer\\InstalledVersions' => $vendorDir . '/composer/InstalledVersions.php', 'Config' => $baseDir . '/core/config.class.inc.php', diff --git a/lib/composer/autoload_static.php b/lib/composer/autoload_static.php index da75d88ba..065c0f934 100644 --- a/lib/composer/autoload_static.php +++ b/lib/composer/autoload_static.php @@ -837,6 +837,8 @@ class ComposerStaticInit7f81b4a2a468a061c306af5e447a9a9f 'Combodo\\iTop\\Service\\TemporaryObjects\\TemporaryObjectManager' => __DIR__ . '/../..' . '/sources/Service/TemporaryObjects/TemporaryObjectManager.php', 'Combodo\\iTop\\Service\\TemporaryObjects\\TemporaryObjectRepository' => __DIR__ . '/../..' . '/sources/Service/TemporaryObjects/TemporaryObjectRepository.php', 'Combodo\\iTop\\Service\\TemporaryObjects\\TemporaryObjectsEvents' => __DIR__ . '/../..' . '/sources/Service/TemporaryObjects/TemporaryObjectsEvents.php', + 'Combodo\\iTop\\SessionTracker\\SessionGC' => __DIR__ . '/../..' . '/sources/SessionTracker/SessionGC.php', + 'Combodo\\iTop\\SessionTracker\\SessionHandler' => __DIR__ . '/../..' . '/sources/SessionTracker/SessionHandler.php', 'CompileCSSService' => __DIR__ . '/../..' . '/application/compilecssservice.class.inc.php', 'Composer\\InstalledVersions' => __DIR__ . '/..' . '/composer/InstalledVersions.php', 'Config' => __DIR__ . '/../..' . '/core/config.class.inc.php', diff --git a/pages/ajax.render.php b/pages/ajax.render.php index 2981273ab..a200cce29 100644 --- a/pages/ajax.render.php +++ b/pages/ajax.render.php @@ -68,7 +68,7 @@ try break; default: - ContextTag::AddContext(ContextTag::TAG_CONSOLE); + $oTag = new ContextTag(ContextTag::TAG_CONSOLE); } // First check if we can redirect the route to a dedicated controller diff --git a/sources/Application/Helper/Session.php b/sources/Application/Helper/Session.php index 4b708a61e..867e3ca70 100644 --- a/sources/Application/Helper/Session.php +++ b/sources/Application/Helper/Session.php @@ -7,6 +7,7 @@ namespace Combodo\iTop\Application\Helper; +use Combodo\iTop\SessionTracker\SessionHandler; use utils; /** @@ -34,6 +35,7 @@ class Session } if (!self::$bIsInitialized) { + SessionHandler::session_set_save_handler(); session_name('itop-'.md5(APPROOT)); } @@ -214,4 +216,4 @@ class Session return utils::IsModeCLI(); } -} \ No newline at end of file +} diff --git a/sources/SessionTracker/SessionGC.php b/sources/SessionTracker/SessionGC.php new file mode 100644 index 000000000..8653ea26d --- /dev/null +++ b/sources/SessionTracker/SessionGC.php @@ -0,0 +1,32 @@ + + * @package Combodo\iTop\SessionTracker + * @since 3.1.1 3.2.0 N°6901 + */ +class SessionGC implements \iBackgroundProcess +{ + /** + * @inheritDoc + */ + public function GetPeriodicity() + { + return 60 * 1; // seconds + } + + /** + * @inheritDoc + */ + public function Process($iTimeLimit) + { + $iMaxLifetime = ini_get('session.gc_maxlifetime') ?? 1440; + $oSessionHandler = new SessionHandler(); + $iProcessed = $oSessionHandler->gc_with_time_limit($iMaxLifetime, $iTimeLimit); + return "processed $iProcessed tasks"; + } +} diff --git a/sources/SessionTracker/SessionHandler.php b/sources/SessionTracker/SessionHandler.php new file mode 100644 index 000000000..5aa0c4072 --- /dev/null +++ b/sources/SessionTracker/SessionHandler.php @@ -0,0 +1,240 @@ + + * @package Combodo\iTop\SessionTracker + * @since 3.1.1 3.2.0 N°6901 + */ +class SessionHandler extends \SessionHandler +{ + /** + * @param string $session_id + * + * @return bool + */ + #[ReturnTypeWillChange] + public function destroy($session_id) : bool + { + IssueLog::Debug("Destroy PHP session", \LogChannels::SESSIONTRACKER, [ + 'session_id' => $session_id, + ]); + $bRes = parent::destroy($session_id); + + if ($bRes) { + $this->unlink_session_file($session_id); + } + + return $bRes; + } + + /** + * @param int $max_lifetime + */ + #[ReturnTypeWillChange] + public function gc($max_lifetime) : bool + { + IssueLog::Debug("Run PHP sessions garbage collector", \LogChannels::SESSIONTRACKER, [ + 'max_lifetime' => $max_lifetime, + ]); + $iRes = parent::gc($max_lifetime); + $this->gc_with_time_limit($max_lifetime); + return $iRes; + } + + /** + * @param string $save_path + * @param string $session_name + */ + #[ReturnTypeWillChange] + public function open($save_path, $session_name) : bool + { + $bRes = parent::open($save_path, $session_name); + + $session_id = session_id(); + IssueLog::Debug("Open PHP session", \LogChannels::SESSIONTRACKER, [ + 'session_id' => $session_id, + ]); + + if ($bRes) { + $this->touch_session_file($session_id); + } + + return $bRes; + } + + /** + * @param string $session_id + * @param string $data + * + * @return bool + */ + #[ReturnTypeWillChange] + public function write($session_id, $data) : bool + { + $bRes = parent::write($session_id, $data); + + IssueLog::Debug("Write PHP session", \LogChannels::SESSIONTRACKER, [ + 'session_id' => $session_id, + 'data' => $data, + ]); + + if ($bRes) { + $this->touch_session_file($session_id); + } + + return $bRes; + } + + public static function session_set_save_handler() : void + { + if (false === utils::GetConfig()->Get('sessions_tracking.enabled')){ + //feature disabled + return; + } + + $sessionhandler = new SessionHandler(); + session_set_save_handler($sessionhandler, true); + + $iThreshold = utils::GetConfig()->Get('sessions_tracking.gc_threshold'); + $iThreshold = min(100, $iThreshold); + $iThreshold = max(1, $iThreshold); + if ((100 != $iThreshold) && (rand(1, 100) > $iThreshold)) { + return; + } + + $iMaxLifetime = ini_get('session.gc_maxlifetime') ?? 60; + $iMaxDurationInSeconds = utils::GetConfig()->Get('sessions_tracking.gc_duration_in_seconds'); + $sessionhandler->gc_with_time_limit($iMaxLifetime, time() + $iMaxDurationInSeconds); + } + + private function generate_session_content(?string $sPreviousFileVersionContent) : ?string + { + try { + $sUserId = UserRights::GetUserId(); + if (null === $sUserId) { + return null; + } + + // Default value in case of + // - First time file creation + // - Data corruption (not a json / not an array / no previous creation_time key) + $iCreationTime = time(); + + if (! is_null($sPreviousFileVersionContent)) { + $aJson = json_decode($sPreviousFileVersionContent, true); + if (is_array($aJson) && array_key_exists('creation_time', $aJson)) { + $iCreationTime = $aJson['creation_time']; + } + } + + return json_encode ( + [ + 'login_mode' => Session::Get('login_mode'), + 'user_id' => $sUserId, + 'creation_time' => $iCreationTime, + 'context' => implode('|', ContextTag::GetStack()) + ] + ); + } catch(Exception $e) { + + } + + return null; + } + + private function get_file_path($session_id) : string + { + return utils::GetDataPath() . "sessions/session_$session_id"; + } + + private function touch_session_file($session_id) : ?string + { + if (strlen($session_id) == 0) { + return null; + } + + clearstatcache(); + if (! is_dir(utils::GetDataPath() . "sessions")) { + @mkdir(utils::GetDataPath() . "sessions"); + } + + $sFilePath = $this->get_file_path($session_id); + + $sPreviousFileVersionContent = null; + if (is_file($sFilePath)) { + $sPreviousFileVersionContent = file_get_contents($sFilePath); + } + $sNewContent = $this->generate_session_content($sPreviousFileVersionContent); + if (is_null($sNewContent) || ($sPreviousFileVersionContent === $sNewContent)) { + @touch($sFilePath); + } else { + file_put_contents($sFilePath, $sNewContent); + } + + return $sFilePath; + } + + private function unlink_session_file($session_id) + { + $sFilePath = $this->get_file_path($session_id); + if (is_file($sFilePath)) { + @unlink($sFilePath); + } + } + + /** + * @param int $max_lifetime + * @param int $iTimeLimit Unix timestamp of time limit not to exceed. -1 for no limit. + * + * @return int + */ + public function gc_with_time_limit(int $max_lifetime, int $iTimeLimit = -1) : int + { + $aFiles = $this->list_session_files(); + $iProcessed = 0; + $now = time(); + + foreach ($aFiles as $sFile) { + if ($now - filemtime($sFile) > $max_lifetime) { + @unlink($sFile); + $iProcessed++; + } + + if (-1 !== $iTimeLimit && time() > $iTimeLimit) { + //cleanup processing has to stop after $iTimeLimit + break; + } + } + + return $iProcessed; + } + + public function list_session_files() : array + { + clearstatcache(); + if (! is_dir(utils::GetDataPath() . "sessions")) { + @mkdir(utils::GetDataPath() . "sessions"); + } + + return glob(utils::GetDataPath() . "sessions/session_*"); + } +} diff --git a/tests/php-unit-tests/unitary-tests/sources/SessionTracker/SessionHandlerTest.php b/tests/php-unit-tests/unitary-tests/sources/SessionTracker/SessionHandlerTest.php new file mode 100644 index 000000000..ac89a8421 --- /dev/null +++ b/tests/php-unit-tests/unitary-tests/sources/SessionTracker/SessionHandlerTest.php @@ -0,0 +1,241 @@ +aFiles=[]; + $this->oTag = new ContextTag(ContextTag::TAG_REST); + } + + protected function tearDown(): void + { + parent::tearDown(); + $this->oTag = null; + + foreach ($this->aFiles as $sFile){ + if (is_file($sFile)){ + @unlink($sFile); + } + } + } + + private function CreateUserAndLogIn() : ? string { + $_SESSION = []; + $oUser = $this->CreateContactlessUser("admin" . uniqid(), 1, "1234@Abcdefg"); + + \UserRights::Login($oUser->Get('login')); + return $oUser->GetKey(); + } + + private function GenerateSessionContent(SessionHandler $oSessionHandler, ?string $sPreviousFileVersionContent) : ?string { + return $this->InvokeNonPublicMethod(SessionHandler::class, "generate_session_content", $oSessionHandler, $aArgs = [$sPreviousFileVersionContent]); + } + + /* + * @covers SessionHandler::generate_session_content + */ + public function testGenerateSessionContentNoUserLoggedIn(){ + $oSessionHandler = new SessionHandler(); + $sContent = $this->GenerateSessionContent($oSessionHandler, null); + $this->assertNull($sContent, "Session content should be null when there is no user logged in"); + } + + public function GenerateSessionContentCorruptedPreviousFileContentProvider() { + return [ + 'not a json' => [ "not a json" ], + 'not an array' => [ json_encode("not an array") ], + 'array without creation_time key' => [ json_encode([]) ], + ]; + } + + /** + * @covers SessionHandler::generate_session_content + * @dataProvider GenerateSessionContentCorruptedPreviousFileContentProvider + */ + public function testGenerateSessionContent_SessionFileRepairment(?string $sFileContent){ + $sUserId = $this->CreateUserAndLogIn(); + + $oSessionHandler = new SessionHandler(); + Session::Set('login_mode', 'foo_login_mode'); + + $sContent = $this->GenerateSessionContent($oSessionHandler, $sFileContent); + + $this->assertNotNull($sContent, 'Should not return null'); + $aJson = json_decode($sContent, true); + $this->assertNotEquals(false, $aJson, 'Should return a valid json string, found: '.$sContent); + $this->assertEquals($sUserId, $aJson['user_id'] ?? '', "Should report the login of the logged in user in [user_id]: $sContent"); + $this->assertEquals(ContextTag::TAG_REST, $aJson['context'] ?? '', "Should report the context tag(s) in [context]: $sContent"); + $this->assertIsInt($aJson['creation_time'] ?? '', "Should report the session start timestamp in [creation_time]: $sContent"); + $this->assertEquals('foo_login_mode', $aJson['login_mode'] ?? '', "Should report the current login mode in [login_mode]: $sContent"); + } + + /* + * @covers SessionHandler::generate_session_content + */ + public function testGenerateSessionContent(){ + $sUserId = $this->CreateUserAndLogIn(); + + $oSessionHandler = new SessionHandler(); + Session::Set('login_mode', 'foo_login_mode'); + + //first time + $sFirstContent = $this->GenerateSessionContent($oSessionHandler, null); + + $this->assertNotNull($sFirstContent, 'Should not return null'); + $aJson = json_decode($sFirstContent, true); + $this->assertNotEquals(false, $aJson, 'Should return a valid json string, found: '.$sFirstContent); + $this->assertEquals($sUserId, $aJson['user_id'] ?? '', "Should report the login of the logged in user in [user_id]: $sFirstContent"); + $this->assertEquals(ContextTag::TAG_REST, $aJson['context'] ?? '', "Should report the context tag(s) in [context]: $sFirstContent"); + $this->assertIsInt($aJson['creation_time'] ?? '', "Should report the session start timestamp in [creation_time]: $sFirstContent"); + $this->assertEquals('foo_login_mode', $aJson['login_mode'] ?? '', "Should report the current login mode in [login_mode]: $sFirstContent"); + + $iFirstSessionCreationTime = $aJson['creation_time']; + + // Switch context + change user id via impersonation + // check it is still tracked in session files + $oOtherUser = $this->CreateContactlessUser("admin" . uniqid(), 1, "1234@Abcdefg"); + $this->assertTrue(\UserRights::Impersonate($oOtherUser->Get('login')), "Failed to execute impersonate on: ".$oOtherUser->Get('login')); + $oTag2 = new ContextTag(ContextTag::TAG_SYNCHRO); + $sNewContent = $this->GenerateSessionContent($oSessionHandler, $sFirstContent); + $this->assertNotNull($sNewContent, 'Should not return null'); + $aJson = json_decode($sNewContent, true); + $this->assertNotEquals(false, $aJson, 'Should return a valid json string, found: '.$sNewContent); + $this->assertEquals(ContextTag::TAG_REST . '|' . ContextTag::TAG_SYNCHRO, $aJson['context'] ?? '', "After impersonation, should report the new context tags in [context]: $sNewContent"); + $this->assertEquals($iFirstSessionCreationTime, $aJson['creation_time'] ?? '', "After impersonation, should still report the the session start timestamp in [creation_time]: $sNewContent"); + $this->assertEquals('foo_login_mode', $aJson['login_mode'] ?? '', "After impersonation, should still report the login mode in [login_mode]: $sNewContent"); + $this->assertEquals($oOtherUser->GetKey(), $aJson['user_id'] ?? '', "Should report the impersonate user in [user_id]: $sNewContent"); + } + + private function touchSessionFile(SessionHandler $oSessionHandler, $session_id) : ?string { + $sRes = $this->InvokeNonPublicMethod(SessionHandler::class, "touch_session_file", $oSessionHandler, $aArgs = [$session_id]); + if (!is_null($sRes) && is_file($sRes)) { + // Record the file for cleanup on tearDown + $this->aFiles[] = $sRes; + } + clearstatcache(); + return $sRes; + } + + /* + * @covers SessionHandler::touch_session_file + */ + public function testTouchSessionFile_NoUserLoggedIn(){ + $oSessionHandler = new SessionHandler(); + $session_id = uniqid(); + $sFile = $this->touchSessionFile($oSessionHandler, $session_id); + $this->assertEquals(true, is_file($sFile), "Should return a file name: '$sFile' is not a valid file name"); + $sContent = file_get_contents($sFile); + $this->assertEquals(null, $sContent, 'Should create an empty file, found: '.$sContent); + } + + /* + * @covers SessionHandler::touch_session_file + */ + public function testTouchSessionFile_UserLoggedIn(){ + $sUserId = $this->CreateUserAndLogIn(); + Session::Set('login_mode', 'foo_login_mode'); + + $oSessionHandler = new SessionHandler(); + $session_id = uniqid(); + $sFile = $this->touchSessionFile($oSessionHandler, $session_id); + $this->assertEquals(true, is_file($sFile), "Should return a file name: '$sFile' is not a valid file name"); + $sFirstContent = file_get_contents($sFile); + + $iFirstCTime = filectime($sFile) - 1; + // Set it in the past to check that it will be further updated (without the need to sleep...) + touch($sFile, $iFirstCTime); + + $this->assertNotNull($sFirstContent, 'Should not return null'); + $aJson = json_decode($sFirstContent, true); + $this->assertNotEquals(false, $aJson, 'Should return a valid json string, found: '.$sFirstContent); + $this->assertEquals($sUserId, $aJson['user_id'] ?? '', "Should report the login of the logged in user in [user_id]: $sFirstContent"); + $this->assertEquals(ContextTag::TAG_REST, $aJson['context'] ?? '', "Should report the context tag(s) in [context]: $sFirstContent"); + $this->assertIsInt($aJson['creation_time'] ?? '', "Should report the session start timestamp in [creation_time]: $sFirstContent"); + $this->assertEquals('foo_login_mode', $aJson['login_mode'] ?? '', "Should report the current login mode in [login_mode]: $sFirstContent"); + + $this->touchSessionFile($oSessionHandler, $session_id); + $sNewContent = file_get_contents($sFile); + $this->assertEquals($sFirstContent, $sNewContent, 'On successive calls, should not modify an existing session file'); + $this->assertGreaterThan($iFirstCTime, filectime($sFile), 'On successive calls, should have changed the file ctime'); + } + + /** + * @covers SessionHandler::touch_session_file + */ + public function testTouchSessionFileWithEmptySessionId() { + $this->CreateUserAndLogIn(); + Session::Set('login_mode', 'toto'); + + $oSessionHandler = new SessionHandler(); + $this->assertNull($this->touchSessionFile($oSessionHandler, ''), 'Should return null when session id is an empty string'); + $this->assertNull($this->touchSessionFile($oSessionHandler, false), 'Should return null when session id (boolean) false'); + } + + private function GetFilePath(SessionHandler $oSessionHandler, $session_id) : string { + $sFile = $this->InvokeNonPublicMethod(SessionHandler::class, "get_file_path", $oSessionHandler, $aArgs = [$session_id]); + // Record file for cleanup on tearDown + $this->aFiles[] = $sFile; + return $sFile; + } + + public function GgcWithTimeLimitProvider(){ + return [ + 'no cleanup time limit' => [ + 'iTimeLimit' => -1, + 'iExpectedProcessed' => 2 + ], + 'cleanup time limit in the pass => first file removed only' => [ + 'iTimeLimit' => time() - 1, + 'iExpectedProcessed' => 1 + ], + ]; + } + + /** + * @covers SessionHandler::gc_with_time_limit + * @covers SessionHandler::list_session_files + * @dataProvider GgcWithTimeLimitProvider + */ + public function testGgcWithTimeLimit($iTimeLimit, $iExpectedProcessed) { + $oSessionHandler = new SessionHandler(); + //remove all first + $oSessionHandler->gc_with_time_limit(-1); + $this->assertEquals([], $oSessionHandler->list_session_files(), 'list_session_files should report no file at startup'); + + $max_lifetime = 1440; + $iNbExpiredFiles = 2; + $iNbFiles = 5; + $iExpiredTimeStamp = time() - $max_lifetime - 1; + for($i=0; $i<$iNbFiles; $i++) { + $sFile = $this->GetFilePath($oSessionHandler, uniqid()); + file_put_contents($sFile, "fakedata"); + + if ($iNbExpiredFiles > 0){ + $iNbExpiredFiles--; + touch($sFile, $iExpiredTimeStamp); + } + } + + $aFoundSessionFiles = $oSessionHandler->list_session_files(); + $this->assertEquals($iNbFiles, sizeof($aFoundSessionFiles), 'list_session_files should reports all files'); + foreach ($aFoundSessionFiles as $sFile){ + $this->assertTrue(is_file($sFile), 'list_session_files should return a valid file paths, found: '.$sFile); + } + + $iProcessed = $oSessionHandler->gc_with_time_limit($max_lifetime, $iTimeLimit); + $this->assertEquals($iExpectedProcessed, $iProcessed, 'gc_with_time_limit should report the count of expired files'); + $this->assertEquals($iNbFiles - $iExpectedProcessed, sizeof($oSessionHandler->list_session_files()), 'gc_with_time_limit should actually remove all processed files'); + } +} diff --git a/webservices/cron.php b/webservices/cron.php index b39ab8efe..9e8c4bec5 100644 --- a/webservices/cron.php +++ b/webservices/cron.php @@ -108,7 +108,7 @@ function RunTask(BackgroundTask $oTask, $iTimeLimit) // Time in seconds allowed to the task $iCurrTimeLimit = $iTimeLimit; // Compute allowed time - if ($oRefClass->implementsInterface('iScheduledProcess') === false) + if ($oRefClass->implementsInterface('iScheduledProcess') === false) { // Periodic task, allow only X times ($iMaxTaskExecutionTime) its periodicity (GetPeriodicity()) $iMaxTaskExecutionTime = MetaModel::GetConfig()->Get('cron_task_max_execution_time'); @@ -148,7 +148,7 @@ function RunTask(BackgroundTask $oTask, $iTimeLimit) $oTask->Set('first_run_date', $oDateStarted->format('Y-m-d H:i:s')); } $oTask->ComputeDurations($fDuration); // does increment the counter and compute statistics - + // Update the timestamp since we want to be able to re-order the tasks based on the time they finished $oDateEnded = new DateTime(); $oTask->Set('latest_run_date', $oDateEnded->format('Y-m-d H:i:s'));