diff --git a/apps/provisioning_api/lib/Controller/UsersController.php b/apps/provisioning_api/lib/Controller/UsersController.php
index 3697d6b36a2aa..88cef9a7baf17 100644
--- a/apps/provisioning_api/lib/Controller/UsersController.php
+++ b/apps/provisioning_api/lib/Controller/UsersController.php
@@ -32,6 +32,7 @@
use OCP\AppFramework\OCS\OCSForbiddenException;
use OCP\AppFramework\OCS\OCSNotFoundException;
use OCP\AppFramework\OCSController;
+use OCP\Config\IUserConfig;
use OCP\EventDispatcher\IEventDispatcher;
use OCP\Files\IRootFolder;
use OCP\Group\ISubAdmin;
@@ -48,6 +49,7 @@
use OCP\IUserManager;
use OCP\IUserSession;
use OCP\L10N\IFactory;
+use OCP\Mail\IEmailValidator;
use OCP\Security\Events\GenerateSecurePasswordEvent;
use OCP\Security\ISecureRandom;
use OCP\User\Backend\ISetDisplayNameBackend;
@@ -60,6 +62,15 @@
*/
class UsersController extends AUserDataOCSController {
+ /**
+ * Virtual permitted-field keys for editUserMultiField's groups /
+ * subadminGroups parameters. These have no editUser equivalent — editUser
+ * is single-field and group membership lives on dedicated endpoints — but
+ * they participate in the shared getPermittedFields gate.
+ */
+ private const FIELD_GROUPS = 'groups';
+ private const FIELD_SUBADMIN_GROUPS = 'subadminGroups';
+
private IL10N $l10n;
public function __construct(
@@ -83,6 +94,8 @@ public function __construct(
private IPhoneNumberUtil $phoneNumberUtil,
private IAppManager $appManager,
private IAppConfig $appConfig,
+ private IUserConfig $userConfig,
+ private IEmailValidator $emailValidator,
) {
parent::__construct(
$appName,
@@ -944,16 +957,14 @@ public function editUserMultiField(
}
$isSelf = $targetUser->getUID() === $currentLoggedInUser->getUID();
- $isAdmin = $this->groupManager->isAdmin($currentLoggedInUser->getUID());
+ $isAdmin = (bool)$this->groupManager->isAdmin($currentLoggedInUser->getUID());
$isDelegatedAdmin = $this->groupManager->isDelegatedAdmin($currentLoggedInUser->getUID());
- $subAdminManager = $this->groupManager->getSubAdmin();
- $isSubAdminAccessible = !$isSelf && $subAdminManager->isUserAccessible($currentLoggedInUser, $targetUser);
+ $isSubAdminAccessible = !$isSelf
+ && $this->groupManager->getSubAdmin()->isUserAccessible($currentLoggedInUser, $targetUser);
- $canEditOther = $isAdmin
- || ($isDelegatedAdmin && !$this->groupManager->isAdmin($targetUser->getUID()))
- || $isSubAdminAccessible;
+ $permittedFields = $this->getPermittedFields($targetUser, $currentLoggedInUser, $isAdmin, $isDelegatedAdmin, $isSubAdminAccessible);
- if (!$isSelf && !$canEditOther) {
+ if (!$isSelf && empty($permittedFields)) {
// OCSForbiddenException used here (rather than the older OCSException pattern in editUser)
// because it is semantically correct: the caller is authenticated but lacks permission.
throw new OCSForbiddenException('Insufficient permissions to edit this user');
@@ -962,40 +973,40 @@ public function editUserMultiField(
// Validate all submitted fields — collect errors before applying anything
$errors = [];
- if ($displayName !== null) {
- $backend = $targetUser->getBackend();
- if (!$isSelf) {
- $canSetDisplayName = $backend instanceof ISetDisplayNameBackend
- || ($backend !== null && $backend->implementsActions(Backend::SET_DISPLAYNAME));
- } else {
- $canSetDisplayName = $targetUser->canChangeDisplayName();
- }
- if (!$canSetDisplayName) {
- $errors['displayName'] = $this->l10n->t('Cannot change display name for this user');
- }
+ if ($displayName !== null && !in_array(self::USER_FIELD_DISPLAYNAME, $permittedFields)) {
+ $errors['displayName'] = $this->l10n->t('Cannot change display name for this user');
}
if ($password !== null) {
- if (($error = $this->validatePasswordChange($targetUser, $password)) !== null) {
+ if (!in_array(self::USER_FIELD_PASSWORD, $permittedFields)) {
+ $errors['password'] = $this->l10n->t('Insufficient permissions to change password');
+ } elseif (($error = $this->validatePasswordChange($targetUser, $password)) !== null) {
$errors['password'] = $error[0];
}
}
- if ($email !== null && $email !== '' && !filter_var($email, FILTER_VALIDATE_EMAIL)) {
- $errors['email'] = $this->l10n->t('Invalid email address');
+ if ($email !== null) {
+ if (!in_array(IAccountManager::PROPERTY_EMAIL, $permittedFields)) {
+ $errors['email'] = $this->l10n->t('Insufficient permissions to change email address');
+ } else {
+ try {
+ $email = $this->validateAndNormalizeEmail($email, allowEmpty: true);
+ } catch (\InvalidArgumentException $e) {
+ $errors['email'] = $e->getMessage();
+ }
+ }
}
if ($language !== null) {
- $forceLanguage = $this->config->getSystemValue('force_language', false);
- if ($forceLanguage !== false && !$isAdmin && !$isDelegatedAdmin) {
+ if (!in_array(self::USER_FIELD_LANGUAGE, $permittedFields)) {
$errors['language'] = $this->l10n->t('Language change is not allowed on this instance');
- } elseif (!$this->l10nFactory->languageExists(null, $language)) {
- $errors['language'] = $this->l10n->t('Invalid language');
+ } elseif (($error = $this->validateLanguagePolicy($language, $isAdmin, $isDelegatedAdmin)) !== null) {
+ $errors['language'] = $error;
}
}
if ($quota !== null) {
- if (!$canEditOther) {
+ if (!in_array(self::USER_FIELD_QUOTA, $permittedFields)) {
$errors['quota'] = $this->l10n->t('Insufficient permissions to change quota');
} else {
try {
@@ -1007,7 +1018,7 @@ public function editUserMultiField(
}
if ($groups !== null) {
- if (!$isAdmin && !$isDelegatedAdmin) {
+ if (!in_array(self::FIELD_GROUPS, $permittedFields)) {
$errors['groups'] = $this->l10n->t('Insufficient permissions to change groups');
} else {
foreach ($groups as $gid) {
@@ -1020,7 +1031,7 @@ public function editUserMultiField(
}
if ($subadminGroups !== null) {
- if (!$isAdmin && !$isDelegatedAdmin) {
+ if (!in_array(self::FIELD_SUBADMIN_GROUPS, $permittedFields)) {
$errors['subadminGroups'] = $this->l10n->t('Insufficient permissions to change sub-admin groups');
} else {
foreach ($subadminGroups as $gid) {
@@ -1032,7 +1043,7 @@ public function editUserMultiField(
}
}
- if ($manager !== null && !$canEditOther) {
+ if ($manager !== null && !in_array(self::USER_FIELD_MANAGER, $permittedFields)) {
$errors['manager'] = $this->l10n->t('Insufficient permissions to change manager');
}
@@ -1053,13 +1064,11 @@ public function editUserMultiField(
// Apply remaining changes — all fully validated, setters won't throw
if ($displayName !== null) {
- // OC\User\User::setDisplayName() rejects empty strings (!empty check),
- // so "clear display name" means "reset to userId" — the default.
- $targetUser->setDisplayName($displayName !== '' ? $displayName : $userId);
+ $this->applyDisplayName($targetUser, $displayName, $userId, allowEmptyAsReset: true);
}
if ($email !== null) {
- $targetUser->setSystemEMailAddress(mb_strtolower(trim($email)));
+ $targetUser->setSystemEMailAddress($email);
}
if ($quota !== null) {
@@ -1067,7 +1076,7 @@ public function editUserMultiField(
}
if ($language !== null) {
- $this->config->setUserValue($targetUser->getUID(), 'core', 'lang', $language);
+ $this->applyLanguage($targetUser, $language);
}
if ($manager !== null) {
@@ -1075,44 +1084,252 @@ public function editUserMultiField(
}
if ($groups !== null) {
- $currentGroups = $this->groupManager->getUserGroups($targetUser);
- $currentGroupIds = array_map(fn (IGroup $g) => $g->getGID(), $currentGroups);
- foreach (array_diff($currentGroupIds, $groups) as $gid) {
- $this->groupManager->get($gid)?->removeUser($targetUser);
- }
- foreach (array_diff($groups, $currentGroupIds) as $gid) {
- // Only full admins can add users to the admin group
- if (!$isAdmin && $gid === 'admin') {
- continue;
- }
- $this->groupManager->get($gid)?->addUser($targetUser);
- }
+ $this->applyGroupMembership($targetUser, $groups, callerIsFullAdmin: $isAdmin);
}
if ($subadminGroups !== null) {
- $currentSubAdminGroups = $subAdminManager->getSubAdminsGroups($targetUser);
- $currentSubAdminGroupIds = array_map(fn (IGroup $g) => $g->getGID(), $currentSubAdminGroups);
- foreach (array_diff($currentSubAdminGroupIds, $subadminGroups) as $gid) {
- $group = $this->groupManager->get($gid);
- if ($group !== null) {
- $subAdminManager->deleteSubAdmin($targetUser, $group);
- }
+ $this->applySubAdminMembership($targetUser, $subadminGroups);
+ }
+
+ /** @var Provisioning_APIUserDetails $data */
+ $data = $this->getUserData($userId);
+ return new DataResponse($data);
+ }
+
+ /**
+ * Single source of truth for the editUser permittedFields allow-list and
+ * the editUserMultiField per-field permission gates.
+ *
+ * @return string[] Field keys; empty when a non-self caller has no rights.
+ */
+ private function getPermittedFields(
+ IUser $targetUser,
+ IUser $caller,
+ bool $isAdmin,
+ bool $isDelegatedAdmin,
+ bool $isSubAdminAccessible,
+ ): array {
+ $isSelf = $targetUser->getUID() === $caller->getUID();
+ $permittedFields = [];
+
+ if ($isSelf) {
+ if ($targetUser->canChangeDisplayName()) {
+ $permittedFields[] = self::USER_FIELD_DISPLAYNAME;
}
- foreach (array_diff($subadminGroups, $currentSubAdminGroupIds) as $gid) {
- // Cannot create sub-admins for the admin group
- if ($gid === 'admin') {
+
+ $permittedFields[] = IAccountManager::COLLECTION_EMAIL;
+ $permittedFields[] = self::USER_FIELD_PASSWORD;
+ $permittedFields[] = self::USER_FIELD_NOTIFICATION_EMAIL;
+ $permittedFields[] = self::USER_FIELD_TIMEZONE;
+
+ if (
+ $this->config->getSystemValue('force_language', false) === false
+ || $isAdmin
+ || $isDelegatedAdmin
+ ) {
+ $permittedFields[] = self::USER_FIELD_LANGUAGE;
+ }
+
+ if (
+ $this->config->getSystemValue('force_locale', false) === false
+ || $isAdmin
+ || $isDelegatedAdmin
+ ) {
+ $permittedFields[] = self::USER_FIELD_LOCALE;
+ $permittedFields[] = self::USER_FIELD_FIRST_DAY_OF_WEEK;
+ }
+
+ foreach (IAccountManager::ALLOWED_PROPERTIES as $property) {
+ $permittedFields[] = $property . self::SCOPE_SUFFIX;
+ if ($property === IAccountManager::PROPERTY_AVATAR) {
continue;
}
- $group = $this->groupManager->get($gid);
- if ($group !== null && !$subAdminManager->isSubAdminOfGroup($targetUser, $group)) {
- $subAdminManager->createSubAdmin($targetUser, $group);
+ if (!$targetUser->canEditProperty($property)) {
+ continue;
}
+ $permittedFields[] = $property;
}
+
+ // If admin they can edit their own quota and manager
+ if ($isAdmin || $isDelegatedAdmin) {
+ $permittedFields[] = self::USER_FIELD_QUOTA;
+ $permittedFields[] = self::USER_FIELD_MANAGER;
+ $permittedFields[] = self::FIELD_GROUPS;
+ $permittedFields[] = self::FIELD_SUBADMIN_GROUPS;
+ }
+
+ return $permittedFields;
}
- /** @var Provisioning_APIUserDetails $data */
- $data = $this->getUserData($userId);
- return new DataResponse($data);
+ // Check if admin / subadmin
+ $canEditOther = $isAdmin
+ || ($isDelegatedAdmin && !$this->groupManager->isAdmin($targetUser->getUID()))
+ || $isSubAdminAccessible;
+
+ if (!$canEditOther) {
+ // No rights
+ return [];
+ }
+
+ // They have permissions over the user
+ if ($this->validateDisplayNameChange($targetUser, isSelf: false) === null) {
+ $permittedFields[] = self::USER_FIELD_DISPLAYNAME;
+ $permittedFields[] = IAccountManager::PROPERTY_DISPLAYNAME;
+ }
+ $permittedFields[] = IAccountManager::PROPERTY_EMAIL;
+ $permittedFields[] = IAccountManager::COLLECTION_EMAIL;
+ $permittedFields[] = self::USER_FIELD_PASSWORD;
+ $permittedFields[] = self::USER_FIELD_LANGUAGE;
+ $permittedFields[] = self::USER_FIELD_LOCALE;
+ $permittedFields[] = self::USER_FIELD_TIMEZONE;
+ $permittedFields[] = self::USER_FIELD_FIRST_DAY_OF_WEEK;
+ $permittedFields[] = IAccountManager::PROPERTY_PHONE;
+ $permittedFields[] = IAccountManager::PROPERTY_ADDRESS;
+ $permittedFields[] = IAccountManager::PROPERTY_WEBSITE;
+ $permittedFields[] = IAccountManager::PROPERTY_TWITTER;
+ $permittedFields[] = IAccountManager::PROPERTY_BLUESKY;
+ $permittedFields[] = IAccountManager::PROPERTY_FEDIVERSE;
+ $permittedFields[] = IAccountManager::PROPERTY_ORGANISATION;
+ $permittedFields[] = IAccountManager::PROPERTY_ROLE;
+ $permittedFields[] = IAccountManager::PROPERTY_HEADLINE;
+ $permittedFields[] = IAccountManager::PROPERTY_BIOGRAPHY;
+ $permittedFields[] = IAccountManager::PROPERTY_PROFILE_ENABLED;
+ $permittedFields[] = IAccountManager::PROPERTY_PRONOUNS;
+ $permittedFields[] = self::USER_FIELD_QUOTA;
+ $permittedFields[] = self::USER_FIELD_NOTIFICATION_EMAIL;
+ $permittedFields[] = self::USER_FIELD_MANAGER;
+ if ($isAdmin || $isDelegatedAdmin) {
+ $permittedFields[] = self::FIELD_GROUPS;
+ $permittedFields[] = self::FIELD_SUBADMIN_GROUPS;
+ }
+
+ return $permittedFields;
+ }
+
+ /**
+ * Check whether the caller may change the display name on the target user.
+ * Self-edit goes through canChangeDisplayName(); non-self requires the
+ * backend's SET_DISPLAYNAME capability.
+ *
+ * @return string|null l10n'd error message, or null when permitted.
+ */
+ private function validateDisplayNameChange(IUser $targetUser, bool $isSelf): ?string {
+ $backend = $targetUser->getBackend();
+ if (!$isSelf) {
+ $canSetDisplayName = $backend instanceof ISetDisplayNameBackend
+ || ($backend !== null && $backend->implementsActions(Backend::SET_DISPLAYNAME));
+ } else {
+ $canSetDisplayName = $targetUser->canChangeDisplayName();
+ }
+ if (!$canSetDisplayName) {
+ return $this->l10n->t('Cannot change display name for this user');
+ }
+ return null;
+ }
+
+ /**
+ * Trim, lower-case, and validate an email address. When $allowEmpty is true,
+ * empty input returns ''; otherwise empty input throws.
+ *
+ * @return string Normalized email, or '' when $allowEmpty and $value is ''.
+ * @throws \InvalidArgumentException With l10n'd message when malformed or
+ * when empty input is not permitted.
+ */
+ private function validateAndNormalizeEmail(string $value, bool $allowEmpty): string {
+ $normalized = mb_strtolower(trim($value));
+ if ($normalized === '') {
+ if ($allowEmpty) {
+ return '';
+ }
+ throw new \InvalidArgumentException($this->l10n->t('Invalid email address'));
+ }
+ if (!$this->emailValidator->isValid($normalized)) {
+ throw new \InvalidArgumentException($this->l10n->t('Invalid email address'));
+ }
+ return $normalized;
+ }
+
+ /**
+ * Enforce the `force_language` system config (admins / delegated admins are
+ * exempt) and validate the language exists.
+ *
+ * @return string|null l10n'd error message, or null when permitted.
+ */
+ private function validateLanguagePolicy(string $language, bool $isAdmin, bool $isDelegatedAdmin): ?string {
+ $forceLanguage = $this->config->getSystemValue('force_language', false);
+ if ($forceLanguage !== false && !$isAdmin && !$isDelegatedAdmin) {
+ return $this->l10n->t('Language change is not allowed on this instance');
+ }
+ if (!$this->l10nFactory->languageExists(null, $language)) {
+ return $this->l10n->t('Invalid language');
+ }
+ return null;
+ }
+
+ /**
+ * Persist a language preference for the target user.
+ */
+ private function applyLanguage(IUser $targetUser, string $language): void {
+ $this->userConfig->setValueString($targetUser->getUID(), 'core', 'lang', $language);
+ }
+
+ /**
+ * Reconcile group membership against a desired set of GIDs (group existence
+ * validated upstream). Non-full-admin callers cannot add the target to the
+ * literal 'admin' group, matching the convention in addToGroup().
+ */
+ private function applyGroupMembership(IUser $targetUser, array $desiredGids, bool $callerIsFullAdmin): void {
+ $currentGroups = $this->groupManager->getUserGroups($targetUser);
+ $currentGroupIds = array_map(fn (IGroup $g) => $g->getGID(), $currentGroups);
+ foreach (array_diff($currentGroupIds, $desiredGids) as $gid) {
+ $this->groupManager->get($gid)?->removeUser($targetUser);
+ }
+ foreach (array_diff($desiredGids, $currentGroupIds) as $gid) {
+ if (!$callerIsFullAdmin && $gid === 'admin') {
+ continue;
+ }
+ $this->groupManager->get($gid)?->addUser($targetUser);
+ }
+ }
+
+ /**
+ * Reconcile sub-admin membership against a desired set of GIDs (group
+ * existence validated upstream). 'admin' is always skipped, matching
+ * the convention in addSubAdmin().
+ */
+ private function applySubAdminMembership(IUser $targetUser, array $desiredGids): void {
+ $subAdminManager = $this->groupManager->getSubAdmin();
+ $currentGroups = $subAdminManager->getSubAdminsGroups($targetUser);
+ $currentGroupIds = array_map(fn (IGroup $g) => $g->getGID(), $currentGroups);
+ foreach (array_diff($currentGroupIds, $desiredGids) as $gid) {
+ $group = $this->groupManager->get($gid);
+ if ($group !== null) {
+ $subAdminManager->deleteSubAdmin($targetUser, $group);
+ }
+ }
+ foreach (array_diff($desiredGids, $currentGroupIds) as $gid) {
+ if ($gid === 'admin') {
+ continue;
+ }
+ $group = $this->groupManager->get($gid);
+ if ($group !== null && !$subAdminManager->isSubAdminOfGroup($targetUser, $group)) {
+ $subAdminManager->createSubAdmin($targetUser, $group);
+ }
+ }
+ }
+
+ /**
+ * Apply a new display name. When $allowEmptyAsReset is true an empty value
+ * resets to $userId (PATCH semantics); otherwise the empty value is
+ * forwarded so setDisplayName() throws InvalidArgumentException (legacy
+ * editUser semantics — caller catches and translates to OCS 101).
+ */
+ private function applyDisplayName(IUser $targetUser, string $value, string $userId, bool $allowEmptyAsReset): void {
+ if ($allowEmptyAsReset && $value === '') {
+ $targetUser->setDisplayName($userId);
+ return;
+ }
+ $targetUser->setDisplayName($value);
}
/**
@@ -1192,94 +1409,16 @@ public function editUser(string $userId, string $key, string $value): DataRespon
throw new OCSException('', OCSController::RESPOND_NOT_FOUND);
}
- $permittedFields = [];
- if ($targetUser->getUID() === $currentLoggedInUser->getUID()) {
- if ($targetUser->canChangeDisplayName()) {
- $permittedFields[] = self::USER_FIELD_DISPLAYNAME;
- }
-
- $permittedFields[] = IAccountManager::COLLECTION_EMAIL;
-
- $permittedFields[] = self::USER_FIELD_PASSWORD;
- $permittedFields[] = self::USER_FIELD_NOTIFICATION_EMAIL;
- $permittedFields[] = self::USER_FIELD_TIMEZONE;
- if (
- $this->config->getSystemValue('force_language', false) === false
- || $this->groupManager->isAdmin($currentLoggedInUser->getUID())
- || $this->groupManager->isDelegatedAdmin($currentLoggedInUser->getUID())
- ) {
- $permittedFields[] = self::USER_FIELD_LANGUAGE;
- }
-
- if (
- $this->config->getSystemValue('force_locale', false) === false
- || $this->groupManager->isAdmin($currentLoggedInUser->getUID())
- || $this->groupManager->isDelegatedAdmin($currentLoggedInUser->getUID())
- ) {
- $permittedFields[] = self::USER_FIELD_LOCALE;
- $permittedFields[] = self::USER_FIELD_FIRST_DAY_OF_WEEK;
- }
+ $isAdmin = (bool)$this->groupManager->isAdmin($currentLoggedInUser->getUID());
+ $isDelegatedAdmin = $this->groupManager->isDelegatedAdmin($currentLoggedInUser->getUID());
+ $isSelf = $targetUser->getUID() === $currentLoggedInUser->getUID();
+ $isSubAdminAccessible = !$isSelf
+ && $this->groupManager->getSubAdmin()->isUserAccessible($currentLoggedInUser, $targetUser);
- foreach (IAccountManager::ALLOWED_PROPERTIES as $property) {
- $permittedFields[] = $property . self::SCOPE_SUFFIX;
- if ($property === IAccountManager::PROPERTY_AVATAR) {
- continue;
- }
- if (!$targetUser->canEditProperty($property)) {
- continue;
- }
- $permittedFields[] = $property;
- }
+ $permittedFields = $this->getPermittedFields($targetUser, $currentLoggedInUser, $isAdmin, $isDelegatedAdmin, $isSubAdminAccessible);
- // If admin they can edit their own quota and manager
- $isAdmin = $this->groupManager->isAdmin($currentLoggedInUser->getUID());
- $isDelegatedAdmin = $this->groupManager->isDelegatedAdmin($currentLoggedInUser->getUID());
- if ($isAdmin || $isDelegatedAdmin) {
- $permittedFields[] = self::USER_FIELD_QUOTA;
- $permittedFields[] = self::USER_FIELD_MANAGER;
- }
- } else {
- // Check if admin / subadmin
- $subAdminManager = $this->groupManager->getSubAdmin();
- if (
- $this->groupManager->isAdmin($currentLoggedInUser->getUID())
- || $this->groupManager->isDelegatedAdmin($currentLoggedInUser->getUID()) && !$this->groupManager->isInGroup($targetUser->getUID(), 'admin')
- || $subAdminManager->isUserAccessible($currentLoggedInUser, $targetUser)
- ) {
- // They have permissions over the user
- if (
- $targetUser->getBackend() instanceof ISetDisplayNameBackend
- || $targetUser->getBackend()->implementsActions(Backend::SET_DISPLAYNAME)
- ) {
- $permittedFields[] = self::USER_FIELD_DISPLAYNAME;
- $permittedFields[] = IAccountManager::PROPERTY_DISPLAYNAME;
- }
- $permittedFields[] = IAccountManager::PROPERTY_EMAIL;
- $permittedFields[] = IAccountManager::COLLECTION_EMAIL;
- $permittedFields[] = self::USER_FIELD_PASSWORD;
- $permittedFields[] = self::USER_FIELD_LANGUAGE;
- $permittedFields[] = self::USER_FIELD_LOCALE;
- $permittedFields[] = self::USER_FIELD_TIMEZONE;
- $permittedFields[] = self::USER_FIELD_FIRST_DAY_OF_WEEK;
- $permittedFields[] = IAccountManager::PROPERTY_PHONE;
- $permittedFields[] = IAccountManager::PROPERTY_ADDRESS;
- $permittedFields[] = IAccountManager::PROPERTY_WEBSITE;
- $permittedFields[] = IAccountManager::PROPERTY_TWITTER;
- $permittedFields[] = IAccountManager::PROPERTY_BLUESKY;
- $permittedFields[] = IAccountManager::PROPERTY_FEDIVERSE;
- $permittedFields[] = IAccountManager::PROPERTY_ORGANISATION;
- $permittedFields[] = IAccountManager::PROPERTY_ROLE;
- $permittedFields[] = IAccountManager::PROPERTY_HEADLINE;
- $permittedFields[] = IAccountManager::PROPERTY_BIOGRAPHY;
- $permittedFields[] = IAccountManager::PROPERTY_PROFILE_ENABLED;
- $permittedFields[] = IAccountManager::PROPERTY_PRONOUNS;
- $permittedFields[] = self::USER_FIELD_QUOTA;
- $permittedFields[] = self::USER_FIELD_NOTIFICATION_EMAIL;
- $permittedFields[] = self::USER_FIELD_MANAGER;
- } else {
- // No rights
- throw new OCSException('', OCSController::RESPOND_NOT_FOUND);
- }
+ if (!$isSelf && empty($permittedFields)) {
+ throw new OCSException('', OCSController::RESPOND_NOT_FOUND);
}
// Check if permitted to edit this field
if (!in_array($key, $permittedFields)) {
@@ -1290,7 +1429,7 @@ public function editUser(string $userId, string $key, string $value): DataRespon
case self::USER_FIELD_DISPLAYNAME:
case IAccountManager::PROPERTY_DISPLAYNAME:
try {
- $targetUser->setDisplayName($value);
+ $this->applyDisplayName($targetUser, $value, $userId, allowEmptyAsReset: false);
} catch (InvalidArgumentException $e) {
throw new OCSException($e->getMessage(), 101);
}
@@ -1317,22 +1456,22 @@ public function editUser(string $userId, string $key, string $value): DataRespon
}
break;
case self::USER_FIELD_LANGUAGE:
- if (!$this->l10nFactory->languageExists(null, $value)) {
- throw new OCSException($this->l10n->t('Invalid language'), 101);
+ if (($error = $this->validateLanguagePolicy($value, $isAdmin, $isDelegatedAdmin)) !== null) {
+ throw new OCSException($error, 101);
}
- $this->config->setUserValue($targetUser->getUID(), 'core', 'lang', $value);
+ $this->applyLanguage($targetUser, $value);
break;
case self::USER_FIELD_LOCALE:
if (!$this->l10nFactory->localeExists($value)) {
throw new OCSException($this->l10n->t('Invalid locale'), 101);
}
- $this->config->setUserValue($targetUser->getUID(), 'core', 'locale', $value);
+ $this->userConfig->setValueString($targetUser->getUID(), 'core', 'locale', $value);
break;
case self::USER_FIELD_TIMEZONE:
if (!in_array($value, \DateTimeZone::listIdentifiers())) {
throw new OCSException($this->l10n->t('Invalid timezone'), 101);
}
- $this->config->setUserValue($targetUser->getUID(), 'core', 'timezone', $value);
+ $this->userConfig->setValueString($targetUser->getUID(), 'core', 'timezone', $value);
break;
case self::USER_FIELD_FIRST_DAY_OF_WEEK:
$intValue = (int)$value;
@@ -1340,9 +1479,9 @@ public function editUser(string $userId, string $key, string $value): DataRespon
throw new OCSException($this->l10n->t('Invalid first day of week'), 101);
}
if ($intValue === -1) {
- $this->config->deleteUserValue($targetUser->getUID(), 'core', AUserDataOCSController::USER_FIELD_FIRST_DAY_OF_WEEK);
+ $this->userConfig->deleteUserConfig($targetUser->getUID(), 'core', AUserDataOCSController::USER_FIELD_FIRST_DAY_OF_WEEK);
} else {
- $this->config->setUserValue($targetUser->getUID(), 'core', AUserDataOCSController::USER_FIELD_FIRST_DAY_OF_WEEK, $value);
+ $this->userConfig->setValueString($targetUser->getUID(), 'core', AUserDataOCSController::USER_FIELD_FIRST_DAY_OF_WEEK, $value);
}
break;
case self::USER_FIELD_NOTIFICATION_EMAIL:
@@ -1366,28 +1505,29 @@ public function editUser(string $userId, string $key, string $value): DataRespon
}
break;
case IAccountManager::PROPERTY_EMAIL:
- $value = mb_strtolower(trim($value));
- if (filter_var($value, FILTER_VALIDATE_EMAIL) || $value === '') {
- $targetUser->setSystemEMailAddress($value);
- } else {
+ try {
+ $value = $this->validateAndNormalizeEmail($value, allowEmpty: true);
+ } catch (\InvalidArgumentException $e) {
throw new OCSException('', 101);
}
+ $targetUser->setSystemEMailAddress($value);
break;
case IAccountManager::COLLECTION_EMAIL:
- $value = mb_strtolower(trim($value));
- if (filter_var($value, FILTER_VALIDATE_EMAIL) && $value !== $targetUser->getSystemEMailAddress()) {
- $userAccount = $this->accountManager->getAccount($targetUser);
- $mailCollection = $userAccount->getPropertyCollection(IAccountManager::COLLECTION_EMAIL);
-
- if ($mailCollection->getPropertyByValue($value)) {
- throw new OCSException('', 101);
- }
-
- $mailCollection->addPropertyWithDefaults($value);
- $this->accountManager->updateAccount($userAccount);
- } else {
+ try {
+ $value = $this->validateAndNormalizeEmail($value, allowEmpty: false);
+ } catch (\InvalidArgumentException $e) {
throw new OCSException('', 101);
}
+ if ($value === $targetUser->getSystemEMailAddress()) {
+ throw new OCSException('', 101);
+ }
+ $userAccount = $this->accountManager->getAccount($targetUser);
+ $mailCollection = $userAccount->getPropertyCollection(IAccountManager::COLLECTION_EMAIL);
+ if ($mailCollection->getPropertyByValue($value)) {
+ throw new OCSException('', 101);
+ }
+ $mailCollection->addPropertyWithDefaults($value);
+ $this->accountManager->updateAccount($userAccount);
break;
case IAccountManager::PROPERTY_PHONE:
case IAccountManager::PROPERTY_ADDRESS:
@@ -2006,7 +2146,7 @@ public function resendWelcomeMessage(string $userId): DataResponse {
}
try {
- if ($this->config->getUserValue($targetUser->getUID(), 'core', 'lostpassword')) {
+ if ($this->userConfig->getValueString($targetUser->getUID(), 'core', 'lostpassword') !== '') {
$emailTemplate = $this->newUserMailHelper->generateTemplate($targetUser, true);
} else {
$emailTemplate = $this->newUserMailHelper->generateTemplate($targetUser, false);
diff --git a/apps/provisioning_api/tests/Controller/UsersControllerTest.php b/apps/provisioning_api/tests/Controller/UsersControllerTest.php
index 20a0cf6bffc24..9c4ad341ebfb3 100644
--- a/apps/provisioning_api/tests/Controller/UsersControllerTest.php
+++ b/apps/provisioning_api/tests/Controller/UsersControllerTest.php
@@ -26,6 +26,7 @@
use OCP\AppFramework\OCS\OCSException;
use OCP\AppFramework\OCS\OCSForbiddenException;
use OCP\AppFramework\OCSController;
+use OCP\Config\IUserConfig;
use OCP\EventDispatcher\IEventDispatcher;
use OCP\Files\IRootFolder;
use OCP\Group\ISubAdmin;
@@ -41,6 +42,7 @@
use OCP\IUserSession;
use OCP\L10N\IFactory;
use OCP\Mail\IEMailTemplate;
+use OCP\Mail\IEmailValidator;
use OCP\Security\Events\GenerateSecurePasswordEvent;
use OCP\Security\ISecureRandom;
use OCP\UserInterface;
@@ -70,6 +72,10 @@ class UsersControllerTest extends TestCase {
private IPhoneNumberUtil $phoneNumberUtil;
private IAppManager $appManager;
private IAppConfig&MockObject $appConfig;
+ private IUserConfig&MockObject $userConfig;
+ private IEmailValidator&MockObject $emailValidator;
+ /** @var \Closure(string): bool Swappable isValid() behavior; defaults to filter_var parity. */
+ private \Closure $emailValidatorIsValid;
protected function setUp(): void {
parent::setUp();
@@ -92,6 +98,14 @@ protected function setUp(): void {
$this->phoneNumberUtil = new PhoneNumberUtil();
$this->appManager = $this->createMock(IAppManager::class);
$this->appConfig = $this->createMock(IAppConfig::class);
+ $this->userConfig = $this->createMock(IUserConfig::class);
+ $this->emailValidator = $this->createMock(IEmailValidator::class);
+ // Default to filter_var parity so existing tests are unaffected; individual tests may
+ // swap $this->emailValidatorIsValid to exercise IEmailValidator-specific accept/reject.
+ $this->emailValidatorIsValid = fn (string $email): bool => $email !== '' && filter_var($email, FILTER_VALIDATE_EMAIL) !== false;
+ $this->emailValidator->method('isValid')->willReturnCallback(
+ fn (string $email): bool => ($this->emailValidatorIsValid)($email),
+ );
$this->rootFolder = $this->createMock(IRootFolder::class);
$l10n = $this->createMock(IL10N::class);
@@ -120,6 +134,8 @@ protected function setUp(): void {
$this->phoneNumberUtil,
$this->appManager,
$this->appConfig,
+ $this->userConfig,
+ $this->emailValidator,
])
->onlyMethods(['fillStorageInfo'])
->getMock();
@@ -506,6 +522,8 @@ public function testAddUserSuccessfulWithDisplayName(): void {
$this->phoneNumberUtil,
$this->appManager,
$this->appConfig,
+ $this->userConfig,
+ $this->emailValidator,
])
->onlyMethods(['editUser'])
->getMock();
@@ -2143,7 +2161,7 @@ public function testEditUserAdminUserSelfEditChangeValidQuota(): void {
->with('UserToEdit')
->willReturn($targetUser);
$this->groupManager
- ->expects($this->exactly(3))
+ ->expects($this->any())
->method('isAdmin')
->with('UID')
->willReturn(true);
@@ -2182,7 +2200,7 @@ public function testEditUserAdminUserSelfEditChangeInvalidQuota(): void {
->with('UserToEdit')
->willReturn($targetUser);
$this->groupManager
- ->expects($this->exactly(3))
+ ->expects($this->any())
->method('isAdmin')
->with('UID')
->willReturn(true);
@@ -2267,8 +2285,8 @@ public function testEditUserSelfEditChangeLanguage(): void {
->method('getUID')
->willReturn('UserToEdit');
$targetUser = $this->createMock(IUser::class);
- $this->config->expects($this->once())
- ->method('setUserValue')
+ $this->userConfig->expects($this->once())
+ ->method('setValueString')
->with('UserToEdit', 'core', 'lang', 'de');
$this->userSession
->expects($this->once())
@@ -2322,8 +2340,8 @@ public function testEditUserSelfEditChangeLanguageButForced($forced): void {
->method('getUID')
->willReturn('UserToEdit');
$targetUser = $this->createMock(IUser::class);
- $this->config->expects($this->never())
- ->method('setUserValue');
+ $this->userConfig->expects($this->never())
+ ->method('setValueString');
$this->userSession
->expects($this->once())
->method('getUser')
@@ -2363,8 +2381,8 @@ public function testEditUserAdminEditChangeLanguage(): void {
->method('getUID')
->willReturn('admin');
$targetUser = $this->createMock(IUser::class);
- $this->config->expects($this->once())
- ->method('setUserValue')
+ $this->userConfig->expects($this->once())
+ ->method('setValueString')
->with('UserToEdit', 'core', 'lang', 'de');
$this->userSession
->expects($this->once())
@@ -2413,8 +2431,8 @@ public function testEditUserAdminEditChangeLanguageInvalidLanguage(): void {
->method('getUID')
->willReturn('admin');
$targetUser = $this->createMock(IUser::class);
- $this->config->expects($this->never())
- ->method('setUserValue');
+ $this->userConfig->expects($this->never())
+ ->method('setValueString');
$this->userSession
->expects($this->once())
->method('getUser')
@@ -2813,10 +2831,13 @@ public function testUpdateUserForceLanguageBlocksNonAdmin(): void {
$subAdmin->method('isUserAccessible')->willReturn(false);
$this->groupManager->method('getSubAdmin')->willReturn($subAdmin);
- // force_language is set — regular users cannot change language
+ // force_language is set — regular users cannot change language.
+ // force_locale is unset so the locale gate stays open (irrelevant to this test).
$this->config->method('getSystemValue')
- ->with('force_language', false)
- ->willReturn('en');
+ ->willReturnMap([
+ ['force_language', false, 'en'],
+ ['force_locale', false, false],
+ ]);
$result = $this->api->editUserMultiField('regularuser', language: 'de');
$this->assertSame(Http::STATUS_UNPROCESSABLE_ENTITY, $result->getStatus());
@@ -2848,6 +2869,265 @@ public function testEditUserMultiFieldClearDisplayNameResetsToUserId(): void {
$this->assertSame(Http::STATUS_OK, $result->getStatus());
}
+ public function testValidateDisplayNameChangeReturnsErrorWhenBackendLacksSupport(): void {
+ // Non-self path: backend must implement SET_DISPLAYNAME (or ISetDisplayNameBackend).
+ // When neither is true, the helper must return a non-null l10n error string.
+ $targetUser = $this->createMock(IUser::class);
+ $backend = $this->createMock(UserInterface::class);
+ $backend->method('implementsActions')->willReturn(false);
+ $targetUser->method('getBackend')->willReturn($backend);
+
+ $result = $this->invokePrivate($this->api, 'validateDisplayNameChange', [$targetUser, false]);
+
+ $this->assertNotNull($result);
+ $this->assertIsString($result);
+ }
+
+ public function testApplyDisplayNameResetsToUserIdWhenEmptyAndAllowed(): void {
+ // Multi-field PATCH semantics: empty string resets to userId.
+ $targetUser = $this->createMock(IUser::class);
+ $targetUser->expects($this->once())->method('setDisplayName')->with('targetuser');
+
+ $this->invokePrivate($this->api, 'applyDisplayName', [$targetUser, '', 'targetuser', true]);
+ }
+
+ public function testApplyDisplayNameForwardsEmptyWhenResetDisallowed(): void {
+ // Single-field PUT semantics: empty string is forwarded so OC\User\User::setDisplayName()
+ // can throw InvalidArgumentException → OCS 101 (legacy editUser contract).
+ $targetUser = $this->createMock(IUser::class);
+ $targetUser->expects($this->once())->method('setDisplayName')->with('');
+
+ $this->invokePrivate($this->api, 'applyDisplayName', [$targetUser, '', 'targetuser', false]);
+ }
+
+ public function testValidateAndNormalizeEmailLowercasesAndTrims(): void {
+ $result = $this->invokePrivate($this->api, 'validateAndNormalizeEmail', [' Foo@Example.COM ', false]);
+ $this->assertSame('foo@example.com', $result);
+ }
+
+ public function testValidateAndNormalizeEmailRejectsMalformed(): void {
+ $this->expectException(\InvalidArgumentException::class);
+ $this->invokePrivate($this->api, 'validateAndNormalizeEmail', ['not-an-email', false]);
+ }
+
+ public function testValidateAndNormalizeEmailAcceptsEmptyWhenAllowed(): void {
+ $result = $this->invokePrivate($this->api, 'validateAndNormalizeEmail', ['', true]);
+ $this->assertSame('', $result);
+ }
+
+ public function testValidateLanguagePolicyBlocksNonAdminWhenForceLanguageSet(): void {
+ // force_language set + non-admin caller → must short-circuit with an l10n error
+ // before language-exists check is reached.
+ $this->config->method('getSystemValue')
+ ->with('force_language', false)
+ ->willReturn('en');
+
+ $result = $this->invokePrivate($this->api, 'validateLanguagePolicy', ['de', false, false]);
+
+ $this->assertNotNull($result);
+ $this->assertIsString($result);
+ }
+
+ public function testApplyGroupMembershipDelegatedAdminCannotPromoteToAdminGroup(): void {
+ // Caller is a delegated admin (callerIsFullAdmin=false). Desired GIDs include 'admin'.
+ // Helper must skip the 'admin' add and still apply the other group.
+ $targetUser = $this->createMock(IUser::class);
+ $this->groupManager->method('getUserGroups')->with($targetUser)->willReturn([]);
+
+ $adminGroup = $this->createMock(IGroup::class);
+ $adminGroup->expects($this->never())->method('addUser');
+
+ $staffGroup = $this->createMock(IGroup::class);
+ $staffGroup->expects($this->once())->method('addUser')->with($targetUser);
+
+ $this->groupManager->method('get')->willReturnMap([
+ ['admin', $adminGroup],
+ ['staff', $staffGroup],
+ ]);
+
+ $this->invokePrivate($this->api, 'applyGroupMembership', [$targetUser, ['admin', 'staff'], false]);
+ }
+
+ public function testGetPermittedFieldsReturnsEmptyForNonSelfWithoutAccess(): void {
+ // A regular user trying to edit another user with no admin / sub-admin
+ // rights gets back an empty set; callers translate that to OCS NOT_FOUND
+ // or 403 depending on endpoint contract.
+ $targetUser = $this->createMock(IUser::class);
+ $targetUser->method('getUID')->willReturn('targetuser');
+ $caller = $this->createMock(IUser::class);
+ $caller->method('getUID')->willReturn('caller');
+
+ $result = $this->invokePrivate(
+ $this->api,
+ 'getPermittedFields',
+ [$targetUser, $caller, false, false, false],
+ );
+ $this->assertSame([], $result);
+ }
+
+ public function testApplySubAdminMembershipCannotPromoteToAdminGroup(): void {
+ // Helper must skip 'admin' unconditionally and still apply other groups.
+ $targetUser = $this->createMock(IUser::class);
+ $subAdminManager = $this->createMock(ISubAdmin::class);
+ $subAdminManager->method('getSubAdminsGroups')->willReturn([]);
+ $subAdminManager->method('isSubAdminOfGroup')->willReturn(false);
+ $this->groupManager->method('getSubAdmin')->willReturn($subAdminManager);
+
+ $adminGroup = $this->createMock(IGroup::class);
+ $staffGroup = $this->createMock(IGroup::class);
+ $this->groupManager->method('get')->willReturnMap([
+ ['admin', $adminGroup],
+ ['staff', $staffGroup],
+ ]);
+
+ // 'admin' must never be promoted, 'staff' must be promoted exactly once.
+ $subAdminManager->expects($this->once())
+ ->method('createSubAdmin')
+ ->with($targetUser, $staffGroup);
+
+ $this->invokePrivate($this->api, 'applySubAdminMembership', [$targetUser, ['admin', 'staff']]);
+ }
+
+ public function testApplySubAdminMembershipRemovesStaleGroups(): void {
+ // Demotion branch: a current sub-admin group missing from the desired set
+ // must be removed via deleteSubAdmin(), and groups that stay are left alone.
+ $targetUser = $this->createMock(IUser::class);
+
+ $staleGroup = $this->createMock(IGroup::class);
+ $staleGroup->method('getGID')->willReturn('stale');
+ $keepGroup = $this->createMock(IGroup::class);
+ $keepGroup->method('getGID')->willReturn('keep');
+
+ $subAdminManager = $this->createMock(ISubAdmin::class);
+ $subAdminManager->method('getSubAdminsGroups')->willReturn([$staleGroup, $keepGroup]);
+ $this->groupManager->method('getSubAdmin')->willReturn($subAdminManager);
+ $this->groupManager->method('get')->willReturnMap([
+ ['stale', $staleGroup],
+ ['keep', $keepGroup],
+ ]);
+
+ // 'stale' is dropped → demoted exactly once; 'keep' stays → no add, no remove.
+ $subAdminManager->expects($this->once())
+ ->method('deleteSubAdmin')
+ ->with($targetUser, $staleGroup);
+ $subAdminManager->expects($this->never())->method('createSubAdmin');
+
+ $this->invokePrivate($this->api, 'applySubAdminMembership', [$targetUser, ['keep']]);
+ }
+
+ public function testEditUserMultiFieldSelfCannotChangeEmailWhenPropertyLocked(): void {
+ // Behavior change: the multi-field endpoint now gates the primary email on
+ // canEditProperty('email'). A self user whose backend locks that property
+ // (e.g. an LDAP-mapped account) can no longer change their email here. On the
+ // pre-refactor multi-field endpoint this was a format-only check, so the change
+ // previously went through. This now matches the single-field editUser endpoint.
+ $currentUser = $this->createMock(IUser::class);
+ $currentUser->method('getUID')->willReturn('regularuser');
+ $this->userSession->method('getUser')->willReturn($currentUser);
+
+ $targetUser = $this->createMock(IUser::class);
+ $targetUser->method('getUID')->willReturn('regularuser');
+ // Only the email property is locked; everything else stays editable.
+ $targetUser->method('canEditProperty')
+ ->willReturnCallback(fn (string $property): bool => $property !== IAccountManager::PROPERTY_EMAIL);
+ $this->userManager->method('get')->with('regularuser')->willReturn($targetUser);
+
+ // Regular, non-admin self caller. Sub-admin accessibility is not consulted on the
+ // self path (the controller short-circuits getSubAdmin() when caller === target).
+ $this->groupManager->method('isAdmin')->willReturn(false);
+ $this->groupManager->method('isDelegatedAdmin')->willReturn(false);
+
+ // force_language / force_locale unset: those unrelated gates stay open.
+ $this->config->method('getSystemValue')->willReturnCallback(fn (string $key, mixed $default) => $default);
+
+ // The permission gate must fire before any write happens.
+ $targetUser->expects($this->never())->method('setSystemEMailAddress');
+
+ $result = $this->api->editUserMultiField('regularuser', email: 'new@example.com');
+ $this->assertSame(Http::STATUS_UNPROCESSABLE_ENTITY, $result->getStatus());
+ $this->assertArrayHasKey('email', $result->getData()['errors']);
+ }
+
+ public function testEditUserSubAdminCannotChangeLanguageWhenForced(): void {
+ // Behavior change: the single-field editUser LANGUAGE case now enforces the
+ // force_language policy (previously it only checked languageExists()). A sub-admin
+ // editing another user is neither admin nor delegated admin, so force_language now
+ // blocks them, matching what the multi-field endpoint already did.
+ $this->expectException(OCSException::class);
+ $this->expectExceptionCode(101);
+ $this->expectExceptionMessage('Language change is not allowed on this instance');
+
+ $loggedInUser = $this->createMock(IUser::class);
+ $loggedInUser->method('getUID')->willReturn('subadmin');
+ $targetUser = $this->createMock(IUser::class);
+ $targetUser->method('getUID')->willReturn('UserToEdit');
+ $targetUser->method('getBackend')->willReturn($this->createMock(UserInterface::class));
+
+ $this->userSession->method('getUser')->willReturn($loggedInUser);
+ $this->userManager->method('get')->with('UserToEdit')->willReturn($targetUser);
+
+ $this->groupManager->method('isAdmin')->willReturn(false);
+ $this->groupManager->method('isDelegatedAdmin')->willReturn(false);
+ // Sub-admin has access over the target → canEditOther, so language is a permitted field.
+ $subAdmin = $this->createMock(ISubAdmin::class);
+ $subAdmin->method('isUserAccessible')->willReturn(true);
+ $this->groupManager->method('getSubAdmin')->willReturn($subAdmin);
+
+ $this->config->method('getSystemValue')
+ ->willReturnCallback(fn (string $key, mixed $default) => $key === 'force_language' ? 'en' : $default);
+
+ // Language must never be persisted.
+ $this->userConfig->expects($this->never())->method('setValueString');
+
+ $this->api->editUser('UserToEdit', 'language', 'de');
+ }
+
+ public function testEditUserEmailRejectedWhenEmailValidatorRejects(): void {
+ // Behavior change: email format is decided by IEmailValidator (Egulias), not filter_var.
+ // When the validator rejects, the edit fails with OCS 101 and nothing is written —
+ // even for an address filter_var would have accepted.
+ $this->expectException(OCSException::class);
+ $this->expectExceptionCode(101);
+
+ $this->emailValidatorIsValid = fn (string $email): bool => false;
+
+ $loggedInUser = $this->createMock(IUser::class);
+ $loggedInUser->method('getUID')->willReturn('UID');
+ $targetUser = $this->createMock(IUser::class);
+ $targetUser->method('getUID')->willReturn('UID');
+ $targetUser->method('canEditProperty')
+ ->willReturnCallback(fn (string $property): bool => $property === IAccountManager::PROPERTY_EMAIL);
+ $targetUser->expects($this->never())->method('setSystemEMailAddress');
+
+ $this->userSession->method('getUser')->willReturn($loggedInUser);
+ $this->userManager->method('get')->with('UserToEdit')->willReturn($targetUser);
+ $this->config->method('getSystemValue')->willReturnCallback(fn (string $key, mixed $default) => $default);
+
+ $this->api->editUser('UserToEdit', 'email', 'looks-valid@example.com');
+ }
+
+ public function testEditUserEmailAcceptsAddressApprovedByValidator(): void {
+ // IEmailValidator (RFC mode) accepts forms filter_var rejects, e.g. a bare hostname.
+ // The controller must defer to the validator and persist the normalized address.
+ $this->emailValidatorIsValid = fn (string $email): bool => true;
+
+ $loggedInUser = $this->createMock(IUser::class);
+ $loggedInUser->method('getUID')->willReturn('UID');
+ $targetUser = $this->createMock(IUser::class);
+ $targetUser->method('getUID')->willReturn('UID');
+ $targetUser->method('canEditProperty')
+ ->willReturnCallback(fn (string $property): bool => $property === IAccountManager::PROPERTY_EMAIL);
+ // 'user@localhost' is rejected by filter_var but accepted by RFC validation; it must be
+ // lower-cased, trimmed, and stored.
+ $targetUser->expects($this->once())->method('setSystemEMailAddress')->with('user@localhost');
+
+ $this->userSession->method('getUser')->willReturn($loggedInUser);
+ $this->userManager->method('get')->with('UserToEdit')->willReturn($targetUser);
+ $this->config->method('getSystemValue')->willReturnCallback(fn (string $key, mixed $default) => $default);
+
+ $this->assertEquals([], $this->api->editUser('UserToEdit', 'email', ' User@LocalHost ')->getData());
+ }
+
public function testDeleteUserNotExistingUser(): void {
$this->expectException(OCSException::class);
$this->expectExceptionCode(998);
@@ -4101,6 +4381,8 @@ public function testGetCurrentUserLoggedIn(): void {
$this->phoneNumberUtil,
$this->appManager,
$this->appConfig,
+ $this->userConfig,
+ $this->emailValidator,
])
->onlyMethods(['getUserData'])
->getMock();
@@ -4194,6 +4476,8 @@ public function testGetUser(): void {
$this->phoneNumberUtil,
$this->appManager,
$this->appConfig,
+ $this->userConfig,
+ $this->emailValidator,
])
->onlyMethods(['getUserData'])
->getMock();
diff --git a/build/psalm-baseline.xml b/build/psalm-baseline.xml
index 329f3732fb029..e01cecfcba6df 100644
--- a/build/psalm-baseline.xml
+++ b/build/psalm-baseline.xml
@@ -2259,20 +2259,12 @@
-
-
-
-
-
-
-
-