Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 0 additions & 12 deletions .phpstan-baseline.php
Original file line number Diff line number Diff line change
Expand Up @@ -5317,12 +5317,6 @@
'count' => 1,
'path' => __DIR__ . '/src/NotificationEvent.php',
];
$ignoreErrors[] = [
'message' => '#^Parameter \\#2 \\$tid of method NotificationTemplate\\:\\:getDataToSend\\(\\) expects string, int\\<min, \\-1\\>\\|int\\<1, max\\> given\\.$#',
'identifier' => 'argument.type',
'count' => 1,
'path' => __DIR__ . '/src/NotificationEventAbstract.php',
];
$ignoreErrors[] = [
'message' => '#^Call to preg_quote\\(\\) is missing delimiter / to be effective\\.$#',
'identifier' => 'argument.invalidPregQuote',
Expand Down Expand Up @@ -5371,12 +5365,6 @@
'count' => 1,
'path' => __DIR__ . '/src/NotificationTargetSavedSearch_Alert.php',
];
$ignoreErrors[] = [
'message' => '#^Method NotificationTemplate\\:\\:getTemplateByLanguage\\(\\) should return int\\|false but returns non\\-falsy\\-string\\.$#',
'identifier' => 'return.type',
'count' => 1,
'path' => __DIR__ . '/src/NotificationTemplate.php',
];
$ignoreErrors[] = [
'message' => '#^Parameter \\#2 \\$items_id of static method CommonDBConnexity\\:\\:getItemsAssociatedTo\\(\\) expects string, int given\\.$#',
'identifier' => 'argument.type',
Expand Down
219 changes: 219 additions & 0 deletions phpunit/functional/NotificationTemplateUserSpecificTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
<?php

/**
* ---------------------------------------------------------------------
*
* GLPI - Gestionnaire Libre de Parc Informatique
*
* http://glpi-project.org
*
* @copyright 2015-2025 Teclib' and contributors.
* @licence https://www.gnu.org/licenses/gpl-3.0.html
*
* ---------------------------------------------------------------------
*
* LICENSE
*
* This file is part of GLPI.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* ---------------------------------------------------------------------
*/

namespace tests\units;

use DbTestCase;

/**
* Test for user-specific template generation fix.
* This validates the fix for the bug where users of the same type were sharing
* the same cached template, causing plugins to generate identical data (like tokens)
* for all users of the same type.
*
* @see https://github.com/glpi-project/glpi/pull/20186
*/
class NotificationTemplateUserSpecificTest extends DbTestCase
{
/**
* Test that each user gets a unique template cache key
* even when they have the same language and user type.
*/
public function testUniqueTemplateCacheKeyPerUser(): void
{
$this->login();

$template = new \NotificationTemplate();
$template->getFromDB(1); // Use an existing template

$user = new \User();
$target = new \NotificationTargetUser(0, 'passwordexpires', $user);

// Two different users with the same language and type
$user1_infos = [
'language' => 'en_GB',
'users_id' => 2, // TU_USER
'additionnaloption' => ['usertype' => \NotificationTarget::GLPI_USER],
];

$user2_infos = [
'language' => 'en_GB', // Same language as user1
'users_id' => 4, // tech user
'additionnaloption' => ['usertype' => \NotificationTarget::GLPI_USER], // Same type as user1
];

// Reset template cache to start fresh
$template->resetComputedTemplates();

// Get template for first user
$template_id1 = $template->getTemplateByLanguage($target, $user1_infos, 'passwordexpires', []);
$this->assertNotFalse($template_id1);

// Get template for second user (same language and type, but different users_id)
$template_id2 = $template->getTemplateByLanguage($target, $user2_infos, 'passwordexpires', []);
$this->assertNotFalse($template_id2);

// Verify that different template IDs are generated for different users
$this->assertNotEquals(
$template_id1,
$template_id2,
'Each user should get a unique template cache key to ensure user-specific data is generated'
);
}

/**
* Test that current_user_infos is properly set on NotificationTarget
* when getForTemplate is called.
*/
public function testCurrentUserInfosIsSetOnTarget(): void
{
$this->login();

$user = new \User();
$target = new \NotificationTargetUser(0, 'passwordexpires', $user);

$user_infos = [
'language' => 'en_GB',
'users_id' => 2,
'email' => 'test@example.com',
'additionnaloption' => ['usertype' => \NotificationTarget::GLPI_USER],
];

$options = [
'_user_infos' => $user_infos,
'additionnaloption' => $user_infos['additionnaloption'],
];

// Call getForTemplate with user_infos in options
$target->getForTemplate('passwordexpires', $options);

// Verify that current_user_infos is set
$this->assertNotEmpty($target->current_user_infos);
$this->assertEquals($user_infos['users_id'], $target->current_user_infos['users_id']);
$this->assertEquals($user_infos['email'], $target->current_user_infos['email']);
}

/**
* Test that different email users get unique template cache keys
*/
public function testUniqueTemplateCacheKeyPerEmailUser(): void
{
$this->login();

$template = new \NotificationTemplate();
$template->getFromDB(1);

$user = new \User();
$target = new \NotificationTargetUser(0, 'passwordexpires', $user);

$email_user1 = [
'language' => 'en_GB',
'email' => 'user1@example.com',
'additionnaloption' => ['usertype' => \NotificationTarget::EXTERNAL_USER],
];

$email_user2 = [
'language' => 'en_GB',
'email' => 'user2@example.com',
'additionnaloption' => ['usertype' => \NotificationTarget::EXTERNAL_USER],
];

$template->resetComputedTemplates();

$template_id1 = $template->getTemplateByLanguage($target, $email_user1, 'passwordexpires', []);
$template_id2 = $template->getTemplateByLanguage($target, $email_user2, 'passwordexpires', []);

$this->assertNotFalse($template_id1);
$this->assertNotFalse($template_id2);
$this->assertNotEquals(
$template_id1,
$template_id2,
'Email users should get unique templates based on their email address'
);
}

/**
* Test that mixed users (GLPI users and email users) all get unique templates
*/
public function testMixedUserTypesGetUniqueTemplates(): void
{
$this->login();

$template = new \NotificationTemplate();
$template->getFromDB(1);

$user = new \User();
$target = new \NotificationTargetUser(0, 'passwordexpires', $user);

$scenarios = [
'glpi_user_1' => [
'language' => 'en_GB',
'users_id' => 2,
'additionnaloption' => ['usertype' => \NotificationTarget::GLPI_USER],
],
'glpi_user_2' => [
'language' => 'en_GB',
'users_id' => 4,
'additionnaloption' => ['usertype' => \NotificationTarget::GLPI_USER],
],
'external_email_1' => [
'language' => 'en_GB',
'email' => 'external1@example.com',
'additionnaloption' => ['usertype' => \NotificationTarget::EXTERNAL_USER],
],
'external_email_2' => [
'language' => 'en_GB',
'email' => 'external2@example.com',
'additionnaloption' => ['usertype' => \NotificationTarget::EXTERNAL_USER],
],
];

$template->resetComputedTemplates();
$template_ids = [];

foreach ($scenarios as $name => $user_infos) {
$tid = $template->getTemplateByLanguage($target, $user_infos, 'passwordexpires', []);
$this->assertNotFalse($tid, "Template generation should succeed for scenario: $name");
$template_ids[$name] = $tid;
}

// All template IDs should be unique
$unique_ids = array_unique($template_ids);
$this->assertCount(
count($scenarios),
$unique_ids,
'All scenarios should generate unique template cache keys'
);
}
}
16 changes: 16 additions & 0 deletions src/NotificationTarget.php
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,17 @@ class NotificationTarget extends CommonDBChild
*/
public $recipient_data = [];

/**
* Current recipient user info for template generation.
* Contains the user information (users_id, email, language, etc.) for the recipient
* currently being processed. This allows plugins to generate user-specific data
* (like unique tokens) during the ITEM_GET_DATA hook.
*
* @var array
* @since 10.0.21
*/
public $current_user_infos = [];

private $allow_response = true;
private $mode = null;
private $event = null;
Expand Down Expand Up @@ -1392,6 +1403,11 @@ public function &getForTemplate($event, $options)
{
$this->data = [];

// Store current user info for plugins to access during ITEM_GET_DATA hook
if (isset($options['_user_infos'])) {
$this->current_user_infos = $options['_user_infos'];
}

$this->addDataForTemplate($event, $options);

// Add global tags data, use `+` operator to preserve overriden values
Expand Down
14 changes: 13 additions & 1 deletion src/NotificationTemplate.php
Original file line number Diff line number Diff line change
Expand Up @@ -238,7 +238,7 @@ public function getAdditionnalProcessOption($options)
* @param $event
* @param $options array
*
* @return false|integer id of the template in templates_by_languages / false if computation failed
* @return false|string id of the template in templates_by_languages / false if computation failed
**/
public function getTemplateByLanguage(
NotificationTarget $target,
Expand All @@ -256,8 +256,18 @@ public function getTemplateByLanguage(
$additionnaloption = [];
}

// Build a unique user identifier for cache key
// This ensures each user gets their own template instance with user-specific data
$user_identifier = '';
if (isset($user_infos['users_id']) && $user_infos['users_id'] > 0) {
$user_identifier = 'user_' . $user_infos['users_id'];
} elseif (isset($user_infos['email'])) {
$user_identifier = 'email_' . $user_infos['email'];
}

$tid = $language;
$tid .= serialize($additionnaloption);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you can simplify the solution and do it only in the plugin by passing the user ID in additionaloption?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I actually considered this approach, but unfortunately it won't work due to how the template caching mechanism works.

The issue is that additionnaloption is already included in the cache key (via serialize($additionnaloption)). So in theory, adding users_id to additionnaloption from the plugin should create unique cache keys per user.

However, the problem is when the plugin hook is called:

  1. NotificationEventAbstract::raise() loops over each user and calls getTemplateByLanguage()
  2. getTemplateByLanguage() computes the cache key using $user_infos['additionnaloption'] (which comes from the core, not the plugin)
  3. Only if the template is not cached, it calls getForTemplate() which triggers the ITEM_GET_DATA hook where plugins can add data
  4. The plugin's hook (plugin_approvalbymail_get_datas) is called after the cache key is computed and inside the cache miss block

So even if the plugin wanted to add users_id to additionnaloption, it's too late - the cache decision has already been made based on the original additionnaloption from the core.

The only way to solve this from the plugin side would be to somehow modify $user_infos['additionnaloption'] before getTemplateByLanguage() is called, but there's no hook available at that point.

That's why the fix needs to be in the core: either by including the user identifier in the cache key (as done here), or by providing a hook before the cache key computation.

$tid .= $user_identifier;

$tid = sha1($tid);

Expand All @@ -275,7 +285,9 @@ public function getTemplateByLanguage(
}

//Get template's language data for in this language
// Pass user_infos to allow plugins to identify the current recipient
$options['additionnaloption'] = $additionnaloption;
$options['_user_infos'] = $user_infos;
$data = &$target->getForTemplate($event, $options);

$footer_string = __('Automatically generated by GLPI');
Expand Down