Compare commits

...

9 Commits

Author SHA1 Message Date
Anna Larch
d1a0175eb3 fixup! fix(performance): move array_merge outside of loop 2026-03-25 22:49:38 +01:00
Anna Larch
928a61dec2 fixup! fix(performance): move array_merge outside of loop 2026-03-25 22:35:26 +01:00
Anna Larch
08f9e63eff fixup! fix(performance): move array_merge outside of loop 2026-03-25 22:30:41 +01:00
Anna Larch
ed28046fa5 fixup! fix(performance): move array_merge outside of loop 2026-03-25 22:29:42 +01:00
Anna Larch
06dd3ef32e fixup! fix(performance): move array_merge outside of loop 2026-03-25 22:26:19 +01:00
Anna Larch
3dde009c7b fixup! fix(performance): move array_merge outside of loop 2026-03-25 21:52:29 +01:00
Anna Larch
0d172165b4 fixup! fix(performance): move array_merge outside of loop 2026-03-25 21:52:29 +01:00
Anna Larch
a63fd32d74 fixup! fix(performance): move array_merge outside of loop 2026-03-25 21:52:29 +01:00
Anna Larch
84192ac433 fix(performance): move array_merge outside of loop
Signed-off-by: Anna Larch <anna@nextcloud.com>
2026-03-25 21:52:29 +01:00
11 changed files with 419 additions and 118 deletions

View File

@@ -17,6 +17,7 @@ return array(
'OCA\\Provisioning_API\\Controller\\UsersController' => $baseDir . '/../lib/Controller/UsersController.php',
'OCA\\Provisioning_API\\Controller\\VerificationController' => $baseDir . '/../lib/Controller/VerificationController.php',
'OCA\\Provisioning_API\\FederatedShareProviderFactory' => $baseDir . '/../lib/FederatedShareProviderFactory.php',
'OCA\\Provisioning_API\\Listener\\UserDataCacheListener' => $baseDir . '/../lib/Listener/UserDataCacheListener.php',
'OCA\\Provisioning_API\\Listener\\UserDeletedListener' => $baseDir . '/../lib/Listener/UserDeletedListener.php',
'OCA\\Provisioning_API\\Middleware\\Exceptions\\NotSubAdminException' => $baseDir . '/../lib/Middleware/Exceptions/NotSubAdminException.php',
'OCA\\Provisioning_API\\Middleware\\ProvisioningApiMiddleware' => $baseDir . '/../lib/Middleware/ProvisioningApiMiddleware.php',

View File

@@ -32,6 +32,7 @@ class ComposerStaticInitProvisioning_API
'OCA\\Provisioning_API\\Controller\\UsersController' => __DIR__ . '/..' . '/../lib/Controller/UsersController.php',
'OCA\\Provisioning_API\\Controller\\VerificationController' => __DIR__ . '/..' . '/../lib/Controller/VerificationController.php',
'OCA\\Provisioning_API\\FederatedShareProviderFactory' => __DIR__ . '/..' . '/../lib/FederatedShareProviderFactory.php',
'OCA\\Provisioning_API\\Listener\\UserDataCacheListener' => __DIR__ . '/..' . '/../lib/Listener/UserDataCacheListener.php',
'OCA\\Provisioning_API\\Listener\\UserDeletedListener' => __DIR__ . '/..' . '/../lib/Listener/UserDeletedListener.php',
'OCA\\Provisioning_API\\Middleware\\Exceptions\\NotSubAdminException' => __DIR__ . '/..' . '/../lib/Middleware/Exceptions/NotSubAdminException.php',
'OCA\\Provisioning_API\\Middleware\\ProvisioningApiMiddleware' => __DIR__ . '/..' . '/../lib/Middleware/ProvisioningApiMiddleware.php',

View File

@@ -8,6 +8,7 @@ namespace OCA\Provisioning_API\AppInfo;
use OC\Group\Manager as GroupManager;
use OCA\Provisioning_API\Capabilities;
use OCA\Provisioning_API\Listener\UserDataCacheListener;
use OCA\Provisioning_API\Listener\UserDeletedListener;
use OCA\Provisioning_API\Middleware\ProvisioningApiMiddleware;
use OCA\Settings\Mailer\NewUserMailHelper;
@@ -27,6 +28,8 @@ use OCP\L10N\IFactory;
use OCP\Mail\IMailer;
use OCP\Security\ICrypto;
use OCP\Security\ISecureRandom;
use OCP\User\Events\PasswordUpdatedEvent;
use OCP\User\Events\UserChangedEvent;
use OCP\User\Events\UserDeletedEvent;
use OCP\Util;
use Psr\Container\ContainerInterface;
@@ -37,7 +40,10 @@ class Application extends App implements IBootstrap {
}
public function register(IRegistrationContext $context): void {
$context->registerEventListener(UserChangedEvent::class, UserDataCacheListener::class);
$context->registerEventListener(UserDeletedEvent::class, UserDataCacheListener::class);
$context->registerEventListener(UserDeletedEvent::class, UserDeletedListener::class);
$context->registerEventListener(PasswordUpdatedEvent::class, UserDataCacheListener::class);
$context->registerService(NewUserMailHelper::class, function (ContainerInterface $c) {
return new NewUserMailHelper(

View File

@@ -22,6 +22,8 @@ use OCP\Files\FileInfo;
use OCP\Files\IRootFolder;
use OCP\Files\NotFoundException;
use OCP\Group\ISubAdmin;
use OCP\ICache;
use OCP\ICacheFactory;
use OCP\IConfig;
use OCP\IRequest;
use OCP\IUser;
@@ -50,6 +52,10 @@ abstract class AUserDataOCSController extends OCSController {
public const USER_FIELD_MANAGER = 'manager';
public const USER_FIELD_NOTIFICATION_EMAIL = 'notify_email';
private const CACHE_TTL = 300; // 5 minutes
private ICache $cache;
public function __construct(
string $appName,
IRequest $request,
@@ -61,8 +67,10 @@ abstract class AUserDataOCSController extends OCSController {
protected ISubAdmin $subAdminManager,
protected IFactory $l10nFactory,
protected IRootFolder $rootFolder,
ICacheFactory $cacheFactory,
) {
parent::__construct($appName, $request);
$this->cache = $cacheFactory->createDistributed('provisioning_api');
}
/**
@@ -79,8 +87,6 @@ abstract class AUserDataOCSController extends OCSController {
$currentLoggedInUser = $this->userSession->getUser();
assert($currentLoggedInUser !== null, 'No user logged in');
$data = [];
// Check if the target user exists
$targetUserObject = $this->userManager->get($userId);
if ($targetUserObject === null) {
@@ -89,6 +95,15 @@ abstract class AUserDataOCSController extends OCSController {
$isAdmin = $this->groupManager->isAdmin($currentLoggedInUser->getUID());
$isDelegatedAdmin = $this->groupManager->isDelegatedAdmin($currentLoggedInUser->getUID());
$cacheKey = 'user_data_' . $userId . '_' . ($isAdmin || $isDelegatedAdmin ? 'admin' : 'noadmin') . ($includeScopes ? '_scoped' : '');
/** @var Provisioning_APIUserDetails|null $cached */
$cached = $this->cache->get($cacheKey);
if ($cached !== null) {
return $cached;
}
$data = [];
if ($isAdmin
|| $isDelegatedAdmin
|| $this->groupManager->getSubAdmin()->isUserAccessible($currentLoggedInUser, $targetUserObject)) {
@@ -197,6 +212,7 @@ abstract class AUserDataOCSController extends OCSController {
'setPassword' => $backend instanceof ISetPasswordBackend || $backend->implementsActions(Backend::SET_PASSWORD),
];
$this->cache->set($cacheKey, $data, self::CACHE_TTL);
return $data;
}

View File

@@ -23,6 +23,7 @@ use OCP\AppFramework\OCS\OCSNotFoundException;
use OCP\AppFramework\OCSController;
use OCP\Files\IRootFolder;
use OCP\Group\ISubAdmin;
use OCP\ICacheFactory;
use OCP\IConfig;
use OCP\IGroup;
use OCP\IGroupManager;
@@ -51,6 +52,7 @@ class GroupsController extends AUserDataOCSController {
IFactory $l10nFactory,
IRootFolder $rootFolder,
private LoggerInterface $logger,
ICacheFactory $cacheFactory,
) {
parent::__construct($appName,
$request,
@@ -62,6 +64,7 @@ class GroupsController extends AUserDataOCSController {
$subAdminManager,
$l10nFactory,
$rootFolder,
$cacheFactory,
);
}

View File

@@ -36,6 +36,8 @@ use OCP\EventDispatcher\IEventDispatcher;
use OCP\Files\IRootFolder;
use OCP\Group\ISubAdmin;
use OCP\HintException;
use OCP\ICache;
use OCP\ICacheFactory;
use OCP\IConfig;
use OCP\IGroup;
use OCP\IGroupManager;
@@ -60,6 +62,9 @@ use Psr\Log\LoggerInterface;
class UsersController extends AUserDataOCSController {
private IL10N $l10n;
private ICache $cache;
private const CACHE_PREFIX = 'provisioning_usercache';
public function __construct(
string $appName,
@@ -81,6 +86,7 @@ class UsersController extends AUserDataOCSController {
private IEventDispatcher $eventDispatcher,
private IPhoneNumberUtil $phoneNumberUtil,
private IAppManager $appManager,
private ICacheFactory $cacheFactory,
) {
parent::__construct(
$appName,
@@ -93,9 +99,11 @@ class UsersController extends AUserDataOCSController {
$subAdminManager,
$l10nFactory,
$rootFolder,
$cacheFactory,
);
$this->l10n = $l10nFactory->get($appName);
$this->cache = $this->cacheFactory->createDistributed(self::CACHE_PREFIX);
}
/**
@@ -111,30 +119,46 @@ class UsersController extends AUserDataOCSController {
#[NoAdminRequired]
public function getUsers(string $search = '', ?int $limit = null, int $offset = 0): DataResponse {
$user = $this->userSession->getUser();
$users = [];
// Admin? Or SubAdmin?
$uid = $user->getUID();
$subAdminManager = $this->groupManager->getSubAdmin();
$users = $this->cache->get($uid . '_' . $search);
if (!empty($users)) {
return new DataResponse([
'users' => $users
]);
}
$isAdmin = $this->groupManager->isAdmin($uid);
$isDelegatedAdmin = $this->groupManager->isDelegatedAdmin($uid);
if ($isAdmin || $isDelegatedAdmin) {
$users = $this->userManager->search($search, $limit, $offset);
} elseif ($subAdminManager->isSubAdmin($user)) {
$subAdminOfGroups = $subAdminManager->getSubAdminsGroups($user);
foreach ($subAdminOfGroups as $key => $group) {
$subAdminOfGroups[$key] = $group->getGID();
}
$users = [];
foreach ($subAdminOfGroups as $group) {
$users = array_merge($users, $this->groupManager->displayNamesInGroup($group, $search, $limit, $offset));
}
$users = array_keys($users);
$this->cache->set($uid . '_' . $search, $users, 10);
return new DataResponse([
'users' => $users
]);
}
/** @var list<string> $users */
$users = array_keys($users);
$users = [];
$subAdminManager = $this->groupManager->getSubAdmin();
if (!$subAdminManager->isSubAdmin($user)) {
return new DataResponse([
'users' => $users
]);
}
$subAdminOfGroups = $subAdminManager->getSubAdminsGroups($user);
foreach ($subAdminOfGroups as $key => $group) {
$subAdminOfGroups[$key] = $group->getGID();
}
$users = [];
foreach ($subAdminOfGroups as $group) {
$users += $this->groupManager->displayNamesInGroup($group, $search, $limit, $offset);
}
$users = array_keys($users);
$this->cache->set($uid . '_' . $search, $users, 10);
return new DataResponse([
'users' => $users
]);
@@ -152,29 +176,7 @@ class UsersController extends AUserDataOCSController {
*/
#[NoAdminRequired]
public function getUsersDetails(string $search = '', ?int $limit = null, int $offset = 0): DataResponse {
$currentUser = $this->userSession->getUser();
$users = [];
// Admin? Or SubAdmin?
$uid = $currentUser->getUID();
$subAdminManager = $this->groupManager->getSubAdmin();
$isAdmin = $this->groupManager->isAdmin($uid);
$isDelegatedAdmin = $this->groupManager->isDelegatedAdmin($uid);
if ($isAdmin || $isDelegatedAdmin) {
$users = $this->userManager->search($search, $limit, $offset);
$users = array_keys($users);
} elseif ($subAdminManager->isSubAdmin($currentUser)) {
$subAdminOfGroups = $subAdminManager->getSubAdminsGroups($currentUser);
foreach ($subAdminOfGroups as $key => $group) {
$subAdminOfGroups[$key] = $group->getGID();
}
$users = [];
foreach ($subAdminOfGroups as $group) {
$users[] = array_keys($this->groupManager->displayNamesInGroup($group, $search, $limit, $offset));
}
$users = array_merge(...$users);
}
$users = $this->getUsers($search, $limit, $offset)->getData()['users'];
$usersDetails = [];
foreach ($users as $userId) {
@@ -187,14 +189,9 @@ class UsersController extends AUserDataOCSController {
$userData = null;
$this->logger->warning('Found one enabled account that is removed from its backend, but still exists in Nextcloud database', ['accountId' => $userId]);
}
// Do not insert empty entry
if ($userData !== null) {
$usersDetails[$userId] = $userData;
} else {
// Logged user does not have permissions to see this user
// only showing its id
$usersDetails[$userId] = ['id' => $userId];
}
// $userdata === null means logged user does not have permissions to see this user
$usersDetails[$userId] = $userData ?? ['id' => $userId];
}
return new DataResponse([
@@ -238,25 +235,21 @@ class UsersController extends AUserDataOCSController {
} elseif ($subAdminManager->isSubAdmin($currentUser)) {
$subAdminOfGroups = $subAdminManager->getSubAdminsGroups($currentUser);
$users = [];
$usersSet = [];
/* We have to handle offset ourselve for correctness */
$tempLimit = ($limit === null ? null : $limit + $offset);
foreach ($subAdminOfGroups as $group) {
$users = array_unique(array_merge(
$users,
array_map(
fn (IUser $user): string => $user->getUID(),
array_filter(
$group->searchUsers($search),
fn (IUser $user): bool => !$user->isEnabled()
)
)
));
if (($tempLimit !== null) && (count($users) >= $tempLimit)) {
foreach (array_filter(
$group->searchUsers($search),
fn (IUser $user): bool => !$user->isEnabled()
) as $user) {
$usersSet[$user->getUID()] = true;
}
if (($tempLimit !== null) && (count($usersSet) >= $tempLimit)) {
break;
}
}
$users = array_slice($users, $offset, $limit);
$users = array_slice(array_keys($usersSet), $offset, $limit);
}
$usersDetails = [];
@@ -820,6 +813,7 @@ class UsersController extends AUserDataOCSController {
throw new OCSException('', OCSController::RESPOND_NOT_FOUND);
}
$this->cache->clear(self::CACHE_PREFIX);
$subAdminManager = $this->groupManager->getSubAdmin();
$isDelegatedAdmin = $this->groupManager->isDelegatedAdmin($currentLoggedInUser->getUID());
$isAdminOrSubadmin = $this->groupManager->isAdmin($currentLoggedInUser->getUID())
@@ -920,6 +914,7 @@ class UsersController extends AUserDataOCSController {
throw new OCSException('', OCSController::RESPOND_NOT_FOUND);
}
$this->cache->clear(self::CACHE_PREFIX);
$permittedFields = [];
if ($targetUser->getUID() === $currentLoggedInUser->getUID()) {
if ($targetUser->canChangeDisplayName()) {
@@ -1295,6 +1290,7 @@ class UsersController extends AUserDataOCSController {
throw new OCSException('', OCSController::RESPOND_NOT_FOUND);
}
$this->cache->clear(self::CACHE_PREFIX);
// Go ahead with the delete
if ($targetUser->delete()) {
return new DataResponse();
@@ -1341,7 +1337,6 @@ class UsersController extends AUserDataOCSController {
*/
private function setEnabled(string $userId, bool $value): DataResponse {
$currentLoggedInUser = $this->userSession->getUser();
$targetUser = $this->userManager->get($userId);
if ($targetUser === null || $targetUser->getUID() === $currentLoggedInUser->getUID()) {
throw new OCSException('', 101);
@@ -1355,6 +1350,7 @@ class UsersController extends AUserDataOCSController {
throw new OCSException('', OCSController::RESPOND_NOT_FOUND);
}
$this->cache->clear(self::CACHE_PREFIX);
// enable/disable the user now
$targetUser->setEnabled($value);
return new DataResponse();
@@ -1387,22 +1383,21 @@ class UsersController extends AUserDataOCSController {
return new DataResponse([
'groups' => $this->groupManager->getUserGroupIds($targetUser)
]);
} else {
$subAdminManager = $this->groupManager->getSubAdmin();
// Looking up someone else
if ($subAdminManager->isUserAccessible($loggedInUser, $targetUser)) {
// Return the group that the method caller is subadmin of for the user in question
$groups = array_values(array_intersect(
array_map(static fn (IGroup $group) => $group->getGID(), $subAdminManager->getSubAdminsGroups($loggedInUser)),
$this->groupManager->getUserGroupIds($targetUser)
));
return new DataResponse(['groups' => $groups]);
} else {
// Not permitted
throw new OCSException('', OCSController::RESPOND_NOT_FOUND);
}
}
$subAdminManager = $this->groupManager->getSubAdmin();
// Looking up someone else
if (!$subAdminManager->isUserAccessible($loggedInUser, $targetUser)) {
throw new OCSException('', OCSController::RESPOND_NOT_FOUND);
}
// Return the group that the method caller is subadmin of for the user in question
$groups = array_values(array_intersect(
array_map(static fn (IGroup $group) => $group->getGID(), $subAdminManager->getSubAdminsGroups($loggedInUser)),
$this->groupManager->getUserGroupIds($targetUser)
));
return new DataResponse(['groups' => $groups]);
}
/**
@@ -1445,41 +1440,41 @@ class UsersController extends AUserDataOCSController {
return new DataResponse([
'groups' => $groups,
]);
} else {
$subAdminManager = $this->groupManager->getSubAdmin();
// Looking up someone else
if ($subAdminManager->isUserAccessible($loggedInUser, $targetUser)) {
// Return the group that the method caller is subadmin of for the user in question
$gids = array_values(array_intersect(
array_map(
static fn (IGroup $group) => $group->getGID(),
$subAdminManager->getSubAdminsGroups($loggedInUser),
),
$this->groupManager->getUserGroupIds($targetUser)
));
$groups = array_map(
function (string $gid) {
$group = $this->groupManager->get($gid);
return [
'id' => $group->getGID(),
'displayname' => $group->getDisplayName(),
'usercount' => $group->count(),
'disabled' => $group->countDisabled(),
'canAdd' => $group->canAddUser(),
'canRemove' => $group->canRemoveUser(),
];
},
$gids,
);
return new DataResponse([
'groups' => $groups,
]);
} else {
// Not permitted
throw new OCSException('', OCSController::RESPOND_NOT_FOUND);
}
}
$subAdminManager = $this->groupManager->getSubAdmin();
// Looking up someone else
if (!$subAdminManager->isUserAccessible($loggedInUser, $targetUser)) {
// Not permitted
throw new OCSException('', OCSController::RESPOND_NOT_FOUND);
}
// Return the group that the method caller is subadmin of for the user in question
$gids = array_values(array_intersect(
array_map(
static fn (IGroup $group) => $group->getGID(),
$subAdminManager->getSubAdminsGroups($loggedInUser),
),
$this->groupManager->getUserGroupIds($targetUser)
));
$groups = array_map(
function (string $gid) {
$group = $this->groupManager->get($gid);
return [
'id' => $group->getGID(),
'displayname' => $group->getDisplayName(),
'usercount' => $group->count(),
'disabled' => $group->countDisabled(),
'canAdd' => $group->canAddUser(),
'canRemove' => $group->canRemoveUser(),
];
},
$gids,
);
return new DataResponse([
'groups' => $groups,
]);
}
/**
@@ -1581,7 +1576,7 @@ class UsersController extends AUserDataOCSController {
public function removeFromGroup(string $userId, string $groupid): DataResponse {
$loggedInUser = $this->userSession->getUser();
if ($groupid === null || trim($groupid) === '') {
if (trim($groupid) === '') {
throw new OCSException('', 101);
}

View File

@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Provisioning_API\Listener;
use OCP\EventDispatcher\Event;
use OCP\EventDispatcher\IEventListener;
use OCP\ICache;
use OCP\ICacheFactory;
use OCP\User\Events\PasswordUpdatedEvent;
use OCP\User\Events\UserChangedEvent;
use OCP\User\Events\UserDeletedEvent;
/**
* Busts the provisioning API user data cache whenever a user is modified or deleted.
*
* @template-implements IEventListener<UserChangedEvent|UserDeletedEvent|PasswordUpdatedEvent>
*/
class UserDataCacheListener implements IEventListener {
private ICache $cache;
public function __construct(ICacheFactory $cacheFactory) {
$this->cache = $cacheFactory->createDistributed('provisioning_api');
}
public function handle(Event $event): void {
if ($event instanceof UserChangedEvent) {
$uid = $event->getUser()->getUID();
} elseif ($event instanceof UserDeletedEvent) {
$uid = $event->getUser()->getUID();
} elseif ($event instanceof PasswordUpdatedEvent) {
$uid = $event->getUser()->getUID();
} else {
return;
}
// Clear all cached variants for this user (admin, noadmin, scoped, etc.)
$this->cache->clear('user_data_' . $uid);
}
}

View File

@@ -14,6 +14,8 @@ use OCP\Accounts\IAccountManager;
use OCP\AppFramework\OCS\OCSException;
use OCP\Files\IRootFolder;
use OCP\Group\ISubAdmin;
use OCP\ICache;
use OCP\ICacheFactory;
use OCP\IConfig;
use OCP\IGroup;
use OCP\IRequest;
@@ -38,6 +40,8 @@ class GroupsControllerTest extends \Test\TestCase {
protected GroupsController&MockObject $api;
private IRootFolder $rootFolder;
private ICache&MockObject $cache;
private ICacheFactory&MockObject $cacheFactory;
protected function setUp(): void {
@@ -53,6 +57,9 @@ class GroupsControllerTest extends \Test\TestCase {
$this->l10nFactory = $this->createMock(IFactory::class);
$this->logger = $this->createMock(LoggerInterface::class);
$this->rootFolder = $this->createMock(IRootFolder::class);
$this->cache = $this->createMock(ICache::class);
$this->cacheFactory = $this->createMock(ICacheFactory::class);
$this->cacheFactory->method('createDistributed')->willReturn($this->cache);
$this->groupManager
->method('getSubAdmin')
@@ -70,7 +77,8 @@ class GroupsControllerTest extends \Test\TestCase {
$this->subAdminManager,
$this->l10nFactory,
$this->rootFolder,
$this->logger
$this->logger,
$this->cacheFactory,
])
->onlyMethods(['fillStorageInfo'])
->getMock();

View File

@@ -26,6 +26,8 @@ use OCP\AppFramework\OCS\OCSException;
use OCP\EventDispatcher\IEventDispatcher;
use OCP\Files\IRootFolder;
use OCP\Group\ISubAdmin;
use OCP\ICache;
use OCP\ICacheFactory;
use OCP\IConfig;
use OCP\IGroup;
use OCP\IL10N;
@@ -65,6 +67,8 @@ class UsersControllerTest extends TestCase {
private IRootFolder $rootFolder;
private IPhoneNumberUtil $phoneNumberUtil;
private IAppManager $appManager;
private ICache&MockObject $cache;
private ICacheFactory&MockObject $cacheFactory;
protected function setUp(): void {
parent::setUp();
@@ -87,6 +91,9 @@ class UsersControllerTest extends TestCase {
$this->phoneNumberUtil = new PhoneNumberUtil();
$this->appManager = $this->createMock(IAppManager::class);
$this->rootFolder = $this->createMock(IRootFolder::class);
$this->cache = $this->createMock(ICache::class);
$this->cacheFactory = $this->createMock(ICacheFactory::class);
$this->cacheFactory->method('createDistributed')->willReturn($this->cache);
$l10n = $this->createMock(IL10N::class);
$l10n->method('t')->willReturnCallback(fn (string $txt, array $replacement = []) => sprintf($txt, ...$replacement));
@@ -113,6 +120,7 @@ class UsersControllerTest extends TestCase {
$this->eventDispatcher,
$this->phoneNumberUtil,
$this->appManager,
$this->cacheFactory,
])
->onlyMethods(['fillStorageInfo'])
->getMock();
@@ -502,6 +510,7 @@ class UsersControllerTest extends TestCase {
$this->eventDispatcher,
$this->phoneNumberUtil,
$this->appManager,
$this->cacheFactory,
])
->onlyMethods(['editUser'])
->getMock();
@@ -3842,6 +3851,7 @@ class UsersControllerTest extends TestCase {
$this->eventDispatcher,
$this->phoneNumberUtil,
$this->appManager,
$this->cacheFactory,
])
->onlyMethods(['getUserData'])
->getMock();
@@ -3936,6 +3946,7 @@ class UsersControllerTest extends TestCase {
$this->eventDispatcher,
$this->phoneNumberUtil,
$this->appManager,
$this->cacheFactory,
])
->onlyMethods(['getUserData'])
->getMock();
@@ -4383,6 +4394,137 @@ class UsersControllerTest extends TestCase {
$this->assertEquals($expectedResp, $this->api->getEditableFields('userId'));
}
public function testGetUserDataReturnsCachedDataOnCacheHit(): void {
$loggedInUser = $this->createMock(IUser::class);
$loggedInUser->method('getUID')->willReturn('admin');
$this->userSession->method('getUser')->willReturn($loggedInUser);
$this->groupManager->method('isAdmin')->with('admin')->willReturn(true);
$this->groupManager->method('isDelegatedAdmin')->with('admin')->willReturn(false);
$cachedData = ['id' => 'UID', 'displayname' => 'Test User', 'email' => 'test@example.com'];
$this->cache
->method('get')
->with('user_data_UID_admin')
->willReturn($cachedData);
// With a cache hit the database must not be consulted at all
$this->userManager->expects($this->never())->method('get');
$this->accountManager->expects($this->never())->method('getAccount');
$result = $this->api->getUser('UID');
$this->assertSame($cachedData, $result->getData());
}
public function testGetUserDataPopulatesCacheOnMiss(): void {
$loggedInUser = $this->createMock(IUser::class);
$loggedInUser->method('getUID')->willReturn('admin');
$this->userSession->method('getUser')->willReturn($loggedInUser);
$this->groupManager->method('isAdmin')->with('admin')->willReturn(true);
$this->groupManager->method('isDelegatedAdmin')->with('admin')->willReturn(false);
// Cache miss
$this->cache->method('get')->willReturn(null);
$targetUser = $this->createMock(IUser::class);
$targetUser->method('getUID')->willReturn('UID');
$targetUser->method('getSystemEMailAddress')->willReturn('test@example.com');
$targetUser->method('getPrimaryEMailAddress')->willReturn('test@example.com');
$targetUser->method('getDisplayName')->willReturn('Test User');
$targetUser->method('getLastLogin')->willReturn(0);
$targetUser->method('getFirstLogin')->willReturn(0);
$targetUser->method('getBackendClassName')->willReturn('Database');
$targetUser->method('getBackend')->willReturn($this->createMock(\OCP\UserInterface::class));
$targetUser->method('getHome')->willReturn('/home/UID');
$targetUser->method('getManagerUids')->willReturn([]);
$targetUser->method('getQuota')->willReturn('none');
$this->userManager->method('get')->with('UID')->willReturn($targetUser);
$subAdminManager = $this->createMock(\OC\SubAdmin::class);
$subAdminManager->method('getSubAdminsGroups')->willReturn([]);
$this->groupManager->method('getSubAdmin')->willReturn($subAdminManager);
$this->groupManager->method('getUserGroups')->willReturn([]);
$this->mockAccount($targetUser, [
IAccountManager::PROPERTY_ADDRESS => ['value' => ''],
IAccountManager::PROPERTY_PHONE => ['value' => ''],
IAccountManager::PROPERTY_TWITTER => ['value' => ''],
IAccountManager::PROPERTY_BLUESKY => ['value' => ''],
IAccountManager::PROPERTY_FEDIVERSE => ['value' => ''],
IAccountManager::PROPERTY_WEBSITE => ['value' => ''],
IAccountManager::PROPERTY_ORGANISATION => ['value' => ''],
IAccountManager::PROPERTY_ROLE => ['value' => ''],
IAccountManager::PROPERTY_HEADLINE => ['value' => ''],
IAccountManager::PROPERTY_BIOGRAPHY => ['value' => ''],
IAccountManager::PROPERTY_PROFILE_ENABLED => ['value' => '0'],
IAccountManager::PROPERTY_PRONOUNS => ['value' => ''],
]);
$emailCollection = $this->createMock(\OCP\Accounts\IAccountPropertyCollection::class);
$emailCollection->method('getProperties')->willReturn([]);
$account = $this->accountManager->getAccount($targetUser);
$this->accountManager->method('getAccount')->with($targetUser)->willReturnCallback(function () use ($targetUser, $emailCollection) {
$account = $this->createMock(\OCP\Accounts\IAccount::class);
$account->method('getPropertyCollection')->willReturn($emailCollection);
$account->method('getProperty')->willReturnCallback(function (string $name) {
$prop = $this->createMock(IAccountProperty::class);
$prop->method('getValue')->willReturn('');
$prop->method('getScope')->willReturn('');
return $prop;
});
return $account;
});
$this->config->method('getUserValue')->willReturn('true');
$this->l10nFactory->method('getUserLanguage')->willReturn('en');
$this->api->method('fillStorageInfo')->willReturn(['used' => 0, 'quota' => -3, 'free' => -3, 'total' => -3, 'relative' => 0]);
// The computed data must be stored in the cache with the 5-minute TTL
$this->cache
->expects($this->once())
->method('set')
->with('user_data_UID_admin', $this->isArray(), 300);
$this->api->getUser('UID');
}
public function testGetUserDataUsesAdminCacheKey(): void {
$loggedInUser = $this->createMock(IUser::class);
$loggedInUser->method('getUID')->willReturn('admin');
$this->userSession->method('getUser')->willReturn($loggedInUser);
$this->groupManager->method('isAdmin')->with('admin')->willReturn(true);
$this->groupManager->method('isDelegatedAdmin')->with('admin')->willReturn(false);
$this->cache
->expects($this->once())
->method('get')
->with($this->stringContains('_admin'))
->willReturn(['id' => 'UID']);
$this->api->getUser('UID');
}
public function testGetUserDataUsesNonAdminCacheKey(): void {
$loggedInUser = $this->createMock(IUser::class);
$loggedInUser->method('getUID')->willReturn('subadmin');
$this->userSession->method('getUser')->willReturn($loggedInUser);
$this->groupManager->method('isAdmin')->with('subadmin')->willReturn(false);
$this->groupManager->method('isDelegatedAdmin')->with('subadmin')->willReturn(false);
$targetUser = $this->createMock(IUser::class);
$targetUser->method('getUID')->willReturn('UID');
$this->userManager->method('get')->with('UID')->willReturn($targetUser);
$subAdminManager = $this->createMock(\OC\SubAdmin::class);
$subAdminManager->method('isUserAccessible')->with($loggedInUser, $targetUser)->willReturn(true);
$this->groupManager->method('getSubAdmin')->willReturn($subAdminManager);
$this->cache
->expects($this->once())
->method('get')
->with($this->stringContains('_noadmin'))
->willReturn(['id' => 'UID']);
$this->api->getUser('UID');
}
private function mockAccount($targetUser, $accountProperties) {
$mockedProperties = [];

View File

@@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Provisioning_API\Tests\Listener;
use OCA\Provisioning_API\Listener\UserDataCacheListener;
use OCP\EventDispatcher\Event;
use OCP\ICache;
use OCP\ICacheFactory;
use OCP\IUser;
use OCP\User\Events\PasswordUpdatedEvent;
use OCP\User\Events\UserChangedEvent;
use OCP\User\Events\UserDeletedEvent;
use PHPUnit\Framework\MockObject\MockObject;
use Test\TestCase;
class UserDataCacheListenerTest extends TestCase {
private ICache&MockObject $cache;
private UserDataCacheListener $listener;
protected function setUp(): void {
parent::setUp();
$this->cache = $this->createMock(ICache::class);
$cacheFactory = $this->createMock(ICacheFactory::class);
$cacheFactory->method('createDistributed')
->with('provisioning_api')
->willReturn($this->cache);
$this->listener = new UserDataCacheListener($cacheFactory);
}
private function makeUser(string $uid): IUser&MockObject {
$user = $this->createMock(IUser::class);
$user->method('getUID')->willReturn($uid);
return $user;
}
public function testHandleUserChangedEventClearsCache(): void {
$user = $this->makeUser('alice');
$event = new UserChangedEvent($user, 'displayName', 'New Name', 'Old Name');
$this->cache
->expects($this->once())
->method('clear')
->with('user_data_alice');
$this->listener->handle($event);
}
public function testHandleUserDeletedEventClearsCache(): void {
$user = $this->makeUser('bob');
$event = new UserDeletedEvent($user);
$this->cache
->expects($this->once())
->method('clear')
->with('user_data_bob');
$this->listener->handle($event);
}
public function testHandlePasswordUpdatedEventClearsCache(): void {
$user = $this->makeUser('carol');
$event = new PasswordUpdatedEvent($user, 'newpassword');
$this->cache
->expects($this->once())
->method('clear')
->with('user_data_carol');
$this->listener->handle($event);
}
public function testHandleUnrelatedEventDoesNothing(): void {
$this->cache->expects($this->never())->method('clear');
$this->cache->expects($this->never())->method('remove');
$this->listener->handle(new Event());
}
}

View File

@@ -2074,10 +2074,6 @@
<code><![CDATA[setUserValue]]></code>
<code><![CDATA[setUserValue]]></code>
</DeprecatedMethod>
<TypeDoesNotContainNull>
<code><![CDATA[$groupid === null]]></code>
<code><![CDATA[$groupid === null]]></code>
</TypeDoesNotContainNull>
</file>
<file src="apps/provisioning_api/lib/FederatedShareProviderFactory.php">
<DeprecatedInterface>