Compare commits

...

16 Commits

Author SHA1 Message Date
Benjamin DALSASS
43121a5a4b N°9379 - PHP unserialze function - security hardening
- typo
2026-04-09 16:34:35 +02:00
Benjamin DALSASS
ff2f10e5b6 N°9379 - PHP unserialze function - security hardening
- ormcaselog index
2026-04-09 15:26:49 +02:00
Benjamin DALSASS
ddaf014898 N°9379 - PHP unserialze function - security hardening
- Ensure shortcut doen't contain php objects, otherwise delete shortcut
2026-04-09 14:59:06 +02:00
Benjamin DALSASS
a71fefa328 N°9379 - PHP unserialze function - security hardening
- Create an unserialze function encapsulation
- Ensure data table settings doesn't contain php objects, otherwise revert to default settings
2026-04-09 08:31:41 +02:00
lenaick.moreira
00735f0c54 N°9483 - The search suggestion message is not displaying correctly 2026-04-08 09:48:15 +02:00
lenaick.moreira
882390e8d6 N°9043 - Fix automatic search on direct linkset block 2026-04-08 09:48:15 +02:00
Lenaick
5d0da47f21 N°8178 - Respect "high_cardinality_classes" parameter on search operation (#870) 2026-04-08 09:47:46 +02:00
lenaick.moreira
4eadff7f3b ♻️ Refactor BODY_DATA_GUI_TYPE constants to public visibility (N°9101) 2026-04-08 09:38:40 +02:00
Molkobain
f66ce1c956 Fix cURL calls to iTop within Docker dev. environment with PHP version autodetection (#869) 2026-04-07 19:06:30 +02:00
jf-cbd
802f9f3e08 N°8576 - [RequestTemplate] Slowness during the selection of drop-down list field (#872) 2026-04-07 17:06:21 +02:00
lenaick.moreira
5c5d98bb78 Enable the retrieval of all data for event notifications in the activity panel 2026-04-01 16:12:36 +02:00
Stephen Abello
a08a9b43f3 N°8737 - Fix search criteria dropdown vertical overflow (#867)
* N°8737 - Fix search criteria dropdown vertical overflow

* Update css/backoffice/components/_search-form.scss
2026-04-01 14:57:10 +02:00
jf-cbd
abd85ff4db Merge remote-tracking branch 'origin/support/3.2' into support/3.2 2026-04-01 10:37:59 +02:00
jf-cbd
81f328b26e N°8543 - rename security parameter with new convention 2026-04-01 10:37:46 +02:00
lenaick.moreira
9a2c8f10bf Fix CliResetSessionTest by allowing empty POST fields to be defined in the curl options 2026-04-01 10:35:59 +02:00
Lenaick
3cdadf3c6e Add tests for lock acquisition functionality (#865) 2026-03-31 17:01:22 +02:00
24 changed files with 509 additions and 100 deletions

View File

@@ -1546,7 +1546,16 @@ class ShortcutMenuNode extends MenuNode
public function GetHyperlink($aExtraParams)
{
$sContext = $this->oShortcut->Get('context');
$aContext = unserialize($sContext);
try {
$aContext = utils::Unserialize($sContext, ['allowed_classes' => false]);
} catch (Exception $e) {
IssueLog::Warning("User shortcut corrupted, delete the shortcut", LogChannels::CONSOLE, [
'shortcut_name' => $this->oShortcut->GetName(),
'root_cause' => $e->getMessage(),
]);
// delete the shortcut
$this->oShortcut->DBDelete();
}
if (isset($aContext['menu'])) {
unset($aContext['menu']);
}

View File

@@ -228,7 +228,7 @@ JS
<<<HTML
<form id="ObjectsAddForm_{$this->sInputid}">
<div id="SearchResultsToAdd_{$this->sInputid}">
<div style="background: #fff; border:0; text-align:center; vertical-align:middle;"><p>{$sEmptyList}</p></div>
<div style="border:0; text-align:center; vertical-align:middle;"><p>{$sEmptyList}</p></div>
</div>
<input type="hidden" id="count_{$this->sInputid}" value="0"/>
</form>

View File

@@ -68,7 +68,7 @@ class UISearchFormForeignKeys
<<<HTML
<form id="ObjectsAddForm_{$this->m_iInputId}">
<div id="SearchResultsToAdd_{$this->m_iInputId}" style="vertical-align:top;height:100%;overflow:auto;padding:0;border:0;">
<div style="background: #fff; border:0; text-align:center; vertical-align:middle;"><p>{$sEmptyList}</p></div>
<div style="border:0; text-align:center; vertical-align:middle;"><p>{$sEmptyList}</p></div>
</div>
<input type="hidden" id="count_{$this->m_iInputId}" value="0"/>
</form>

View File

@@ -3252,4 +3252,50 @@ TXT
return $aTrace;
}
/**
* PHP unserialize encapsulation, allow throwing exception when not allowed object class is detected (for security hardening)
*
* @param mixed $data data to unserialize
* @param array $aOptions PHP @unserialise options
* @param bool $bThrowNotAllowedObjectClassException flag to throw exception
*
* @return mixed PHP @unserialise return
* @throws Exception
*/
public static function Unserialize(mixed $data, array $aOptions, bool $bThrowNotAllowedObjectClassException = true): mixed
{
$data = unserialize($data, $aOptions);
if ($bThrowNotAllowedObjectClassException) {
try {
self::AssertNoIncompleteClassDetected($data);
} catch (Exception $e) {
throw new CoreException('Unserialization failed because an incomplete class was detected.', [], '', $e);
}
}
return $data;
}
/**
* Assert that data provided doesn't contain any incomplete class.
*
* @throws Exception
*/
public static function AssertNoIncompleteClassDetected(mixed $data): void
{
if (is_object($data)) {
if ($data instanceof __PHP_Incomplete_Class) {
throw new Exception('__PHP_Incomplete_Class_Name object detected');
}
foreach (get_object_vars($data) as $property) {
self::AssertNoIncompleteClassDetected($property);
}
} elseif (is_array($data)) {
foreach ($data as $value) {
self::AssertNoIncompleteClassDetected($value);
}
}
}
}

View File

@@ -4829,7 +4829,7 @@ class AttributeCaseLog extends AttributeLongText
}
if (strlen($sIndex) > 0) {
$aIndex = unserialize($sIndex);
$aIndex = utils::Unserialize($sIndex, ['allowed_classes' => false], false);
$value = new ormCaseLog($sLog, $aIndex);
} else {
$value = new ormCaseLog($sLog);

View File

@@ -1746,11 +1746,11 @@ class Config
'source_of_value' => '',
'show_in_conf_sample' => false,
],
'security.force_login_when_no_delegated_authentication_endpoints_list' => [
'security.disable_exec_forced_login_for_all_enpoints' => [
'type' => 'bool',
'description' => 'If true, when no execution policy is defined, the user will be forced to log in (instead of being automatically logged in with the default profile)',
'default' => false,
'value' => false,
'description' => 'If true, when no delegated authentication module is defined, no login will be forced on modules exec endpoints',
'default' => true,
'value' => true,
'source_of_value' => '',
'show_in_conf_sample' => false,
],

View File

@@ -21,6 +21,8 @@ $ibo-search-form-panel--more-criteria--color: $ibo-color-blue-grey-800 !default;
$ibo-search-form-panel--more-criteria--background-color: $ibo-color-white-100 !default;
$ibo-search-form-panel--more-criteria--icon--color: $ibo-color-primary-600 !default;
$ibo-search-form-panel--more-criteria--border-color: $ibo-search-form-panel--criteria--border-color !default;
// calc is redundant but avoid SCSS min() from being used instead of CSS min()
$ibo-search-form-panel--criteria--max-height: calc(min(#{$ibo-size-750}, 50vh)) !default;
$ibo-search-form-panel--items--hover--color: $ibo-color-grey-200 !default;
@@ -278,9 +280,10 @@ $ibo-search-results-area--datatable-scrollhead--border--is-sticking: $ibo-search
}
.sfc_form_group {
display: block;
margin-top: -1px;
z-index: -1;
display: flex;
flex-direction: column;
margin-top: -1px;
z-index: -1;
}
}
@@ -346,11 +349,15 @@ $ibo-search-results-area--datatable-scrollhead--border--is-sticking: $ibo-search
display: none;
max-width: 450px;
width: max-content;
max-height: 520px;
max-height: $ibo-search-form-panel--criteria--max-height;
overflow-x: auto;
overflow-y: hidden;
.sfc_fg_operators {
display: flex;
flex-direction: column;
overflow: auto;
min-height: 0;
font-size: 12px;
.sfc_fg_operator {
@@ -387,6 +394,9 @@ $ibo-search-results-area--datatable-scrollhead--border--is-sticking: $ibo-search
}
.sfc_opc_multichoices {
display: flex;
flex-direction: column;
height: 100%;
label > input {
vertical-align: text-top;
margin-left: $ibo-spacing-0;
@@ -398,7 +408,6 @@ $ibo-search-results-area--datatable-scrollhead--border--is-sticking: $ibo-search
}
.sfc_opc_mc_items_wrapper {
max-height: 415px; /* Must be less than .sfc_form_group:max-height - .sfc_opc_mc_toggler:height - .sfc_opc_mc_filter:height */
overflow-y: auto;
margin: $ibo-spacing-0 -8px; /* Compensate .sfc_opc_multichoices side padding so the hover style can take the full with */
@@ -560,8 +569,14 @@ $ibo-search-results-area--datatable-scrollhead--border--is-sticking: $ibo-search
&.search_form_criteria_enum {
.sfc_form_group {
.sfc_fg_operator_in {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
> label {
display: inline-block;
display: flex;
height: 100%;
min-height: 0;
width: 100%;
line-height: initial;
white-space: nowrap;

View File

@@ -189,11 +189,14 @@ $(function()
this.buildData.script_code = '';
this.buildData.style_code = '';
for (var i in oData.updated_fields)
for (let i in oData.updated_fields)
{
var oUpdatedField = oData.updated_fields[i];
this.options.fields_list[oUpdatedField.id] = oUpdatedField;
this._prepareField(oUpdatedField.id);
const oUpdatedField = oData.updated_fields[i];
const oPreviousField = this.options.fields_list[oUpdatedField.id];
if (!oPreviousField || JSON.stringify(oPreviousField) !== JSON.stringify(oUpdatedField)) {
this.options.fields_list[oUpdatedField.id] = oUpdatedField;
this._prepareField(oUpdatedField.id);
}
}
// Adding code to the dom

View File

@@ -193,6 +193,7 @@ return array(
'Combodo\\iTop\\Application\\Helper\\CKEditorHelper' => $baseDir . '/sources/Application/Helper/CKEditorHelper.php',
'Combodo\\iTop\\Application\\Helper\\ExportHelper' => $baseDir . '/sources/Application/Helper/ExportHelper.php',
'Combodo\\iTop\\Application\\Helper\\FormHelper' => $baseDir . '/sources/Application/Helper/FormHelper.php',
'Combodo\\iTop\\Application\\Helper\\SearchHelper' => $baseDir . '/sources/Application/Helper/SearchHelper.php',
'Combodo\\iTop\\Application\\Helper\\Session' => $baseDir . '/sources/Application/Helper/Session.php',
'Combodo\\iTop\\Application\\Helper\\WebResourcesHelper' => $baseDir . '/sources/Application/Helper/WebResourcesHelper.php',
'Combodo\\iTop\\Application\\Newsroom\\iTopNewsroomProvider' => $baseDir . '/sources/Application/Newsroom/iTopNewsroomProvider.php',

View File

@@ -548,6 +548,7 @@ class ComposerStaticInit7f81b4a2a468a061c306af5e447a9a9f
'Combodo\\iTop\\Application\\Helper\\CKEditorHelper' => __DIR__ . '/../..' . '/sources/Application/Helper/CKEditorHelper.php',
'Combodo\\iTop\\Application\\Helper\\ExportHelper' => __DIR__ . '/../..' . '/sources/Application/Helper/ExportHelper.php',
'Combodo\\iTop\\Application\\Helper\\FormHelper' => __DIR__ . '/../..' . '/sources/Application/Helper/FormHelper.php',
'Combodo\\iTop\\Application\\Helper\\SearchHelper' => __DIR__ . '/../..' . '/sources/Application/Helper/SearchHelper.php',
'Combodo\\iTop\\Application\\Helper\\Session' => __DIR__ . '/../..' . '/sources/Application/Helper/Session.php',
'Combodo\\iTop\\Application\\Helper\\WebResourcesHelper' => __DIR__ . '/../..' . '/sources/Application/Helper/WebResourcesHelper.php',
'Combodo\\iTop\\Application\\Newsroom\\iTopNewsroomProvider' => __DIR__ . '/../..' . '/sources/Application/Newsroom/iTopNewsroomProvider.php',

View File

@@ -5,6 +5,7 @@
* @license http://opensource.org/licenses/AGPL-3.0
*/
use Combodo\iTop\Application\Helper\SearchHelper;
use Combodo\iTop\Application\Helper\Session;
use Combodo\iTop\Application\TwigBase\Twig\TwigHelper;
use Combodo\iTop\Application\UI\Base\Component\Button\ButtonUIBlockFactory;
@@ -126,72 +127,6 @@ function SetObjectBreadCrumbEntry(DBObject $oObj, WebPage $oPage)
$oPage->SetBreadCrumbEntry("ui-details-$sClass-".$oObj->GetKey(), $oObj->Get('friendlyname'), MetaModel::GetName($sClass).': '.$oObj->Get('friendlyname'), '', $sIcon, $sIconType);
}
/**
* Displays the result of a search request
* @param $oP WebPage Web page for the output
* @param $oFilter DBSearch The search of objects to display
* @param $bSearchForm boolean Whether or not to display the search form at the top the page
* @param $sBaseClass string The base class for the search (can be different from the actual class of the results)
* @param $sFormat string The format to use for the output: csv or html
* @param $bDoSearch bool True to display the search results below the search form
* @param $bSearchFormOpen bool True to display the search form fully expanded (only if $bSearchForm of course)
* @throws \CoreException
* @throws \DictExceptionMissingString
*/
function DisplaySearchSet($oP, $oFilter, $bSearchForm = true, $sBaseClass = '', $sFormat = '', $bDoSearch = true, $bSearchFormOpen = true, $aParams = [])
{
//search block
$oBlockForm = null;
if ($bSearchForm) {
$aParams['open'] = $bSearchFormOpen;
if (false === isset($aParams['table_id'])) {
$aParams['table_id'] = 'result_1';
}
if (!empty($sBaseClass)) {
$aParams['baseClass'] = $sBaseClass;
}
$oBlockForm = new DisplayBlock($oFilter, 'search', false /* Asynchronous */, $aParams);
if (!$bDoSearch) {
$oBlockForm->Display($oP, 0);
}
}
if ($bDoSearch) {
if (strtolower($sFormat) == 'csv') {
$oBlock = new DisplayBlock($oFilter, 'csv', false);
// Adjust the size of the Textarea containing the CSV to fit almost all the remaining space
$oP->add_ready_script(" $('#1>textarea').height($('#1').parent().height() - $('#0').outerHeight() - 30).width( $('#1').parent().width() - 20);"); // adjust the size of the block
} else {
$oBlock = new DisplayBlock($oFilter, 'list', false);
// Breadcrumb
//$iCount = $oBlock->GetDisplayedCount();
$sPageId = "ui-search-".$oFilter->GetClass();
$sLabel = MetaModel::GetName($oFilter->GetClass());
$oP->SetBreadCrumbEntry($sPageId, $sLabel, '', '', 'fas fa-search', iTopWebPage::ENUM_BREADCRUMB_ENTRY_ICON_TYPE_CSS_CLASSES);
}
if ($bSearchForm) {
//add search block
$sTableId = utils::ReadParam('_table_id_', null, false, 'raw_data');
if ($sTableId == '') {
$sTableId = 'result_1';
}
$aExtraParams['table_id'] = $sTableId;
$aExtraParams['submit_on_load'] = false;
$oUIBlockForm = $oBlockForm->GetDisplay($oP, 'search_1', $aExtraParams);
//add result block
$oUIBlock = $oBlock->GetDisplay($oP, $sTableId);
$oUIBlock->AddCSSClasses(['display_block', 'sf_results_area']);
$oUIBlock->AddDataAttribute('target', 'search_results');
//$oUIBlockForm->AddSubBlock($oUIBlock);
$oP->AddUiBlock($oUIBlockForm);
$oUIBlockForm->AddSubBlock($oUIBlock);
} else {
$oBlock->Display($oP, 1);
}
}
}
/**
* Displays a form (checkboxes) to select the objects for which to apply a given action
* Only the objects for which the action is valid can be checked. By default all valid objects are checked
@@ -460,7 +395,7 @@ try {
$sOQL = "SELECT $sOQLClass $sOQLClause";
try {
$oFilter = DBObjectSearch::FromOQL($sOQL);
DisplaySearchSet($oP, $oFilter, $bSearchForm, $sBaseClass, $sFormat);
SearchHelper::DisplaySearchSet($oP, $oFilter, $bSearchForm, $sBaseClass, $sFormat);
} catch (CoreException $e) {
$oFilter = new DBObjectSearch($sOQLClass);
$oSet = new DBObjectSet($oFilter);
@@ -487,7 +422,7 @@ try {
}
$oP->set_title(Dict::S('UI:SearchResultsPageTitle'));
$oFilter = new DBObjectSearch($sClass);
DisplaySearchSet($oP, $oFilter, $bSearchForm, '' /* sBaseClass */, $sFormat, $bDoSearch, true /* Search Form Expanded */);
SearchHelper::DisplaySearchSet($oP, $oFilter, $bSearchForm, '' /* sBaseClass */, $sFormat, $bDoSearch, true /* Search Form Expanded */);
break;
///////////////////////////////////////////////////////////////////////////////////////////
@@ -509,7 +444,7 @@ try {
// $sParams = utils::ReadParam('aParams', '{}', false, \utils::ENUM_SANITIZATION_FILTER_RAW_DATA);
// $aParams = json_decode($sParams, true);
DisplaySearchSet($oP, $oFilter, $bSearchForm, '' /* sBaseClass */, $sFormat); //, true, true, $aParams
SearchHelper::DisplaySearchSet($oP, $oFilter, $bSearchForm, '' /* sBaseClass */, $sFormat); //, true, true, $aParams
break;
///////////////////////////////////////////////////////////////////////////////////////////

View File

@@ -104,7 +104,7 @@ if ($sTargetPage === false || $sModule === 'core' || $sModule === 'dictionaries'
$aModuleDelegatedAuthenticationEndpointsList = GetModuleDelegatedAuthenticationEndpoints($sModule);
// If module doesn't have the delegated authentication endpoints list defined, we rely on the conf. param. to decide if we force login or not.
if (is_null($aModuleDelegatedAuthenticationEndpointsList)) {
$bForceLoginWhenNoDelegatedAuthenticationEndpoints = utils::GetConfig()->Get('security.force_login_when_no_delegated_authentication_endpoints_list');
$bForceLoginWhenNoDelegatedAuthenticationEndpoints = !utils::GetConfig()->Get('security.disable_exec_forced_login_for_all_enpoints');
if ($bForceLoginWhenNoDelegatedAuthenticationEndpoints) {
require_once(APPROOT.'/application/startup.inc.php');
LoginWebPage::DoLoginEx();

View File

@@ -36,7 +36,7 @@ class SetupPage extends NiceWebPage
{
public const DEFAULT_PAGE_TEMPLATE_REL_PATH = 'pages/backoffice/setuppage/layout';
protected const BODY_DATA_GUI_TYPE = 'setup';
public const BODY_DATA_GUI_TYPE = 'setup';
public function __construct($sTitle)
{

View File

@@ -0,0 +1,87 @@
<?php
/*
* @copyright Copyright (C) 2010-2026 Combodo SAS
* @license http://opensource.org/licenses/AGPL-3.0
*/
namespace Combodo\iTop\Application\Helper;
use Combodo\iTop\Application\WebPage\iTopWebPage;
use Combodo\iTop\Application\WebPage\WebPage;
use DBSearch;
use DisplayBlock;
use MetaModel;
use utils;
class SearchHelper
{
/**
* Displays the result of a search request
* @param $oP WebPage Web page for the output
* @param $oFilter DBSearch The search of objects to display
* @param $bSearchForm boolean Whether or not to display the search form at the top the page
* @param $sBaseClass string The base class for the search (can be different from the actual class of the results)
* @param $sFormat string The format to use for the output: csv or html
* @param $bDoSearch bool True to display the search results below the search form
* @param $bSearchFormOpen bool True to display the search form fully expanded (only if $bSearchForm of course)
* @throws \CoreException
* @throws \DictExceptionMissingString
*/
public static function DisplaySearchSet($oP, $oFilter, $bSearchForm = true, $sBaseClass = '', $sFormat = '', $bDoSearch = true, $bSearchFormOpen = true, $aParams = []): void
{
//search block
$oBlockForm = null;
if ($bSearchForm) {
$aParams['open'] = $bSearchFormOpen;
if (false === isset($aParams['table_id'])) {
$aParams['table_id'] = 'result_1';
}
if (!empty($sBaseClass)) {
$aParams['baseClass'] = $sBaseClass;
}
$oBlockForm = new DisplayBlock($oFilter, 'search', false /* Asynchronous */, $aParams);
if (!$bDoSearch) {
$oBlockForm->Display($oP, 0);
}
}
if ($bDoSearch) {
if (strtolower($sFormat) == 'csv') {
$oBlock = new DisplayBlock($oFilter, 'csv', false);
// Adjust the size of the Textarea containing the CSV to fit almost all the remaining space
$oP->add_ready_script(" $('#1>textarea').height($('#1').parent().height() - $('#0').outerHeight() - 30).width( $('#1').parent().width() - 20);"); // adjust the size of the block
} else {
$oBlock = new DisplayBlock($oFilter, 'list', false);
// Breadcrumb
//$iCount = $oBlock->GetDisplayedCount();
$sPageId = "ui-search-".$oFilter->GetClass();
$sLabel = MetaModel::GetName($oFilter->GetClass());
$oP->SetBreadCrumbEntry($sPageId, $sLabel, '', '', 'fas fa-search', iTopWebPage::ENUM_BREADCRUMB_ENTRY_ICON_TYPE_CSS_CLASSES);
}
if ($bSearchForm) {
//add search block
$sTableId = utils::ReadParam('_table_id_', null, false, 'raw_data');
if ($sTableId == '') {
$sTableId = 'result_1';
}
$aExtraParams['table_id'] = $sTableId;
$aExtraParams['submit_on_load'] = false;
$oUIBlockForm = $oBlockForm->GetDisplay($oP, 'search_1', $aExtraParams);
// If the class is not high cardinality, we can display the results directly in the same page
if (!utils::IsHighCardinality($oFilter->GetClass())) {
//add result block
$oUIBlock = $oBlock->GetDisplay($oP, $sTableId);
$oUIBlock->AddCSSClasses(['display_block', 'sf_results_area']);
$oUIBlock->AddDataAttribute('target', 'search_results');
$oUIBlockForm->AddSubBlock($oUIBlock);
}
$oP->AddUiBlock($oUIBlockForm);
} else {
$oBlock->Display($oP, 1);
}
}
}
}

View File

@@ -8,8 +8,13 @@ use AttributeFriendlyName;
use AttributeLinkedSet;
use cmdbAbstract;
use cmdbAbstractObject;
use CoreException;
use Dict;
use Exception;
use IssueLog;
use LogChannels;
use Metamodel;
use utils;
/**
* Class DataTableSettings
@@ -130,7 +135,10 @@ class DataTableSettings
*/
public function unserialize($sData)
{
$aData = unserialize($sData);
$aData = utils::Unserialize($sData, ['allowed_classes' => false]);
if (!is_array($aData)) {
throw new CoreException('Wrong data table settings format, expected an array', ['datatable_settings_data' => $aData]);
}
$this->iDefaultPageSize = $aData['iDefaultPageSize'];
$this->aColumns = $aData['aColumns'];
foreach ($this->aClassAliases as $sAlias => $sClass) {
@@ -269,7 +277,19 @@ class DataTableSettings
return null;
}
}
$oSettings->unserialize($pref);
try {
$oSettings->unserialize($pref);
} catch (Exception $e) {
IssueLog::Warning("User table settings corrupted, back to the default values provided by the data model", LogChannels::CONSOLE, [
'table_id' => $sTableId,
'root_cause' => $e->getMessage(),
]);
// unset the preference
appUserPreferences::UnsetPref($oSettings->GetPrefsKey($sTableId));
// use the default values provided by the data model
return null;
}
return $oSettings;
}

View File

@@ -152,6 +152,7 @@ class ActivityPanelFactory
if (false === empty($aRelatedTriggersIDs)) {
// - Prepare query to retrieve events
$oNotifEventsSearch = DBObjectSearch::FromOQL('SELECT EN FROM EventNotification AS EN JOIN Action AS A ON EN.action_id = A.id WHERE EN.trigger_id IN (:triggers_ids) AND EN.object_id = :object_id');
$oNotifEventsSearch->AllowAllData();
$oNotifEventsSet = new DBObjectSet($oNotifEventsSearch, ['id' => false], ['triggers_ids' => $aRelatedTriggersIDs, 'object_id' => $sObjId]);
$oNotifEventsSet->SetLimit(MetaModel::GetConfig()->Get('max_history_length'));

View File

@@ -152,7 +152,7 @@ class WebPage implements Page
*/
public const DEFAULT_PAGE_TEMPLATE_REL_PATH = 'pages/backoffice/webpage/layout';
protected const BODY_DATA_GUI_TYPE = 'backoffice';
public const BODY_DATA_GUI_TYPE = 'backoffice';
protected $s_title;
protected $s_content;

View File

@@ -7,6 +7,6 @@ oWidget{{ oUIBlock.oUILinksDirectWidget.GetInputId() }} = $('#{{ oUIBlock.oUILin
input_name: '{{ oUIBlock.sInputName }}',
submit_to: '{{ oUIBlock.sSubmitUrl }}',
oWizardHelper: {{ oUIBlock.sWizHelper }},
do_search: '{{ oUIBlock.sJSDoSearch }}'
do_search: {{ oUIBlock.sJSDoSearch }}
});
{% endapply %}

View File

@@ -49,7 +49,7 @@
{% endfor %}
{% endblock %}
</head>
<body data-gui-type="{{ aPage.sBodyDataGuiType|default('backoffice') }}">
<body data-gui-type="{{ aPage.sBodyDataGuiType|default(constant('Combodo\\iTop\\Application\\WebPage\\WebPage::BODY_DATA_GUI_TYPE')) }}">
{% if aPage.isPrintable %}<div class="printable-content" style="width: 27.7cm;">{% endif %}
{% block iboPageBodyHtml %}
<div id="ibo-page-container">

View File

@@ -26,14 +26,14 @@ class LoginWebPageTest extends ItopDataTestCase
$this->BackupConfiguration();
$sFolderPath = APPROOT.'env-production/extension-with-delegated-authentication-endpoints-list';
if (file_exists($sFolderPath)) {
throw new Exception("Folder $sFolderPath already exists, please remove it before running the test");
$this->RecurseRmdir($sFolderPath);
}
mkdir($sFolderPath);
$this->RecurseCopy(__DIR__.'/extension-with-delegated-authentication-endpoints-list', $sFolderPath);
$sFolderPath = APPROOT.'env-production/extension-without-delegated-authentication-endpoints-list';
if (file_exists($sFolderPath)) {
throw new Exception("Folder $sFolderPath already exists, please remove it before running the test");
$this->RecurseRmdir($sFolderPath);
}
mkdir($sFolderPath);
$this->RecurseCopy(__DIR__.'/extension-without-delegated-authentication-endpoints-list', $sFolderPath);
@@ -81,8 +81,7 @@ class LoginWebPageTest extends ItopDataTestCase
public function testUserCanAccessAnyFile()
{
// generate random login
$sUserLogin = 'user-'.date('YmdHis');
$sUserLogin = 'user-'.uniqid();
$this->CreateUser($sUserLogin, self::$aURP_Profiles['Service Desk Agent'], self::PASSWORD);
$this->GivenConfigFileAllowedLoginTypes(explode('|', 'form'));
@@ -102,7 +101,7 @@ class LoginWebPageTest extends ItopDataTestCase
public function testWithoutDelegatedAuthenticationEndpointsListWithForceLoginConf()
{
@chmod($this->oConfig->GetLoadedFile(), 0770);
$this->oConfig->Set('security.force_login_when_no_delegated_authentication_endpoints_list', true, 'AnythingButEmptyOrUnknownValue'); // 3rd param to write file even if show_in_conf_sample is false
$this->oConfig->Set('security.disable_exec_forced_login_for_all_enpoints', false, 'AnythingButEmptyOrUnknownValue'); // 3rd param to write file even if show_in_conf_sample is false
$this->oConfig->WriteToFile();
@chmod($this->oConfig->GetLoadedFile(), 0444);
$sPageContent = $this->CallItopUri(

View File

@@ -1470,4 +1470,19 @@ abstract class ItopDataTestCase extends ItopTestCase
@chmod($sConfigPath, 0440);
@unlink($this->sConfigTmpBackupFile);
}
protected function AddLoginModeAndSaveConfiguration(string $sLoginMode): void
{
$aAllowedLoginTypes = $this->oiTopConfig->GetAllowedLoginTypes();
if (!in_array($sLoginMode, $aAllowedLoginTypes)) {
$aAllowedLoginTypes[] = $sLoginMode;
$this->oiTopConfig->SetAllowedLoginTypes($aAllowedLoginTypes);
$this->SaveItopConfFile();
}
}
protected function SaveItopConfFile(): void
{
@chmod($this->oiTopConfig->GetLoadedFile(), 0770);
$this->oiTopConfig->WriteToFile();
@chmod($this->oiTopConfig->GetLoadedFile(), 0440);
}
}

View File

@@ -668,7 +668,7 @@ abstract class ItopTestCase extends KernelTestCase
}
curl_setopt($ch, CURLOPT_URL, $sUrl);
curl_setopt($ch, CURLOPT_POST, 1);// set post data to true
curl_setopt($ch, CURLOPT_POST, $aCurlOptions[CURLOPT_POST] ?? 1);// set post data to true
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
// Force disable of certificate check as most of dev / test env have a self-signed certificate
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
@@ -708,6 +708,16 @@ abstract class ItopTestCase extends KernelTestCase
{
$sUrl = \MetaModel::GetConfig()->Get('app_root_url')."/$sUri";
// Add PHP version in header to be able to handle Docker dev environments with automatic PHP version detection (instead of hardcoding the PHP version in the app_root_url)
$sPhpVersion = PHP_VERSION;
$aPhpVersionParts = explode('.', $sPhpVersion);
$sPhpVersionHeaderValue = ($aPhpVersionParts[0] ?? '0').($aPhpVersionParts[1] ?? '0');
$aCurlOptions = $aCurlOptions ?? [];
$aCurlOptions[CURLOPT_HTTPHEADER] = array_merge(
$aCurlOptions[CURLOPT_HTTPHEADER] ?? [],
['X-PHP-Version: '.$sPhpVersionHeaderValue]
);
return $this->CallUrl($sUrl, $aPostFields, $aCurlOptions, $bXDebugEnabled);
}
}

View File

@@ -0,0 +1,87 @@
<?php
/*
* @copyright Copyright (C) 2010-2026 Combodo SAS
* @license http://opensource.org/licenses/AGPL-3.0
*/
namespace Combodo\iTop\Test\UnitTest\Application\Helper;
use Combodo\iTop\Application\Helper\SearchHelper;
use Combodo\iTop\Application\WebPage\iTopWebPage;
use Combodo\iTop\Test\UnitTest\ItopDataTestCase;
use DBSearch;
use MetaModel;
class SearchHelperTest extends ItopDataTestCase
{
protected static array $aHighCardinalityClasses = [];
protected static bool $bSearchManualSubmit = false;
protected function setUp(): void
{
parent::setUp();
self::$aHighCardinalityClasses = MetaModel::GetConfig()->Get('high_cardinality_classes');
self::$bSearchManualSubmit = MetaModel::GetConfig()->Get('search_manual_submit');
}
protected function tearDown(): void
{
parent::tearDown();
MetaModel::GetConfig()->Set('high_cardinality_classes', static::$aHighCardinalityClasses);
MetaModel::GetConfig()->Set('search_manual_submit', static::$bSearchManualSubmit);
}
public function testDisplaySearchSetWithNoHighCardinalityClassesAddsResultSubBlock(): void
{
MetaModel::GetConfig()->Set('high_cardinality_classes', []);
MetaModel::GetConfig()->Set('search_manual_submit', false);
$oP = new iTopWebPage('SearchHelperTest');
$oFilter = DBSearch::FromOQL('SELECT UserRequest');
SearchHelper::DisplaySearchSet($oP, $oFilter);
$oContentLayout = $oP->GetContentLayout();
$this->assertTrue($oContentLayout->HasSubBlock('search_1'));
$oSearchBlock = $oContentLayout->getSubBlock('search_1');
$this->assertTrue($oSearchBlock->HasSubBlock('result_1'));
if (ob_get_level() > 0) {
ob_end_clean();
}
}
public function testDisplaySearchSetWithHighCardinalityClassesDoesNotAddResultSubBlock(): void
{
MetaModel::GetConfig()->Set('high_cardinality_classes', ['UserRequest']);
MetaModel::GetConfig()->Set('search_manual_submit', false);
$oP = new iTopWebPage('SearchHelperTest');
$oFilter = DBSearch::FromOQL('SELECT UserRequest');
SearchHelper::DisplaySearchSet($oP, $oFilter);
$oContentLayout = $oP->GetContentLayout();
$this->assertTrue($oContentLayout->HasSubBlock('search_1'));
$oSearchBlock = $oContentLayout->getSubBlock('search_1');
$this->assertFalse($oSearchBlock->HasSubBlock('result_1'));
if (ob_get_level() > 0) {
ob_end_clean();
}
}
public function testDisplaySearchSetWithSearchManualSubmitAndWithoutHighCardinalityClassesDoesNotAddResultSubBlock(): void
{
MetaModel::GetConfig()->Set('high_cardinality_classes', []);
MetaModel::GetConfig()->Set('search_manual_submit', true);
$oP = new iTopWebPage('SearchHelperTest');
$oFilter = DBSearch::FromOQL('SELECT UserRequest');
SearchHelper::DisplaySearchSet($oP, $oFilter);
$oContentLayout = $oP->GetContentLayout();
$this->assertTrue($oContentLayout->HasSubBlock('search_1'));
$oSearchBlock = $oContentLayout->getSubBlock('search_1');
$this->assertFalse($oSearchBlock->HasSubBlock('result_1'));
if (ob_get_level() > 0) {
ob_end_clean();
}
}
}

View File

@@ -0,0 +1,180 @@
<?php
namespace Combodo\iTop\Test\UnitTest\Pages;
use Combodo\iTop\Test\UnitTest\ItopDataTestCase;
use Dict;
use UserLocal;
use UserRequest;
class AjaxRenderTest extends ItopDataTestCase
{
public const USE_TRANSACTION = false;
public const AUTHENTICATION_PASSWORD = "tagada-Secret,007";
private static string $sLogin;
private static int $iTicketId;
protected function setUp(): void
{
parent::setUp();
$this->BackupConfiguration();
$this->oiTopConfig->Set('log_level_min', 'Error');
$this->oiTopConfig->Set('login_debug', true);
$this->CreateTestOrganization();
// Add URL authentication mode
$this->AddLoginModeAndSaveConfiguration('url');
// Create ticket
$description = date('dmY H:i:s');
$oTicket = $this->createObject('UserRequest', [
'org_id' => $this->getTestOrgId(),
"title" => "Houston, got a problem",
"description" => $description,
]);
self::$iTicketId = $oTicket->GetKey();
}
// Test that if a user with the right permissions tries to acquire the lock on a ticket, it succeeds and returns the correct success message
public function testAcquireLockSuccess(): void
{
$sOutput = $this->CreateSupportAgentUserAndAcquireLock();
$this->assertStringContainsString('"success":true', $sOutput);
}
// Test that if a user tries to acquire the lock on an object that does not exist, it fails and logs the correct error message
public function testAcquireLockFailsIfObjectDoesNotExist(): void
{
// Create a user with Support Agent Profile
$this->CreateUserWithProfile(self::$aURP_Profiles['Support Agent']);
// Try to acquire the lock on a non-existent object
$sOutput = $this->AcquireLockAsUser(self::$sLogin, 99999999);
// The output should indicate a fatal error because we hide the existence of the object when it does not exist or is not accessible by the user
$this->assertEquals(Dict::S('UI:PageTitle:FatalError'), $sOutput);
// Check that the error log contains the expected error message about the object not existing
$sLastErrorLogLines = $this->GetErrorLogLastLines(APPROOT.'log/error.log', 10);
$this->assertStringContainsString(Dict::S('UI:ObjectDoesNotExist'), $sLastErrorLogLines);
}
// Test that if a user tries to acquire the lock on an object for which they don't have modification rights, it fails and logs the correct error message
public function testAcquireLockFailsIfUserHasNoModifyRights(): void
{
// Create a user with a profile without modification rights on UserRequest
$this->CreateUserWithProfile(self::$aURP_Profiles['Configuration Manager']);
// Try to acquire the lock on the ticket
$sOutput = $this->AcquireLockAsUser(self::$sLogin, self::$iTicketId);
// The output should indicate a fatal error because we hide the existence of the object when it does not exist or is not accessible by the user
$this->assertEquals(Dict::S('UI:PageTitle:FatalError'), $sOutput);
// The user should not have the rights to acquire the lock, and an error should be logged
$sLastErrorLogLines = $this->GetErrorLogLastLines(APPROOT.'log/error.log', 10);
$this->assertStringContainsString(Dict::S('UI:ObjectDoesNotExist'), $sLastErrorLogLines);
}
// Test that if a user tries to acquire the lock on an object that belongs to another organization, it fails and logs the correct error message
public function testAcquireLockFailsIfObjectInOtherOrg(): void
{
// Create an organization and a ticket in this organization
$iOtherOrgId = $this->createObject('Organization', ['name' => 'OtherOrg'])->GetKey();
$oTicket = $this->createObject('UserRequest', [
'org_id' => $iOtherOrgId,
'title' => 'Ticket autre org',
'description' => 'Test',
]);
// Create a user who only has access to the main test organization
$oUser = $this->CreateUserWithProfile(self::$aURP_Profiles['Support Agent']);
$oAllowedOrgList = $oUser->Get('allowed_org_list');
$oUserOrg = \MetaModel::NewObject('URP_UserOrg', ['allowed_org_id' => $this->getTestOrgId()]);
$oAllowedOrgList->AddItem($oUserOrg);
$oUser->Set('allowed_org_list', $oAllowedOrgList);
$oUser->DBWrite();
// Try to acquire the lock on the ticket of the other organization
$sOutput = $this->AcquireLockAsUser(self::$sLogin, $oTicket->GetKey());
// The output should indicate a fatal error because we hide the existence of the object when it does not exist or is not accessible by the user
$this->assertEquals(Dict::S('UI:PageTitle:FatalError'), $sOutput);
// The user should not have access to the ticket of the other organization, so an error should be logged
$sLastErrorLogLines = $this->GetErrorLogLastLines(APPROOT.'log/error.log', 10);
$this->assertStringContainsString(Dict::S('UI:ObjectDoesNotExist'), $sLastErrorLogLines);
}
// Test that if a user has already acquired the lock on an object, another user cannot acquire it and gets the correct error message
public function testAcquireLockFailsIfAlreadyLockedByAnotherUser(): void
{
// First, acquire the lock with a user (User A)
$this->CreateSupportAgentUserAndAcquireLock();
$sUserALogin = self::$sLogin;
// Create a second user (User B) who tries to acquire the lock
$sOutput = $this->CreateSupportAgentUserAndAcquireLock();
// The second user should not be able to acquire the lock, and the output should contain the correct error message indicating that the object is already locked by User A
$this->assertStringContainsString('"success":false', $sOutput);
$this->assertStringContainsString('"message":"'.Dict::Format('UI:CurrentObjectIsSoftLockedBy_User', $sUserALogin).'"', $sOutput);
}
// Helper method to create a user with Support Agent profile and acquire the lock on the ticket
private function CreateSupportAgentUserAndAcquireLock(): string
{
// Create a user with Support Agent Profile
$this->CreateUserWithProfile(self::$aURP_Profiles['Support Agent']);
return $this->AcquireLockAsUser(self::$sLogin, self::$iTicketId);
}
// Helper method to create a user with a specific profile
private function CreateUserWithProfile(int $iProfileId): UserLocal
{
self::$sLogin = uniqid('AjaxRenderTest');
return $this->CreateContactlessUser(self::$sLogin, $iProfileId, self::AUTHENTICATION_PASSWORD);
}
// Helper method to acquire the lock on a ticket as a specific user
private function AcquireLockAsUser(string $sLogin, int $iTicketId): string
{
$aGetFields = [
'operation' => 'acquire_lock',
'auth_user' => $sLogin,
'auth_pwd' => self::AUTHENTICATION_PASSWORD,
'obj_class' => UserRequest::class,
'obj_key' => $iTicketId,
];
return $this->CallItopUri(
"pages/ajax.render.php?".http_build_query($aGetFields),
[],
[
CURLOPT_HTTPHEADER => ['X-Combodo-Ajax:1'],
CURLOPT_POST => 0,
]
);
}
// Returns the last lines of the error log containing only errors (Error level)
private function GetErrorLogLastLines(string $sErrorLogPath, int $iLineNumbers = 1): string
{
if (!file_exists($sErrorLogPath)) {
return '';
}
$aLines = file($sErrorLogPath, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
// Keep only lines containing '| Error |'
$aErrorLines = array_filter($aLines, function ($line) {
return preg_match('/\|\s*Error\s*\|/', $line);
});
// Return the last requested lines
return implode("\n", array_slice($aErrorLines, -$iLineNumbers));
}
}