diff --git a/CHANGELOG.md b/CHANGELOG.md index 04c2e9a..ffc35e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Avoid a per-tag database lookup when rendering the tag column in item lists - Ignore submitted tags that are not visible in the user entities when saving an item +- Fix tag associate item ## [2.14.6] - 2026-08-04 diff --git a/composer.json b/composer.json index f1e2ad1..7627874 100644 --- a/composer.json +++ b/composer.json @@ -12,6 +12,7 @@ }, "autoload": { "psr-4": { + "GlpiPlugin\\Tag\\": "src/", "GlpiPlugin\\Tag\\Tests\\": "tests" } }, diff --git a/inc/tagitem.class.php b/inc/tagitem.class.php index 1b2029c..8b35b1b 100644 --- a/inc/tagitem.class.php +++ b/inc/tagitem.class.php @@ -197,7 +197,7 @@ public static function showForTag(PluginTagTag $tag) if ($canedit) { echo "
"; echo "
"; + action='" . Toolbox::getItemTypeFormURL('PluginTagTagItem') . "'>"; echo ""; echo ""; @@ -223,7 +223,7 @@ public static function showForTag(PluginTagTag $tag) ]); echo ""; echo "
" . __s('Add an item') . "
"; echo sprintf("", $instID); - echo ""; + echo ""; echo "
"; diff --git a/src/Controller/TagItemController.php b/src/Controller/TagItemController.php new file mode 100644 index 0000000..800aaa4 --- /dev/null +++ b/src/Controller/TagItemController.php @@ -0,0 +1,103 @@ +. + * ------------------------------------------------------------------------- + * @copyright Copyright (C) 2014-2026 by Teclib'. + * @license GPLv2 https://www.gnu.org/licenses/gpl-2.0.html + * @link https://github.com/pluginsGLPI/tag + * ------------------------------------------------------------------------- + */ + +namespace GlpiPlugin\Tag\Controller; + +use CommonDBTM; +use Glpi\Controller\GenericFormController; +use Glpi\Exception\Http\AccessDeniedHttpException; +use Glpi\Exception\Http\BadRequestHttpException; +use Glpi\Http\RedirectResponse; +use Html; +use PluginTagTag; +use PluginTagTagItem; +use Session; +use Glpi\Routing\Attribute\ItemtypeFormRoute; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; + +final class TagItemController extends GenericFormController +{ + #[ItemtypeFormRoute(PluginTagTagItem::class)] + public function associate(Request $request): Response + { + Session::checkLoginUser(); + + $tag_id = $request->request->getInt('plugin_tag_tags_id'); + $itemtype = $request->request->get('itemtype'); + $item_id = $request->request->getInt('items_id'); + + if (!$tag_id || !$itemtype || !$item_id) { + throw new BadRequestHttpException(__s('Missing parameters', 'tag')); + } + + $tag = new PluginTagTag(); + if (!$tag->getFromDB($tag_id) || !$tag->can($tag_id, UPDATE)) { + throw new AccessDeniedHttpException(__s('You do not have permission to update this tag', 'tag')); + } + + if (!is_a($itemtype, CommonDBTM::class, true) || !PluginTagTag::canItemtype($itemtype)) { + throw new BadRequestHttpException(__s('Invalid item type', 'tag')); + } + + $item = new $itemtype(); + if (!$item->getFromDB($item_id) || !$item->canUpdateItem()) { + throw new AccessDeniedHttpException(__s('You do not have permission to update this item', 'tag')); + } + + if ( + isset($item->fields['entities_id']) + && $tag->fields['entities_id'] != $item->fields['entities_id'] + && ( + !$tag->fields['is_recursive'] + || !in_array($tag->fields['entities_id'], getAncestorsOf('glpi_entities', (int) $item->fields['entities_id'])) + ) + ) { + throw new AccessDeniedHttpException(__s('This tag is not available in the item entity', 'tag')); + } + + $tag_item = new PluginTagTagItem(); + $found = $tag_item->find([ + 'plugin_tag_tags_id' => $tag_id, + 'items_id' => $item_id, + 'itemtype' => $itemtype, + ]); + + if (count($found) === 0) { + $tag_item->add([ + 'plugin_tag_tags_id' => $tag_id, + 'items_id' => $item_id, + 'itemtype' => $itemtype, + ]); + } + + return new RedirectResponse(Html::getBackUrl()); + } +} diff --git a/tests/TagTestCase.php b/tests/TagTestCase.php index 8018c05..34b520f 100644 --- a/tests/TagTestCase.php +++ b/tests/TagTestCase.php @@ -46,7 +46,7 @@ protected function logOut() $_SESSION['glpi_currenttime'] = $ctime; } - public function loginAs(array $credentials): int + public function loginAs(array $credentials, int $rights = READ | CREATE | UPDATE | PURGE): int { global $DB; @@ -59,7 +59,7 @@ public function loginAs(array $credentials): int $DB->update( 'glpi_profilerights', [ - 'rights' => READ | CREATE | UPDATE | PURGE, + 'rights' => $rights, ], [ 'profiles_id' => $user_profile, @@ -72,17 +72,13 @@ public function loginAs(array $credentials): int return $user->getID(); } - public function createTag(string $tagName): int + public function createTag(string $tagName, array $typeMenu = ['Ticket']): int { - $tag = new PluginTagTag(); - $tag->add( - [ - 'name' => $tagName, - 'is_active' => 1, - 'type_menu' => ['Ticket'], - ], - ); - $this->assertGreaterThan(0, $tag->getID()); + $tag = $this->createItem(PluginTagTag::class, [ + 'name' => $tagName, + 'is_active' => 1, + 'type_menu' => $typeMenu, + ], ['type_menu']); return $tag->getID(); } diff --git a/tests/Units/TagItemTest.php b/tests/Units/TagItemTest.php index 0a27e12..f0c7066 100644 --- a/tests/Units/TagItemTest.php +++ b/tests/Units/TagItemTest.php @@ -30,20 +30,25 @@ namespace GlpiPlugin\Tag\Tests\Units; +use Computer; +use Glpi\Exception\Http\AccessDeniedHttpException; +use GlpiPlugin\Tag\Controller\TagItemController; use GlpiPlugin\Tag\Tests\TagTestCase; use PluginTagTag; +use Symfony\Component\HttpFoundation\Request; use Ticket; final class TagItemTest extends TagTestCase { + private const TECH_USER = ['login' => 'tech', 'pass' => 'tech']; + public function testTagsFromTicket(): void { - $this->login(); + $this->loginAs(self::TECH_USER); $tagID1 = $this->createTag('TicketTag1'); $tagID2 = $this->createTag('TicketTag2'); - $ticket = new Ticket(); $ticket->add([ 'name' => 'Ticket add Tag', @@ -91,4 +96,58 @@ public function testTagOutOfEntityScopeIsNotLinked(): void $this->isItemNotTagged($ticket, $tagID); } + + public function testTagAssociationCreatesLink(): void + { + $this->loginAs(self::TECH_USER); + + $tag = $this->createTag('MyTag', ['Computer']); + $computer = $this->createItem(Computer::class, [ + 'name' => 'Computer to tag', + 'entities_id' => 0, + ]); + + $controller = new TagItemController(); + $request = Request::create('/plugins/tag/associate', 'POST', [ + 'plugin_tag_tags_id' => $tag, + 'itemtype' => Computer::class, + 'items_id' => $computer->getID(), + ]); + + $controller->associate($request); + + $this->isItemTagged($computer, $tag); + } + + //test takes a non-recursive tag associated with _test_child_2 and a computer in _test_child_1. It calls + //associate() with these two elements and waits for an exception. It logs in as a superadmin with the root entity + //set to recursive, to ensure that it is indeed the entity validation that is causing the block, and not a lack of + // permissions. + public function testTagAssociationOutOfEntityScopeIsDenied(): void + { + $this->login(); + $this->setEntity('_test_root_entity', true); + + $tag = $this->createItem(PluginTagTag::class, [ + 'name' => 'OutOfScopeTag', + 'is_active' => 1, + 'type_menu' => ['Computer'], + 'entities_id' => getItemByTypeName('Entity', '_test_child_2', true), + 'is_recursive' => 0, + ], ['type_menu']); + $computer = $this->createItem(Computer::class, [ + 'name' => 'Computer out of tag scope', + 'entities_id' => getItemByTypeName('Entity', '_test_child_1', true), + ]); + + $controller = new TagItemController(); + $request = Request::create('/plugins/tag/associate', 'POST', [ + 'plugin_tag_tags_id' => $tag->getID(), + 'itemtype' => Computer::class, + 'items_id' => $computer->getID(), + ]); + + $this->expectException(AccessDeniedHttpException::class); + $controller->associate($request); + } }