diff --git a/tests/php-unit-tests/src/BaseTestCase/ItopTestCase.php b/tests/php-unit-tests/src/BaseTestCase/ItopTestCase.php index e5d5a30f8..88eb94196 100644 --- a/tests/php-unit-tests/src/BaseTestCase/ItopTestCase.php +++ b/tests/php-unit-tests/src/BaseTestCase/ItopTestCase.php @@ -687,4 +687,17 @@ abstract class ItopTestCase extends KernelTestCase return $this->CallUrl($sUrl, $aPostFields, $aCurlOptions, $bXDebugEnabled); } + + /** + * Return a temporary file path. that will be cleaned up by tearDown() + * + * @return string: temporary file path: file prefix include phpunit test method name + */ + public function GetTemporaryFilePath(): string + { + $sPrefix = $this->getName(false); + $sPath = tempnam(sys_get_temp_dir(), $sPrefix); + $this->aFileToClean[] = $sPath; + return $sPath; + } } diff --git a/tests/php-unit-tests/unitary-tests/webservices/CronServiceTest.php b/tests/php-unit-tests/unitary-tests/webservices/CronServiceTest.php new file mode 100644 index 000000000..96a1a39af --- /dev/null +++ b/tests/php-unit-tests/unitary-tests/webservices/CronServiceTest.php @@ -0,0 +1,120 @@ +RequireOnceItopFile('webservices/CronService.php'); + } + + public function testIsStarted() + { + $sPath = $this->GetTemporaryFilePath(); + file_put_contents($sPath, file_get_contents(__DIR__.'/resources/cron_starting.log')); + + $this->assertEquals(true, \CronService::GetInstance()->IsStarted($sPath)); + } + + public function testIsFailed_MissingCredentialsFailure() + { + $sPath = $this->GetTemporaryFilePath(); + file_put_contents($sPath, file_get_contents(__DIR__.'/resources/cron_missingcreds_error.log')); + + $this->assertEquals("Missing argument 'auth_user'", \CronService::GetInstance()->GetErrorMessage($sPath)); + $this->assertEquals(true, \CronService::GetInstance()->IsFailed($sPath)); + } + + public static function ErrorProvider() + { + return [ + ["Access wrong credentials ('user123')"], + ['gabuzomeu'], + ]; + } + + /** + * @dataProvider ErrorProvider + */ + public function testIsFailed_AuthenticationFailure($sError) + { + $sPath = $this->GetTemporaryFilePath(); + $sContent = <<assertEquals($sError, \CronService::GetInstance()->GetErrorMessage($sPath)); + $this->assertEquals(true, \CronService::GetInstance()->IsFailed($sPath)); + } + + public static function CannotStartProvider() + { + return [ + ["cron_alreadyrunning.log", 'Already running...'], + ['cron_maintenance.log', 'A maintenance is ongoing'], + ['cron_notanadmin.log', 'Access restricted to administrators'], + ]; + } + + /** + * @dataProvider CannotStartProvider + */ + public function testCronCannotStart(string $sLogFile, $sError) + { + $sPath = $this->GetTemporaryFilePath(); + file_put_contents($sPath, file_get_contents(__DIR__.'/resources/'.$sLogFile)); + + $this->assertEquals($sError, \CronService::GetInstance()->GetErrorMessage($sPath)); + $this->assertEquals(true, \CronService::GetInstance()->IsFailed($sPath)); + } + + public function testCronRunning() + { + $sPath = $this->GetTemporaryFilePath(); + $sContent = <<assertEquals(null, \CronService::GetInstance()->GetErrorMessage($sPath)); + $this->assertEquals(false, \CronService::GetInstance()->IsFailed($sPath)); + } + + public function testCronStopped() + { + + $sPath = $this->GetTemporaryFilePath(); + file_put_contents($sPath, file_get_contents(__DIR__.'/resources/cron_stopped.log')); + + $this->assertEquals(null, \CronService::GetInstance()->GetErrorMessage($sPath)); + $this->assertEquals(false, \CronService::GetInstance()->IsFailed($sPath)); + } +} diff --git a/tests/php-unit-tests/unitary-tests/webservices/CronTest.php b/tests/php-unit-tests/unitary-tests/webservices/CronTest.php index 33ca9ee4d..fd7fea681 100644 --- a/tests/php-unit-tests/unitary-tests/webservices/CronTest.php +++ b/tests/php-unit-tests/unitary-tests/webservices/CronTest.php @@ -184,6 +184,92 @@ class CronTest extends ItopDataTestCase $this->assertEquals(static::$sLogin, $sUserLogin); } + public function testGetCronStatus_FailWhenNotAdmin() + { + $this->AddLoginModeAndSaveConfiguration('url'); + $this->CreateUserWithProfiles([self::$aURP_Profiles['REST Services User']]); + + $sLogFileName = "crontest_".uniqid().'.log'; + $aPostFields = [ + 'version' => '1.3', + 'verbose' => 1, + 'debug' => 1, + ]; + + $aGetFields = [ + 'auth_user' => static::$sLogin, + 'auth_pwd' => static::$sPassword, + ]; + + $sJSONResult = $this->CallItopUri("/webservices/get_cron_status.php?".http_build_query($aGetFields), $aPostFields); + + $this->assertEquals('{"message":"Access restricted to administrators"}', $sJSONResult); + } + + public function testGetCronStatus_FailWhenLogFileDoesNotExist() + { + $this->AddLoginModeAndSaveConfiguration('url'); + $this->CreateUserWithProfiles([self::$aURP_Profiles['Administrator']]); + + $aPostFields = [ + 'version' => '1.3', + 'verbose' => 1, + 'debug' => 1, + 'cron_log_file' => 'gabuzomeu.log', + ]; + + $aGetFields = [ + 'auth_user' => static::$sLogin, + 'auth_pwd' => static::$sPassword, + ]; + + $sJSONResult = $this->CallItopUri("/webservices/get_cron_status.php?".http_build_query($aGetFields), $aPostFields); + + $this->assertEquals('{"message":"Cannot read log file"}', $sJSONResult); + } + + public static function GetCronStatusProvider() + { + return [ + ["cron_alreadyrunning.log", 'Already running...', "error"], + ["cron_dummyerror.log", 'Already running...', "error"], + ['cron_maintenance.log', 'A maintenance is ongoing', "error"], + ['cron_missingcreds_error.log', 'A maintenance is ongoing', "error"], + ['cron_notanadmin.log', 'Access restricted to administrators', "error"], + ['cron_starting.log', '', "running"], + ['cron_stopped.log', '', "stopped"], + ]; + } + + /** + * @dataProvider GetCronStatusProvider + */ + public function testGetCronStatus($sLogFilename, $expectedMsg, $expectedStatus) + { + $sLogFile = APPROOT."log/$sLogFilename"; + file_put_contents($sLogFile, file_get_contents(__DIR__.'/resources/'.$sLogFilename)); + $this->aFileToClean[] = $sLogFile; + + $this->AddLoginModeAndSaveConfiguration('url'); + $this->CreateUserWithProfiles([self::$aURP_Profiles['Administrator']]); + + $aPostFields = [ + 'version' => '1.3', + 'verbose' => 1, + 'debug' => 1, + 'cron_log_file' => $sLogFile, + ]; + + $aGetFields = [ + 'auth_user' => static::$sLogin, + 'auth_pwd' => static::$sPassword, + ]; + + $sJSONResult = $this->CallItopUri("/webservices/get_cron_status.php?".http_build_query($aGetFields), $aPostFields); + + $this->assertEquals('{"message":"Cannot read log file"}', $sJSONResult); + } + private function CreateUserWithProfiles(array $aProfileIds): ?string { if (count($aProfileIds) > 0) { diff --git a/tests/php-unit-tests/unitary-tests/webservices/resources/cron_alreadyrunning.log b/tests/php-unit-tests/unitary-tests/webservices/resources/cron_alreadyrunning.log new file mode 100644 index 000000000..bb71cc50d --- /dev/null +++ b/tests/php-unit-tests/unitary-tests/webservices/resources/cron_alreadyrunning.log @@ -0,0 +1,5 @@ +.. +.. +.. +.. +Already running... \ No newline at end of file diff --git a/tests/php-unit-tests/unitary-tests/webservices/resources/cron_dummyerror.log b/tests/php-unit-tests/unitary-tests/webservices/resources/cron_dummyerror.log new file mode 100644 index 000000000..e3b3e5df9 --- /dev/null +++ b/tests/php-unit-tests/unitary-tests/webservices/resources/cron_dummyerror.log @@ -0,0 +1,5 @@ +.. +.. +.. +.. +Error: GABUZOMEU \ No newline at end of file diff --git a/tests/php-unit-tests/unitary-tests/webservices/resources/cron_maintenance.log b/tests/php-unit-tests/unitary-tests/webservices/resources/cron_maintenance.log new file mode 100644 index 000000000..4126e687e --- /dev/null +++ b/tests/php-unit-tests/unitary-tests/webservices/resources/cron_maintenance.log @@ -0,0 +1,5 @@ +.. +.. +.. +.. +A maintenance is ongoing \ No newline at end of file diff --git a/tests/php-unit-tests/unitary-tests/webservices/resources/cron_missingcreds_error.log b/tests/php-unit-tests/unitary-tests/webservices/resources/cron_missingcreds_error.log new file mode 100644 index 000000000..70768fb22 --- /dev/null +++ b/tests/php-unit-tests/unitary-tests/webservices/resources/cron_missingcreds_error.log @@ -0,0 +1,12 @@ +.. +.. +.. +.. +.. +.. +php /var/www/html/iTop/webservices/cron.php +ERROR: Missing argument 'auth_user' + +USAGE: + +php cron.php --auth_user= --auth_pwd= [--param_file=] [--verbose=1] [--debug=1] [--status_only=1] \ No newline at end of file diff --git a/tests/php-unit-tests/unitary-tests/webservices/resources/cron_notanadmin.log b/tests/php-unit-tests/unitary-tests/webservices/resources/cron_notanadmin.log new file mode 100644 index 000000000..8c1122102 --- /dev/null +++ b/tests/php-unit-tests/unitary-tests/webservices/resources/cron_notanadmin.log @@ -0,0 +1,5 @@ +.. +.. +.. +.. +Access restricted to administrators \ No newline at end of file diff --git a/tests/php-unit-tests/unitary-tests/webservices/resources/cron_starting.log b/tests/php-unit-tests/unitary-tests/webservices/resources/cron_starting.log new file mode 100644 index 000000000..0ad3c3844 --- /dev/null +++ b/tests/php-unit-tests/unitary-tests/webservices/resources/cron_starting.log @@ -0,0 +1,5 @@ +.. +.. +.. +.. +Starting: 1772551316 (2026-03-03 16:21:56) \ No newline at end of file diff --git a/tests/php-unit-tests/unitary-tests/webservices/resources/cron_stopped.log b/tests/php-unit-tests/unitary-tests/webservices/resources/cron_stopped.log new file mode 100644 index 000000000..e0500a529 --- /dev/null +++ b/tests/php-unit-tests/unitary-tests/webservices/resources/cron_stopped.log @@ -0,0 +1,5 @@ +.. +.. +.. +.. +Exiting: 1772551316 (2026-03-03 16:21:56) \ No newline at end of file diff --git a/webservices/CronService.php b/webservices/CronService.php new file mode 100644 index 000000000..a477151bc --- /dev/null +++ b/webservices/CronService.php @@ -0,0 +1,100 @@ + --auth_pwd= [--param_file=] [--verbose=1] [--debug=1] [--status_only=1]"; + + private static CronService $oInstance; + + //used for test only + private ?iTopMutex $oMutex = null; + + protected function __construct() + { + } + + final public static function GetInstance(): CronService + { + if (!isset(self::$oInstance)) { + self::$oInstance = new CronService(); + } + + return self::$oInstance; + } + + final public static function SetInstance(?CronService $oInstance): void + { + self::$oInstance = $oInstance; + } + + private function GetMutex(): iTopMutex + { + if (! is_null($this->oMutex)) { + return $this->oMutex; + } + + return new iTopMutex('cron'); + } + + private function IsMutexLocked(): bool + { + return $this->GetMutex()->IsLocked(); + } + + public function IsStarted(string $sErrorFile): bool + { + $aLines = utils::ReadTail($sErrorFile); + $sLine = array_shift($aLines); + return (preg_match('/Starting: (.*)$/', $sLine)); + } + + public function IsStopped(string $sErrorFile): bool + { + $aLines = utils::ReadTail($sErrorFile); + $sLine = array_shift($aLines); + return (preg_match('/Exiting: (.*)$/', $sLine)); + } + + public function IsFailed(string $sLogFile): bool + { + return ! is_null($this->GetErrorMessage($sLogFile)); + } + + public function GetErrorMessage(string $sLogFile): ?string + { + $aLines = utils::ReadTail($sLogFile); + $sLine = array_shift($aLines); + if (preg_match('/ERROR: (.*)$/', $sLine, $aMatches)) { + return $aMatches[1]; + } + + if (preg_match('/Exiting: (.*)$/', $sLine, $aMatches)) { + //stopped + return null; + } + + $aStoppedMessages = [ + 'Already running', + 'Access restricted to administrators', + 'A maintenance is ongoing', + 'Maintenance detected', + 'iTop is not yet installed', + ]; + foreach ($aStoppedMessages as $sMsg) { + if (preg_match("/$sMsg/", $sLine, $aMatches)) { + return $sLine; + } + } + + if (false !== strpos($sLine, self::HELP_MESSAGE)) { + $aLines = utils::ReadTail($sLogFile, 5); + foreach ($aLines as $sLine) { + if (preg_match('/ERROR: (.*)$/', $sLine, $aMatches)) { + return $aMatches[1]; + } + } + } + + return null; + } +} diff --git a/webservices/asynchronously_cron.php b/webservices/asynchronously_cron.php index b0ba91932..14f060717 100644 --- a/webservices/asynchronously_cron.php +++ b/webservices/asynchronously_cron.php @@ -6,6 +6,7 @@ require_once(__DIR__.'/../approot.inc.php'); require_once(APPROOT.'/application/application.inc.php'); require_once(APPROOT.'/application/loginwebpage.class.inc.php'); require_once(APPROOT.'/application/startup.inc.php'); +require_once(__DIR__.'/CronService.php'); function GetCliCommand(string $sPHPExec, string $sLogFile, array $aCronValues): string { @@ -13,20 +14,6 @@ function GetCliCommand(string $sPHPExec, string $sLogFile, array $aCronValues): return sprintf("$sPHPExec %s/cron.php $sCliParams 2>&1 >>$sLogFile &", __DIR__); } -function IsErrorLine(string $sLine): bool -{ - if (preg_match('/^Access wrong credentials/', $sLine)) { - return true; - } - - if (preg_match('/^Access restricted/', $sLine)) { - return true; - } - - return false; - -} - function ReadParam($sParam, $sDefaultValue = null, $sSanitizationFilter = utils::ENUM_SANITIZATION_FILTER_RAW_DATA) { $sValue = utils::ReadParam($sParam, null, true, $sSanitizationFilter); @@ -37,15 +24,9 @@ function ReadParam($sParam, $sDefaultValue = null, $sSanitizationFilter = utils: return trim($sValue); } -function IsCronStartingLine(string $sLine): bool +function LoginAndGetTokenInfo(): array { - return preg_match('/^Starting: /', $sLine); -} - -try { - $oCtx = new ContextTag(ContextTag::TAG_CRON); - \IssueLog::Enable(APPROOT.'log/error.log'); - + //handle authentication LoginWebPage::ResetSession(); $iRet = LoginWebPage::DoLogin(false, false, LoginWebPage::EXIT_RETURN); if ($iRet != LoginWebPage::EXIT_CODE_OK) { @@ -59,50 +40,61 @@ try { throw new \Exception("cannot call cron asynchronously via current login mode $sCurrentLoginMode"); } + /** @var iTokenLoginUIExtension $oLoginFSMExtensionInstance */ + $aTokenInfo = $oLoginFSMExtensionInstance->GetTokenInfo(); + return [$sCurrentLoginMode, base64_encode(json_encode($aTokenInfo))]; +} + +$sCliForLogs = null; +try { + $oCtx = new ContextTag(ContextTag::TAG_CRON); + + list($sCurrentLoginMode, $sTokenInfo) = LoginAndGetTokenInfo(); + + $sLogFilename = ReadParam("cron_log_file", "cron.log"); + $sLogFile = APPROOT."log/$sLogFilename"; + if (! touch($sLogFile)) { + throw new \Exception("Cannot touch $sLogFile"); + } + \IssueLog::Enable($sLogFile); + $aCronValues = []; foreach ([ 'verbose', 'debug'] as $sParam) { $value = ReadParam($sParam, false); $aCronValues[] = "--$sParam=".escapeshellarg($value); } - /** @var iTokenLoginUIExtension $oLoginFSMExtensionInstance */ - $aTokenInfo = $oLoginFSMExtensionInstance->GetTokenInfo(); - $sTokenInfo = base64_encode(json_encode($aTokenInfo)); $aCronValues[] = "--login_mode=".escapeshellarg($sCurrentLoginMode); - $sLogFilename = ReadParam("cron_log_file", "cron.log"); - $sLogFile = APPROOT."log/$sLogFilename"; - - if (! touch($sLogFile)) { - throw new \Exception("Cannot touch $sLogFile"); - } - $sPHPExec = trim(\MetaModel::GetConfig()->Get('php_path')); $aCronValues[] = "--auth_info=".escapeshellarg('XXXX'); $sCliForLogs = GetCliCommand($sPHPExec, $sLogFile, $aCronValues).PHP_EOL; - \IssueLog::Info("launch cron asynchronously/remotely", null, ['cli' => $sCliForLogs]); + \IssueLog::Info("Launch cron asynchronously", null, ['cli' => $sCliForLogs]); $aCronValues[] = "--auth_info=".escapeshellarg($sTokenInfo); $sCli = GetCliCommand($sPHPExec, $sLogFile, $aCronValues); + $process = popen($sCli, 'r'); - // $process = proc_open($sCli, 'r'); if (false === $process) { throw new \Exception("CLI execution issue"); } - /*$aStatus = proc_get_status($process); - if ($aStatus['running']) { - var_dump($aStatus); - throw new \Exception(var_export($aStatus)); - }*/ + $i = 0; + $bCronStartedOrFailed = true; + while ($i < 20) { + usleep(100000); - while ($aLines = utils::ReadTail($sLogFile)) { - $sLastLine = array_shift($aLines); - if (IsErrorLine($sLastLine) || IsCronStartingLine($sLastLine)) { - //return answer once we are sure cron is starting or did not pass authentication + if (CronService::GetInstance()->IsStarted($sLogFile) || CronService::GetInstance()->IsFailed($sLogFile)) { + //make sure cron is really started or failed before answering by HTTP + $bCronStartedOrFailed = false; break; } - usleep(100); + + $i++; + } + + if ($bCronStartedOrFailed) { + throw new \Exception("Cron execution timeout"); } http_response_code(200); @@ -112,12 +104,12 @@ try { $oP->SetOutputDataOnly(true); $oP->Output(); } catch (\Exception $e) { - \IssueLog::Error('Cannot run cron', null, ['msg' => $e->getMessage(), 'stack' => $e->getTraceAsString()]); + \IssueLog::Error('Cannot run cron', null, ['msg' => $e->getMessage(), 'stack' => $e->getTraceAsString(), 'cli' => $sCliForLogs ?? '', ]); http_response_code(500); $oP = new JsonPage(); $oP->add_header('Access-Control-Allow-Origin: *'); - $oP->SetData(["message" => $e->getMessage(), 'cli' => $sCli ?? '', 'msg' => $e->getMessage(), 'stack' => $e->getTraceAsString()]); + $oP->SetData(['msg' => $e->getMessage()]); $oP->SetOutputDataOnly(true); $oP->Output(); } diff --git a/webservices/cron.php b/webservices/cron.php index c0059205f..63f8ed07f 100644 --- a/webservices/cron.php +++ b/webservices/cron.php @@ -24,9 +24,11 @@ use Combodo\iTop\Application\WebPage\WebPage; use Combodo\iTop\Service\InterfaceDiscovery\InterfaceDiscovery; require_once(__DIR__.'/../approot.inc.php'); +require_once(__DIR__.'/CronService.php'); const EXIT_CODE_ERROR = -1; const EXIT_CODE_FATAL = -2; + // early exit if (file_exists(READONLY_MODE_FILE)) { echo "iTop is read-only. Exiting...\n"; @@ -37,7 +39,6 @@ require_once(APPROOT.'/application/application.inc.php'); require_once(APPROOT.'/core/background.inc.php'); IssueLog::Enable(APPROOT.'log/error.log'); - $sConfigFile = APPCONF.ITOP_DEFAULT_ENV.'/'.ITOP_CONFIG_FILE; if (!file_exists($sConfigFile)) { echo "iTop is not yet installed. Exiting...\n"; @@ -65,7 +66,7 @@ function UsageAndExit($oP) if ($bModeCLI) { $oP->p("USAGE:\n"); - $oP->p("php cron.php --auth_user= --auth_pwd= [--param_file=] [--verbose=1] [--debug=1] [--status_only=1]\n"); + $oP->p(\CronService::HELP_MESSAGE."\n"); } else { $oP->p("Optional parameters: verbose, param_file, status_only\n"); } @@ -461,7 +462,6 @@ if ($bIsModeCLI) { try { $bVerbose = utils::ReadParam('verbose', false, true /* Allow CLI */); $bDebug = utils::ReadParam('debug', false, true /* Allow CLI */); - if ($bIsModeCLI) { utils::UseParamFile(); @@ -485,6 +485,7 @@ try { if ($oLoginFSMExtensionInstance instanceof iTokenLoginUIExtension) { $aTokenInfo = json_decode(base64_decode($sTokenInfo), true); + IssueLog::Error("TTTTTTT $sLoginMode TTTTTTTTTTTTTt"); /** @var iTokenLoginUIExtension $oLoginFSMExtensionInstance */ $sAuthUser = $oLoginFSMExtensionInstance->GetUserLogin($aTokenInfo); UserRights::Login($sAuthUser); // Login & set the user's language @@ -539,7 +540,9 @@ try { } } finally { try { - $oMutex->Unlock(); + if (isset($oMutex)) { + $oMutex->Unlock(); + } } catch (Exception $e) { $oP->p("ERROR: '".$e->getMessage()."'"); if ($bDebug) { diff --git a/webservices/get_cron_status.php b/webservices/get_cron_status.php index a47963b28..36f48da4a 100644 --- a/webservices/get_cron_status.php +++ b/webservices/get_cron_status.php @@ -3,8 +3,8 @@ require_once(__DIR__.'/../approot.inc.php'); require_once(APPROOT.'/application/application.inc.php'); require_once(APPROOT.'/application/startup.inc.php'); +require_once(__DIR__.'/CronService.php'); -const ERROR_ALREADY_RUNNING = "error_already_running"; const RUNNING = "running"; const STOPPED = "stopped"; const ERROR = "error"; @@ -17,25 +17,28 @@ try { throw new Exception("Unknown authentication error (retCode=$iRet)", RestResult::UNAUTHORIZED); } + if (!UserRights::IsAdministrator()) { + throw new Exception("Access restricted to administrators"); + } + $sLogFilename = ReadParam("cron_log_file", "cron.log"); - - $sStatus = RUNNING; - $sMsg = ""; $sLogFile = APPROOT."log/$sLogFilename"; + if (! is_file($sLogFile)) { + throw new \Exception("Cannot read log file"); + } - $aLines = utils::ReadTail($sLogFile, 2); - $sLastLine = $aLines[1] ?? ''; - if (0 === strpos($sLastLine, 'Exiting: ')) { - $sContent = $aLines[0]; - if (false !== strpos($sContent, 'Already running')) { - $sStatus = ERROR_ALREADY_RUNNING; - } elseif (preg_match('/ERROR: (.*)\\n/', $sContent, $aMatches)) { - $sMsg = $aMatches[1]; - $sStatus = ERROR; + $sMsg = ""; + if (CronService::GetInstance()->IsStopped($sLogFile)) { + $sStatus = STOPPED; + } else { + $sErrorMsg = CronService::GetInstance()->GetErrorMessage($sLogFile); + if (is_null($sErrorMsg)) { + $sStatus = RUNNING; } else { - $sMsg = "$sContent"; - $sStatus = STOPPED; + $sMsg = $sErrorMsg; + $sStatus = ERROR; } + $sStatus = is_null($sMsg) ? RUNNING : ERROR; } http_response_code(200);