Compare commits

...

4 Commits

Author SHA1 Message Date
Anne-Catherine
5715e0484f N°9759 - Truncating AttributeText doesn't work as expected in case of multibytes characters (#964)
* N°9759 - Truncate AttributeText don't work as expected in case of multibytes characters

* WIP

* Move TrimValue() base definition to AttributeDefinition class, assign DBObject::SetTrim old behavior to it

* Fix variable types

* Fix variable types

* Apply code review fixes, add tests

* Define GetSize in AttributeDefinition to easy polymorphic calls instead of obscure AttributeDBFieldVoid

* Update php with right since version and ticket, remove useless method overload

* Apply suggestions from code review

Co-authored-by: Molkobain <lajarige.guillaume@free.fr>

* Update core/attributedef.class.inc.php

Co-authored-by: Molkobain <lajarige.guillaume@free.fr>

* Update core/attributedef.class.inc.php

Co-authored-by: Molkobain <lajarige.guillaume@free.fr>

* Apply suggestion from @Molkobain code review

Co-authored-by: Molkobain <lajarige.guillaume@free.fr>

* Apply suggestion from @Molkobain code review

Co-authored-by: Molkobain <lajarige.guillaume@free.fr>

* Apply suggestion from @Molkobain code review

Co-authored-by: Molkobain <lajarige.guillaume@free.fr>

* Apply suggestion from @Molkobain code review

Co-authored-by: Molkobain <lajarige.guillaume@free.fr>

* Apply suggestion from @Molkobain code review

Co-authored-by: Molkobain <lajarige.guillaume@free.fr>

* Correctly handle null values in new methods

---------

Co-authored-by: Stephen Abello <stephen.abello@combodo.com>
Co-authored-by: Molkobain <lajarige.guillaume@free.fr>
2026-07-17 16:40:09 +02:00
Molkobain
d6ef4fb7bb N°9687 - Fix incorrect return type for the DBObjectSearch::ApplyDataFilter() function (#939)
* N°9687 - Fix incorrect return type for the DBObjectSearch::ApplyDataFilter() function

* N°9687 - Add unit test

* N°9687 - Improve unit test after code review

* N°9687 - Improve unit test after code review
2026-07-02 16:11:32 +02:00
Lenaick
d6ce202fa8 N°9614 - Add a specific route to display the icons for an EventNotificationNewsroom (#911)
* N°9614 - Add a specific route to display the icons for an EventNotificationNewsroom

* N°9614 - Display the icon in EventNotificationNewsroom only for events relating to the logged-in user

* N°9614 - Refactor icon download logic in EventNotificationNewsroomService and add unit tests

* Add unit test to verify icon download functionality for users without read access
2026-05-20 14:18:13 +02:00
Stephen Abello
1c38d989e4 N°9603 - Fix transitions applied when editing only caselog are not working (#908)
* N°9603 - Fix transitions applied when editing only caselog are not working

* Remove unused variable
2026-05-18 17:28:46 +02:00
14 changed files with 524 additions and 53 deletions

View File

@@ -897,6 +897,60 @@ abstract class AttributeDefinition
return null;
}
/**
* Return the size of $value, expressed in the same unit as {@see static::GetMaxSize()} for this attribute class.
*
* Default unit is a number of **characters**, matching MySQL VARCHAR(M) semantics for VARCHAR-based attributes.
* Byte-based attributes (e.g. {@see AttributeText}, stored as MySQL TEXT which is limited to 65535 **bytes**,
* not characters) MUST override both this method and {@see TrimValue()} consistently.
*
* @param string|null $sValue
*
* @return int Size of $value in the unit of GetMaxSize() (characters by default)
* @since 3.2.3-2 3.2.4 3.3.0 N°9759
*/
public function GetSize(?string $sValue)
{
// If the value is null, we return 0
if ($sValue === null) {
return 0;
}
return mb_strlen($sValue);
}
/**
* Helper to set a value that fits the attribute max size
*
* Truncation is performed in the same unit as GetMaxSize() / {@see GetSize()}: a number of **characters**
* by default (VARCHAR-based attributes). When truncated, a " -truncated (N chars)" suffix is appended and
* the returned value (suffix included) still fits within GetMaxSize().
*
* Default behavior is what DBObject::SetTrim used to do, now delegated to AttributeDefinition
*
* @param string|null $sValue
*
* @return string $sValue truncated so that it fits within {@see GetMaxSize()}.
* @since 3.2.3-2 3.2.4 3.3.0 N°9759
*/
public function TrimValue(?string $sValue)
{
// If the value is null, we return an empty string
if ($sValue === null) {
return '';
}
$iMaxSize = $this->GetMaxSize();
$iLength = mb_strlen($sValue);
if ($iMaxSize && ($iLength > $iMaxSize)) {
$sMessage = " -truncated ($iLength chars)";
return mb_substr($sValue, 0, $iMaxSize - mb_strlen($sMessage)).$sMessage;
}
return $sValue;
}
/**
* @return mixed|null
* @deprecated never used
@@ -4219,6 +4273,51 @@ class AttributeText extends AttributeString
return "TEXT".CMDBSource::GetSqlStringColumnDefinition();
}
/**
* @inheritDoc
*
* Unlike the default implementation, the size is expressed in **bytes**: MySQL TEXT columns are limited
* in bytes (65535), not in characters, and {@see static::GetMaxSize()} for this class returns a number of bytes.
*/
public function GetSize(?string $sValue)
{
// If the value is null, we return 0
if ($sValue === null) {
return 0;
}
return strlen($sValue);
}
/**
* @inheritDoc
*
* Truncation is performed on a **byte** budget (MySQL TEXT limit) without ever cutting through a multibyte
* UTF-8 sequence: the returned value is always valid UTF-8 and never exceeds GetMaxSize() bytes,
* truncation suffix included.
*/
public function TrimValue(?string $sValue)
{
// If the value is null, we return an empty string
if ($sValue === null) {
return '';
}
$iMaxSize = $this->GetMaxSize();
$iLength = strlen($sValue);
$iLengthChar = mb_strlen($sValue);
if ($iMaxSize && ($iLength > $iMaxSize)) {
$sMessage = " -truncated ($iLengthChar chars)";
$iTruncatedValueMaxSize = $iMaxSize - strlen($sMessage);
// mb_strcut cuts on a byte budget but moves the cut point back to a character boundary,
// so it never returns a broken multibyte sequence at the end of the value
$sTruncatedValue = mb_strcut($sValue, 0, $iTruncatedValueMaxSize, 'UTF-8');
return $sTruncatedValue.$sMessage;
}
return $sValue;
}
public function GetSQLColumns($bFullSpec = false)
{
$aColumns = [];

View File

@@ -732,13 +732,8 @@ abstract class DBObject implements iDisplay
public function SetTrim($sAttCode, $sValue)
{
$oAttDef = MetaModel::GetAttributeDef(get_class($this), $sAttCode);
$iMaxSize = $oAttDef->GetMaxSize();
$sLength = mb_strlen($sValue);
if ($iMaxSize && ($sLength > $iMaxSize)) {
$sMessage = " -truncated ($sLength chars)";
$sValue = mb_substr($sValue, 0, $iMaxSize - mb_strlen($sMessage)).$sMessage;
}
$this->Set($sAttCode, $sValue);
$this->Set($sAttCode, $oAttDef->TrimValue($sValue));
}
/**
@@ -2040,7 +2035,7 @@ abstract class DBObject implements iDisplay
}
}
if (!is_null($iMaxSize = $oAtt->GetMaxSize())) {
$iLen = mb_strlen($toCheck);
$iLen = $oAtt->GetSize($toCheck);
if ($iLen > $iMaxSize) {
return "String too long (found $iLen, limited to $iMaxSize)";
}

View File

@@ -1928,9 +1928,8 @@ class DBObjectSearch extends DBSearch
/**
* @inheritDoc
* @return DBObjectSearch
*/
protected function ApplyDataFilters(): DBObjectSearch
protected function ApplyDataFilters(): DBSearch
{
if ($this->IsAllDataAllowed() || $this->IsDataFiltered()) {
return $this;

View File

@@ -676,9 +676,8 @@ class DBUnionSearch extends DBSearch
/**
* @inheritDoc
* @return DBUnionSearch
*/
protected function ApplyDataFilters(): DBUnionSearch
protected function ApplyDataFilters(): DBSearch
{
if ($this->IsAllDataAllowed() || $this->IsDataFiltered()) {
return $this;

View File

@@ -340,13 +340,14 @@ class ormDocument
* @param string $sContentDisposition Either 'inline' or 'attachment'
* @param string $sSecretField The attcode of the field containing a "secret" to be provided in order to retrieve the file
* @param string $sSecretValue The value of the secret to be compared with the value of the attribute $sSecretField
* @param bool $bAllowAllData If true, no rights filtering is applied
*
* @return void
*/
public static function DownloadDocument(WebPage $oPage, $sClass, $id, $sAttCode, $sContentDisposition = 'attachment', $sSecretField = null, $sSecretValue = null)
public static function DownloadDocument(WebPage $oPage, $sClass, $id, $sAttCode, $sContentDisposition = 'attachment', $sSecretField = null, $sSecretValue = null, $bAllowAllData = false)
{
try {
$oObj = MetaModel::GetObject($sClass, $id, false, false);
$oObj = MetaModel::GetObject($sClass, $id, false, $bAllowAllData);
if (!is_object($oObj)) {
// If access to the document is not granted, check if the access to the host object is allowed
$oObj = MetaModel::GetObject($sClass, $id, false, true);

View File

@@ -13,7 +13,6 @@ use Combodo\iTop\Application\UI\Base\Component\Button\ButtonUIBlockFactory;
use Combodo\iTop\Application\UI\Base\Component\ButtonGroup\ButtonGroupUIBlockFactory;
use Combodo\iTop\Application\UI\Base\Component\PopoverMenu\PopoverMenu;
use Combodo\iTop\Application\UI\Base\Component\PopoverMenu\PopoverMenuItem\PopoverMenuItemFactory;
use Combodo\iTop\Application\UI\Base\Layout\ActivityPanel\CaseLogEntryForm\CaseLogEntryForm;
use DBObject;
use DBObjectSet;
use Dict;
@@ -39,7 +38,7 @@ class CaseLogEntryFormFactory
->AddMainActionButtons(static::PrepareCancelButton());
$oSaveButton = static::PrepareSaveButton();
$oTransitionsMenu = static::PrepareTransitionsSelectionPopoverMenu($oObject, $sCaseLogAttCode);
$oTransitionsMenu = static::PrepareTransitionsSelectionPopoverMenu($oObject, $sCaseLogAttCode, $oCaseLogEntryForm->GetId());
// Prevent popover menu from landing behind caselog editor
$oTransitionsMenu->SetContainer(PopoverMenu::ENUM_CONTAINER_BODY);
if (true === $oTransitionsMenu->HasItems()) {
@@ -71,7 +70,16 @@ class CaseLogEntryFormFactory
return $oButton;
}
protected static function PrepareTransitionsSelectionPopoverMenu(DBObject $oObject, string $sCaseLogAttCode): PopoverMenu
/**
* @param DBObject $oObject
* @param string $sCaseLogAttCode
* @param string $sCaseLogEntryFormId
* @since 3.2.3 Add mandatory $sCaseLogEntryFormId parameter
* @return PopoverMenu
* @throws \ArchivedObjectException
* @throws \CoreException
*/
protected static function PrepareTransitionsSelectionPopoverMenu(DBObject $oObject, string $sCaseLogAttCode, string $sCaseLogEntryFormId): PopoverMenu
{
$sObjClass = get_class($oObject);
@@ -79,8 +87,6 @@ class CaseLogEntryFormFactory
$sSectionId = 'send-actions';
$oMenu->AddSection($sSectionId);
$sCaseLogEntryFormDataRole = CaseLogEntryForm::BLOCK_CODE;
// Note: This code is inspired from cmdbAbstract::DisplayModifyForm(), it might be better to factorize it
$aTransitions = $oObject->EnumTransitions();
if (!isset($aExtraParams['custom_operation']) && count($aTransitions)) {
@@ -99,7 +105,7 @@ class CaseLogEntryFormFactory
CaseLogEntryForm::BLOCK_CODE.'--add-action--'.$sCaseLogAttCode.'--stimulus--'.$sStimulusCode,
Dict::Format('UI:Button:SendAnd', $aStimuli[$sStimulusCode]->GetLabel()),
<<<JS
$(this).closest('[data-role="{$sCaseLogEntryFormDataRole}"]').trigger('save_entry.caselog_entry_form.itop', {stimulus_code: '{$sStimulusCode}'});
$('#$sCaseLogEntryFormId').trigger('save_entry.caselog_entry_form.itop', {stimulus_code: '{$sStimulusCode}'});
JS
)
);

View File

@@ -16,10 +16,12 @@ use Combodo\iTop\Application\UI\Base\Component\Title\TitleUIBlockFactory;
use Combodo\iTop\Application\UI\Base\Component\Toolbar\ToolbarUIBlockFactory;
use Combodo\iTop\Application\UI\Base\Layout\Object\ObjectSummary;
use Combodo\iTop\Application\UI\Base\Layout\UIContentBlock;
use Combodo\iTop\Application\WebPage\DownloadPage;
use Combodo\iTop\Application\WebPage\iTopWebPage;
use Combodo\iTop\Application\WebPage\JsonPage;
use Combodo\iTop\Application\WebPage\JsonPPage;
use Combodo\iTop\Controller\Notifications\NotificationsCenterController;
use Combodo\iTop\Service\Notification\Event\EventNotificationNewsroomService;
use Combodo\iTop\Service\Notification\NotificationsRepository;
use Combodo\iTop\Service\Router\Router;
use CoreException;
@@ -27,6 +29,7 @@ use DBObjectSearch;
use DBObjectSet;
use Dict;
use EventNotificationNewsroom;
use Exception;
use MetaModel;
use SecurityException;
use UserRights;
@@ -376,9 +379,10 @@ JS
$oEventBlock->SetCSSColorClass($sReadColor);
$oEventBlock->SetSubTitle($sReadLabel);
$oEventBlock->SetClassLabel('');
/** @var \ormDocument $oImage */
$oImage = $oEvent->Get('icon');
if (!$oImage->IsEmpty()) {
$sIconUrl = $oImage->GetDisplayURL(get_class($oEvent), $iEventId, 'icon');
$sIconUrl = self::GetDisplayIconUrl($iEventId, $oImage->GetSignature());
$oEventBlock->SetIcon($sIconUrl, Panel::ENUM_ICON_COVER_METHOD_COVER, true);
}
@@ -542,7 +546,7 @@ $sMessage
HTML;
$sIcon = $oMessage->Get('icon') !== null ?
$oMessage->Get('icon')->GetDisplayURL(EventNotificationNewsroom::class, $oMessage->GetKey(), 'icon') :
$this->GetDisplayIconUrl($oMessage->GetKey(), $oMessage->Get('icon')->GetSignature()) :
Branding::GetCompactMainLogoAbsoluteUrl();
$aMessages[] = [
'id' => $oMessage->GetKey(),
@@ -689,6 +693,35 @@ HTML;
return $oPage;
}
/**
* Display the icon of an EventNotificationNewsroom
* (copy of ajax.render.php?operation=display_document but with the bAllowAllData parameter set to true in order to bypass the data access restrictions since the icon is not a critical information)
* @return void
* @throws \ConfigException
* @throws \CoreException
*/
public function OperationViewIcon(): void
{
$sId = utils::ReadParam('id', '');
if (!empty($sId)) {
$oPage = new DownloadPage('');
// X-Frame http header : set in page constructor, but we need to allow frame integration for this specific page
// so we're resetting its value ! (see N°3416)
$oPage->add_xframe_options('');
$iCacheSec = (int)utils::ReadParam('cache', 0);
$oPage->set_cache($iCacheSec);
// N°4129 - Prevent XSS attacks & other script executions
if (utils::GetConfig()->Get('security.disable_inline_documents_sandbox') === false) {
$oPage->add_header('Content-Security-Policy: sandbox;');
}
if (EventNotificationNewsroomService::DownloadIcon($oPage, $sId, UserRights::GetContactId()) === true) {
$oPage->Output();
}
}
}
/**
* @param string $sAction
*
@@ -781,4 +814,9 @@ HTML;
return $aReturnData;
}
protected function GetDisplayIconUrl(string $sId, string $sSignature): string
{
return utils::GetAbsoluteUrlAppRoot()."pages/UI.php?route=itopnewsroom.view_icon&id=$sId&s=$sSignature&cache=86400";
}
}

View File

@@ -4,8 +4,10 @@ namespace Combodo\iTop\Service\Notification\Event;
use Action;
use Combodo\iTop\Application\Branding;
use Combodo\iTop\Application\WebPage\WebPage;
use EventNotificationNewsroom;
use MetaModel;
use ormDocument;
use utils;
/**
@@ -70,4 +72,31 @@ class EventNotificationNewsroomService
return $oEvent;
}
/**
* @param \Combodo\iTop\Application\WebPage\WebPage $oPage
* @param string $sId
* @param int $iContactId
*
* @return bool Returns true if the download has been launched, false otherwise (e.g. if the event doesn't exist or doesn't belong to the current user)
* @throws \ArchivedObjectException
* @throws \CoreException
*/
public static function DownloadIcon(WebPage $oPage, string $sId, int $iContactId): bool
{
$oEvent = MetaModel::GetObject(EventNotificationNewsroom::class, $sId, false, true);
if (($oEvent !== null) && ($oEvent->Get('contact_id') === $iContactId)) {
ormDocument::DownloadDocument(
$oPage,
EventNotificationNewsroom::class,
$sId,
'icon',
ormDocument::ENUM_CONTENT_DISPOSITION_INLINE,
bAllowAllData: true
);
return true;
}
return false;
}
}

View File

@@ -37,7 +37,6 @@ use Team;
use User;
use UserRequest;
use UserRights;
use utils;
/**
* @group specificOrgInSampleData
@@ -1297,19 +1296,21 @@ class DBObjectTest extends ItopDataTestCase
{
return [
// UserRequest.title is an AttributeString (maxsize = 255)
'title 250 chars' => ['title', 250],
'title 254 chars' => ['title', 254],
'title 255 chars' => ['title', 255],
'title 256 chars' => ['title', 256],
'title 300 chars' => ['title', 300],
'title 250 chars' => ['title', 250, 250, true],
'title 254 chars' => ['title', 254, 254, true],
'title 255 chars' => ['title', 255, 255, true],
'title 256 chars' => ['title', 256, 255, false],
'title 300 chars' => ['title', 300, 255, false],
// UserRequest.pending_reason is an AttributeText (maxsize=65535) with format=text
'pending_reason 250 chars' => ['pending_reason', 250],
'pending_reason 60000 chars' => ['pending_reason', 60000],
'pending_reason 65534 chars' => ['pending_reason', 65534],
'pending_reason 65535 chars' => ['pending_reason', 65535],
'pending_reason 65536 chars' => ['pending_reason', 65536],
'pending_reason 70000 chars' => ['pending_reason', 70000],
'pending_reason 250 chars' => ['pending_reason', 250, 250, true],
'pending_reason 65534 chars' => ['pending_reason', 65534, 16403, false],
'pending_reason 65535 chars' => ['pending_reason', 65535, 16403, false],
'pending_reason 65536 chars' => ['pending_reason', 65536, 16403, false],
'pending_reason 16385 chars' => ['pending_reason', 16385, 16403, false],
'pending_reason 16384 chars' => ['pending_reason', 16384, 16384, true],
'pending_reason 16383 chars' => ['pending_reason', 16383, 16383, true],
'pending_reason 16382 chars' => ['pending_reason', 16382, 16382, true],
];
}
@@ -1322,7 +1323,7 @@ class DBObjectTest extends ItopDataTestCase
*
* @since 3.1.2 N°3448 - Framework field size check not correctly implemented for multi-bytes languages/strings
*/
public function testCheckLongValueInAttribute(string $sAttrCode, int $iValueLength)
public function testCheckLongValueInAttribute(string $sAttrCode, int $iValueLength, int $iExpectedLength, bool $bIsValueToSetBelowAttrMaxSize): void
{
$sPrefix = 'a'; // just a small prefix so that the emoji bytes won't have a power of 2 (we want a non even value)
$sEmojiToRepeat = '😎'; // this emoji is 4 bytes long
@@ -1343,17 +1344,18 @@ class DBObjectTest extends ItopDataTestCase
$oAttDef = MetaModel::GetAttributeDef(UserRequest::class, $sAttrCode);
$iAttrMaxSize = $oAttDef->GetMaxSize();
$bIsValueToSetBelowAttrMaxSize = ($iValueLength <= $iAttrMaxSize);
$bExpectedStatus = ($oAttDef->GetSize($sValueToSet) <= $iAttrMaxSize);
$this->assertSame($bExpectedStatus, $bIsValueToSetBelowAttrMaxSize, 'The data provider must stay aligned with the attribute max size logic.');
/** @noinspection PhpUnusedLocalVariableInspection */
[$bCheckStatus, $aCheckIssues, $bSecurityIssue] = $oTicket->CheckToWrite();
$this->assertEquals($bIsValueToSetBelowAttrMaxSize, $bCheckStatus, "CheckResult result:".var_export($aCheckIssues, true));
$oTicket->SetTrim($sAttrCode, $sValueToSet);
$sValueInObject = $oTicket->Get($sAttrCode);
$this->assertEquals($iExpectedLength, mb_strlen($sValueInObject), 'Should match expected resulting value length.');
if ($bIsValueToSetBelowAttrMaxSize) {
$this->assertEquals($sValueToSet, $sValueInObject, 'Should not alter string that is already shorter than attribute max length');
} else {
$this->assertEquals($iAttrMaxSize, mb_strlen($sValueInObject), 'Should truncate at the same length than attribute max length');
$sLastCharsOfValueInObject = mb_substr($sValueInObject, -30);
$this->assertStringContainsString(' -truncated', $sLastCharsOfValueInObject, 'Should end with "truncated" comment');
}
@@ -1386,6 +1388,61 @@ class DBObjectTest extends ItopDataTestCase
$this->assertEquals($sResult, $oOrganisation->Get('name'), 'SetTrim must limit string to 255 characters');
}
/**
* Check that DBObject::SetTrim doesn't cut through multibytes characters
*
* @covers DBObject::SetTrim
* @dataProvider SetTrimAttributeTextProvider
*/
public function testSetTrimOnAttributeTextKeepsUtf8Validity(string $sChar, int $iRepeatCount, bool $bExpectExactByteFill): void
{
$oTicket = MetaModel::NewObject('UserRequest', [
'ref' => 'Test Ticket',
'title' => 'Create OK',
'description' => 'Create OK',
'caller_id' => 15,
'org_id' => 3,
]);
$sValueToSet = str_repeat($sChar, $iRepeatCount);
$oTicket->SetTrim('pending_reason', $sValueToSet);
$sValueInObject = $oTicket->Get('pending_reason');
$oAttDef = MetaModel::GetAttributeDef('UserRequest', 'pending_reason');
$iAttrMaxSize = $oAttDef->GetMaxSize();
$iOriginalCharLength = mb_strlen($sValueToSet);
$sMessage = " -truncated ($iOriginalCharLength chars)";
$this->assertStringEndsWith($sMessage, $sValueInObject, 'Trimmed value should keep the expected truncation suffix.');
$this->assertTrue(mb_check_encoding($sValueInObject, 'UTF-8'), 'Trimmed value should stay valid UTF-8.');
$this->assertLessThanOrEqual($iAttrMaxSize, strlen($sValueInObject), 'Trimmed value should never exceed attribute byte max size.');
if ($bExpectExactByteFill) {
$this->assertSame($iAttrMaxSize, strlen($sValueInObject), 'When byte cut lands on a character boundary, SetTrim should use all available bytes.');
}
}
public function SetTrimAttributeTextProvider()
{
return [
// 2-byte UTF-8 chars: truncation payload size is byte-aligned and should fill the max size exactly.
'pending_reason 2-byte chars on byte boundary' => ["\xC3\xA9", 32768, true],
// 4-byte UTF-8 chars: truncation payload size is not byte-aligned and must backtrack to valid UTF-8.
'pending_reason 4-byte chars with mid-character byte cut' => ['💃', 16385, false],
];
}
/**
* @covers DBObject::SetTrim
*/
public function testSetTrimOnNonStringAttributeDoesNotTrim()
{
$oTicket = MetaModel::NewObject(UserRequest::class);
$oTicket->SetTrim('caller_id', '15');
$this->assertEquals(15, $oTicket->Get('caller_id'), 'SetTrim should keep non-string attributes untouched before regular Set conversion');
}
/**
* @covers DBObject::SetComputedDate
* @return void

View File

@@ -0,0 +1,67 @@
<?php
/*
* @copyright Copyright (C) 2010-2026 Combodo SAS
* @license http://opensource.org/licenses/AGPL-3.0
*/
namespace Combodo\iTop\Test\UnitTest\Core;
use Combodo\iTop\Test\UnitTest\ItopDataTestCase;
use DBObjectSearch;
use DBUnionSearch;
use UserRights;
class DBSearchApplyDataFiltersTest extends ItopDataTestCase
{
public const CREATE_TEST_ORG = true;
protected string $sOriginalUserRightsSelectModuleClass = '';
/**
* @throws \Exception
*/
protected function setUp(): void
{
parent::setUp();
// Backup original UserRights select module as it will changed in some tests
$this->sOriginalUserRightsSelectModuleClass = get_class(UserRights::GetModuleInstance());
$this->RequireOnceUnitTestFile('Fixtures/N9687_CustomGetSelectFilterClass.php');
}
/**
* @inheritDoc
*/
public function tearDown(): void
{
// Restore original UserRights select module to not interfere with next tests
UserRights::SelectModule($this->sOriginalUserRightsSelectModuleClass);
parent::tearDown();
}
public function testApplyDataFiltersOnDBObjectSearchShouldAcceptGetSelectFilterClassReturningDBUnionSearch()
{
// Use custom select filter that returns a DBUnionSearch
$sPreviousSelectModuleClass = get_class(UserRights::GetModuleInstance());
UserRights::SelectModule('\\Combodo\\iTop\\Test\\UnitTest\\Core\\Fixtures\\N9687_CustomGetSelectFilterClass');
// Create a user and login, otherwise the select filter won't apply
self::CreateUser('test_dbsearch_applydatafilters', 3);
UserRights::Login('test_dbsearch_applydatafilters');
// Create a person
$oCreatedPerson = $this->CreatePerson(microtime());
// Try to retrieve it using the select filter
$oSearch = DBObjectSearch::FromOQL("SELECT Person WHERE id = {$oCreatedPerson->GetKey()}");
$oFilteredSearch = $this->InvokeNonPublicMethod(DBObjectSearch::class, 'ApplyDataFilters', $oSearch);
// Restore original select module to not interfere with next tests
UserRights::SelectModule($sPreviousSelectModuleClass);
$this->assertEquals(DBUnionSearch::class, get_class($oFilteredSearch), "DBObjectSearch::ApplyDataFilters() should be able to return a \DBUnionSearch");
}
}

View File

@@ -0,0 +1,24 @@
<?php
/*
* @copyright Copyright (C) 2010-2026 Combodo SAS
* @license http://opensource.org/licenses/AGPL-3.0
*/
namespace Combodo\iTop\Test\UnitTest\Core\Fixtures;
use DBObjectSearch;
use DBUnionSearch;
use UserRightsProfile;
class N9687_CustomGetSelectFilterClass extends UserRightsProfile
{
public function GetSelectFilter($oUser, $sClass, $aSettings = [])
{
// We just need the method to return an union search
return new DBUnionSearch([
DBObjectSearch::FromOQL("SELECT $sClass WHERE 1!=2"),
DBObjectSearch::FromOQL("SELECT $sClass WHERE 1=1"),
]);
}
}

View File

@@ -198,6 +198,21 @@ class ormDocumentTest extends ItopDataTestCase
$this->assertStringNotContainsString('the object does not exist or you are not allowed to view it', $sAllowedHtml, 'Unexpected error message when rights are sufficient.');
}
/**
* @dataProvider DownloadDocumentRightsProvider
*/
public function testAllowsDownloadingDocumentWhenBypassingRightsChecksWithAllowAllData(string $sTargetClass, string $sAttCode, string $sData, string $sFileName, ?string $sHostClass)
{
$iDeniedDocumentId = $this->CreateDownloadTargetInOrg($sTargetClass, $sAttCode, $this->iOrgDifferentFromUser, $sData, $sFileName, $sHostClass);
$oPageAllowed = new CaptureWebPage();
ormDocument::DownloadDocument($oPageAllowed, $sTargetClass, $iDeniedDocumentId, $sAttCode, ormDocument::ENUM_CONTENT_DISPOSITION_INLINE, bAllowAllData: true);
$sAllowedHtml = $oPageAllowed->GetHtml();
$this->assertStringContainsString($sData, $sAllowedHtml, 'Expected file data present when bypassing rights checks.');
$this->assertStringNotContainsString("Invalid id ($iDeniedDocumentId) for class '$sTargetClass' - the object does not exist or you are not allowed to view it", $sAllowedHtml, 'Unexpected invalid id error message when bypassing rights checks.');
}
public function DownloadDocumentRightsProvider(): array
{
return [

View File

@@ -0,0 +1,154 @@
<?php
namespace Combodo\iTop\Test\UnitTest\Service\Notification\Event;
use Action;
use ActionNewsroom;
use Combodo\iTop\Application\WebPage\CaptureWebPage;
use Combodo\iTop\Service\Notification\Event\EventNotificationNewsroomService;
use Combodo\iTop\Test\UnitTest\ItopDataTestCase;
use Contact;
use EventNotificationNewsroom;
use Person;
use Ticket;
use Trigger;
use TriggerOnObjectMention;
use UserRequest;
use UserRights;
class EventNotificationNewsroomServiceTest extends ItopDataTestCase
{
public const CREATE_TEST_ORG = true;
private Contact $oContact;
private Trigger $oTrigger;
private Action $oAction;
private Ticket $oTicket;
private EventNotificationNewsroom $oEvent;
protected function setUp(): void
{
parent::setUp();
/** @var Contact $oContact */
$oContact = $this->createObject(Person::class, [
'name' => 'Khalo',
'first_name' => 'Frida',
'org_id' => $this->getTestOrgId(),
]);
$this->oContact = $oContact;
/** @var Trigger $oTrigger */
$oTrigger = $this->createObject(TriggerOnObjectMention::class, [
'description' => 'Person mentioned on Ticket',
'target_class' => 'Ticket',
]);
$this->oTrigger = $oTrigger;
/** @var Action $oAction */
$oAction = $this->createObject(ActionNewsroom::class, [
'name' => 'Notification to persons mentioned in logs',
'status' => 'enabled',
'title' => '$this->friendlyname$',
'message' => 'You have been mentioned by $current_contact->friendlyname$',
'recipients' => 'SELECT Person WHERE id = :mentioned->id',
]);
$this->oAction = $oAction;
/** @var Ticket $oTicket */
$oTicket = $this->createObject(UserRequest::class, [
'org_id' => $this->getTestOrgId(),
'title' => 'Houston, got a problem',
'description' => 'Test description',
]);
$this->oTicket = $oTicket;
}
protected function tearDown(): void
{
parent::tearDown();
$this->oEvent->DBDelete();
}
public function testDownloadIsTriggeredWhenDownloaderIsNotificationRecipient(): void
{
$this->oEvent = EventNotificationNewsroomService::MakeEventFromAction(
oAction: $this->oAction,
iContactId: $this->oContact->GetKey(),
iTriggerId: $this->oTrigger->GetKey(),
sMessage: 'Test message',
sTitle: 'Test event',
sUrl: 'https://localhost/itop/pages/UI.php?operation=details&class=UserRequest&id=1',
iObjectId: $this->oTicket->GetKey(),
sObjectClass: UserRequest::class,
);
$this->oEvent->DBInsert();
$oPage = new CaptureWebPage();
$bDownloadIcon = EventNotificationNewsroomService::DownloadIcon($oPage, $this->oEvent->GetKey(), $this->oContact->GetKey());
$sHtml = $oPage->GetHtml();
$this->assertTrue($bDownloadIcon);
$this->assertNotEquals('', $sHtml);
}
public function testDownloadIsNotTriggeredWhenDownloaderIsNotNotificationRecipient(): void
{
$oContact = $this->createObject(Person::class, [
'name' => 'Doe',
'first_name' => 'John',
'org_id' => $this->getTestOrgId(),
]);
$this->oEvent = EventNotificationNewsroomService::MakeEventFromAction(
oAction: $this->oAction,
iContactId: $oContact->GetKey(),
iTriggerId: $this->oTrigger->GetKey(),
sMessage: 'Test message',
sTitle: 'Test event',
sUrl: 'https://localhost/itop/pages/UI.php?operation=details&class=UserRequest&id=1',
iObjectId: $this->oTicket->GetKey(),
sObjectClass: UserRequest::class,
);
$this->oEvent->DBInsert();
$oPage = new CaptureWebPage();
$bDownloadIcon = EventNotificationNewsroomService::DownloadIcon($oPage, $this->oEvent->GetKey(), $this->oContact->GetKey());
$sHtml = $oPage->GetHtml();
$this->assertFalse($bDownloadIcon);
$this->assertEquals('', $sHtml);
}
public function testDownloadIconIsTriggeredEvenWhenUserCannotReadIconAttribute(): void
{
// Create a user with Support Agent Profile
$sLogin = uniqid('EventNotificationNewsroomServiceTest');
$oUser = $this->CreateContactlessUser($sLogin, self::$aURP_Profiles['Support Agent'], '1234@Abcdefg');
$oUser->Set('contactid', $this->oContact->GetKey());
UserRights::Login($sLogin);
$this->oEvent = EventNotificationNewsroomService::MakeEventFromAction(
oAction: $this->oAction,
iContactId: $this->oContact->GetKey(),
iTriggerId: $this->oTrigger->GetKey(),
sMessage: 'Test message',
sTitle: 'Test event',
sUrl: 'https://localhost/itop/pages/UI.php?operation=details&class=UserRequest&id=1',
iObjectId: $this->oTicket->GetKey(),
sObjectClass: UserRequest::class,
);
$this->oEvent->DBInsert();
$iURValue = UserRights::IsActionAllowedOnAttribute(EventNotificationNewsroom::class, 'icon', UR_ACTION_READ, $this->oEvent, $oUser);
$this->assertEquals(UR_ALLOWED_NO, $iURValue);
$oPage = new CaptureWebPage();
$bDownloadIcon = EventNotificationNewsroomService::DownloadIcon($oPage, $this->oEvent->GetKey(), $this->oContact->GetKey());
$sHtml = $oPage->GetHtml();
$this->assertTrue($bDownloadIcon);
$this->assertNotEquals('', $sHtml);
$this->assertStringNotContainsString('the object does not exist or you are not allowed to view it', $sHtml);
}
}

View File

@@ -277,25 +277,13 @@ abstract class WebServicesBase
$oLog->Set('userinfo', UserRights::GetUser());
$oLog->Set('verb', $sVerb);
$oLog->Set('result', $oRes->IsOk());
$this->TrimAndSetValue($oLog, 'log_info', (string)$oRes->GetInfoAsText());
$this->TrimAndSetValue($oLog, 'log_warning', (string)$oRes->GetWarningsAsText());
$this->TrimAndSetValue($oLog, 'log_error', (string)$oRes->GetErrorsAsText());
$this->TrimAndSetValue($oLog, 'data', (string)$oRes->GetReturnedDataAsText());
$oLog->SetTrim('log_info', (string)$oRes->GetInfoAsText());
$oLog->SetTrim('log_warning', (string)$oRes->GetWarningsAsText());
$oLog->SetTrim('log_error', (string)$oRes->GetErrorsAsText());
$oLog->SetTrim('data', (string)$oRes->GetReturnedDataAsText());
$oLog->DBInsertNoReload();
}
protected function TrimAndSetValue($oLog, $sAttCode, $sValue)
{
$oAttDef = MetaModel::GetAttributeDef(get_class($oLog), $sAttCode);
if (is_object($oAttDef)) {
$iMaxSize = $oAttDef->GetMaxSize();
if ($iMaxSize && (mb_strlen($sValue) > $iMaxSize)) {
$sValue = mb_substr($sValue, 0, $iMaxSize);
}
$oLog->Set($sAttCode, $sValue);
}
}
/**
* Helper to set a scalar attribute
*