From 780c12103b429e5c5b2b274c65b8799be5ec7e5d Mon Sep 17 00:00:00 2001 From: blaipr Date: Mon, 24 Aug 2026 02:11:56 +0200 Subject: [PATCH 1/2] fix: a caller cannot assign a profile past their own MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `isAdminApp` and `isAdminAcc` are gated on the caller already holding them, in the web form and in the API's user endpoints alike. The profile a user is pointed at was not gated at all, at either door — and a profile is where the other thirty permissions live. Assigning one hands the user everything on it, so "may manage users" was "may hold any permission in the installation" in two steps: create or edit a user, point them at the administrator profile, sign in as them. The codebase already answers this question for the other door. `ProfileData::constrainedTo()` strips a *written* profile down to what the writer holds, and the API's `ProfileBase` applies it — because otherwise "may manage profiles" was "may become an administrator". The same escalation was still open next to it, by referencing a profile instead of writing one. `grantsBeyond()` is that question asked of a referenced profile, in terms of the existing one, so a permission added later is covered without either being revisited. Placed in `UserProfileService` and called from the web's `UserSaveBase` and the API's `UserBase`, rather than in `UserService::create()` — that is also the path the installer and LDAP auto-provisioning take, where there is no signed-in caller to constrain against. Two things the tests settled: A profile that does not exist is let through rather than refused. The integration suite caught the first version changing what a bad id reports — "Profile not found" instead of the error the foreign key produces. A profile that is not there grants nothing, so there is nothing for this guard to say about it. An application administrator's profile is not even read, which is what the `never()` in that test asserts. Checked by making the comparison always false: the refusal test fails and the two that pin what must still work do not. --- .../User/Ports/UserProfileService.php | 17 +++ src/Application/User/Services/UserProfile.php | 27 +++++ src/Domain/User/Models/ProfileData.php | 14 +++ .../Api/Controllers/User/CreateController.php | 6 +- .../Api/Controllers/User/EditController.php | 6 +- .../In/Api/Controllers/User/UserBase.php | 23 +++- .../Controllers/User/SaveCreateController.php | 2 + .../Controllers/User/SaveEditController.php | 2 + .../In/Web/Controllers/User/UserSaveBase.php | 26 ++++- .../User/Services/UserProfileTest.php | 103 ++++++++++++++++++ .../Controllers/User/CreateControllerTest.php | 6 +- .../Controllers/User/DeleteControllerTest.php | 6 +- .../Controllers/User/EditControllerTest.php | 6 +- .../Controllers/User/SearchControllerTest.php | 6 +- .../Controllers/User/ViewControllerTest.php | 6 +- .../In/Web/Controllers/User/RefusalsTest.php | 3 +- 16 files changed, 249 insertions(+), 10 deletions(-) diff --git a/src/Application/User/Ports/UserProfileService.php b/src/Application/User/Ports/UserProfileService.php index 42b1b1d08..b1345d237 100644 --- a/src/Application/User/Ports/UserProfileService.php +++ b/src/Application/User/Ports/UserProfileService.php @@ -47,6 +47,23 @@ interface UserProfileService * @throws QueryException * @throws NoSuchItemException */ + /** + * Refuse a profile that grants more than the signed-in user holds themselves + * + * Assigning a user a profile hands them everything on it, so a delegate who may create or edit + * users must not be able to point one at a profile stronger than their own — otherwise "may + * manage users" is "may become an administrator" in two steps. Application administrators are + * not constrained; they hold everything by definition. + * + * @param int $profileId + * + * @throws ServiceException When the profile grants a permission the caller does not hold + * @throws ConstraintException + * @throws QueryException + * @throws NoSuchItemException + */ + public function assertAssignableBy(int $profileId): void; + public function getById(int $id): UserProfileModel; /** diff --git a/src/Application/User/Services/UserProfile.php b/src/Application/User/Services/UserProfile.php index f59fcebbd..cfd20a1d4 100644 --- a/src/Application/User/Services/UserProfile.php +++ b/src/Application/User/Services/UserProfile.php @@ -28,6 +28,7 @@ use SP\Application\Application; use SP\Domain\Common\Models\Simple; use SP\Domain\Common\Services\Service; +use SP\Domain\User\Models\ProfileData; use SP\Domain\Common\Services\ServiceException; use SP\Domain\Core\Dtos\ItemSearchDto; use SP\Domain\Core\Exceptions\ConstraintException; @@ -56,6 +57,32 @@ public function __construct(Application $application, private readonly UserProfi parent::__construct($application); } + /** + * @inheritDoc + */ + public function assertAssignableBy(int $profileId): void + { + if ($this->context->getUserData()->isAdminApp) { + return; + } + + try { + $profileData = $this->getById($profileId)->hydrate(ProfileData::class) ?? new ProfileData(); + } catch (NoSuchItemException) { + // Nothing to constrain: a profile that does not exist grants nothing. Refusing here + // would change what a bad id reports — the foreign key already rejects it, and this + // guard is about how much a profile grants, not whether it is there. + return; + } + + if ($profileData->grantsBeyond($this->context->getUserProfile())) { + throw ServiceException::error( + __u('You cannot assign a profile with more permissions than your own'), + __u('Please contact to the administrator') + ); + } + } + /** * @throws ConstraintException * @throws QueryException diff --git a/src/Domain/User/Models/ProfileData.php b/src/Domain/User/Models/ProfileData.php index f17efd20b..8c6e2504b 100644 --- a/src/Domain/User/Models/ProfileData.php +++ b/src/Domain/User/Models/ProfileData.php @@ -42,6 +42,20 @@ class ProfileData extends Model * Every boolean on the model is intersected, so a permission added later is covered without * this having to be revisited. */ + /** + * Whether this profile grants anything the actor does not itself hold. + * + * `constrainedTo()` answers the question for a profile being *written*. This answers it for one + * being *referenced*: assigning a user a profile hands them everything on it, so a delegate who + * may create or edit users must not be able to point one at a profile stronger than their own. + * Otherwise "may manage users" is "may become an administrator" in two steps, which is the same + * escalation `constrainedTo()` exists to stop through the other door. + */ + public function grantsBeyond(?ProfileData $actorProfile): bool + { + return $this->constrainedTo($actorProfile)->toArray() !== $this->toArray(); + } + public function constrainedTo(?ProfileData $actorProfile): ProfileData { $mutations = []; diff --git a/src/Infrastructure/Adapter/In/Api/Controllers/User/CreateController.php b/src/Infrastructure/Adapter/In/Api/Controllers/User/CreateController.php index ef40513e4..56b04a07b 100644 --- a/src/Infrastructure/Adapter/In/Api/Controllers/User/CreateController.php +++ b/src/Infrastructure/Adapter/In/Api/Controllers/User/CreateController.php @@ -35,6 +35,10 @@ public function createAction(): ApiResponse private function buildUserData(): User { + $userProfileId = $this->apiService->getParamInt('userProfileId', true); + + $this->assertProfileIsAssignable($userProfileId); + return new User([ 'name' => $this->apiService->getParamString('name', true), 'login' => $this->apiService->getParamString('login', true), @@ -45,7 +49,7 @@ private function buildUserData(): User 'email' => $this->apiService->getParamString('email'), 'notes' => $this->apiService->getParamString('notes'), 'userGroupId' => $this->apiService->getParamInt('userGroupId', true), - 'userProfileId' => $this->apiService->getParamInt('userProfileId', true), + 'userProfileId' => $userProfileId, 'isAdminApp' => $this->context->getUserData()->isAdminApp && (bool) $this->apiService->getParamInt('isAdminApp'), 'isAdminAcc' => $this->context->getUserData()->isAdminApp && (bool) $this->apiService->getParamInt('isAdminAcc'), 'isDisabled' => (bool) $this->apiService->getParamInt('isDisabled'), diff --git a/src/Infrastructure/Adapter/In/Api/Controllers/User/EditController.php b/src/Infrastructure/Adapter/In/Api/Controllers/User/EditController.php index aadb8d2a4..44e56eafd 100644 --- a/src/Infrastructure/Adapter/In/Api/Controllers/User/EditController.php +++ b/src/Infrastructure/Adapter/In/Api/Controllers/User/EditController.php @@ -36,6 +36,10 @@ public function editAction(): ApiResponse private function buildUserData(): User { + $userProfileId = $this->apiService->getParamInt('userProfileId', true); + + $this->assertProfileIsAssignable($userProfileId); + return new User([ 'id' => $this->apiService->getParamInt('id', true), 'name' => $this->apiService->getParamString('name', true), @@ -43,7 +47,7 @@ private function buildUserData(): User 'email' => $this->apiService->getParamString('email'), 'notes' => $this->apiService->getParamString('notes'), 'userGroupId' => $this->apiService->getParamInt('userGroupId', true), - 'userProfileId' => $this->apiService->getParamInt('userProfileId', true), + 'userProfileId' => $userProfileId, 'isAdminApp' => $this->context->getUserData()->isAdminApp && (bool) $this->apiService->getParamInt('isAdminApp'), 'isAdminAcc' => $this->context->getUserData()->isAdminApp && (bool) $this->apiService->getParamInt('isAdminAcc'), 'isDisabled' => (bool) $this->apiService->getParamInt('isDisabled'), diff --git a/src/Infrastructure/Adapter/In/Api/Controllers/User/UserBase.php b/src/Infrastructure/Adapter/In/Api/Controllers/User/UserBase.php index 453f7ddbf..549b170c9 100644 --- a/src/Infrastructure/Adapter/In/Api/Controllers/User/UserBase.php +++ b/src/Infrastructure/Adapter/In/Api/Controllers/User/UserBase.php @@ -6,6 +6,7 @@ use SP\Application\Application; use SP\Application\Api\Ports\ApiService; use SP\Domain\Core\Acl\AclInterface; +use SP\Application\User\Ports\UserProfileService; use SP\Application\User\Ports\UserService; use SP\Domain\User\Models\User as UserModel; use SP\Infrastructure\Adapter\In\Api\Controllers\ControllerBase; @@ -20,7 +21,8 @@ public function __construct( Router $router, ApiService $apiService, AclInterface $acl, - UserService $userService + UserService $userService, + private readonly UserProfileService $userProfileService ) { parent::__construct($application, $router, $apiService, $acl); $this->userService = $userService; @@ -39,4 +41,23 @@ protected static function withoutCredentials(UserModel $user): array { return $user->toArray(null, UserModel::CREDENTIAL_COLS, true); } + + /** + * A caller may not point a user at a profile stronger than their own. + * + * `isAdminApp` and `isAdminAcc` are already gated on the caller holding them, here and in the + * web form — but the profile itself was not, and a profile is where the other thirty + * permissions live. Without this, "may create or edit users" reached every permission in the + * installation by assigning an existing profile that has them, which through the API is + * reachable with a token minted for USER_EDIT. + * + * @throws \SP\Domain\Common\Services\ServiceException + * @throws \SP\Domain\Core\Exceptions\ConstraintException + * @throws \SP\Domain\Core\Exceptions\QueryException + * @throws \SP\Domain\Core\Exceptions\NoSuchItemException + */ + final protected function assertProfileIsAssignable(int $profileId): void + { + $this->userProfileService->assertAssignableBy($profileId); + } } diff --git a/src/Infrastructure/Adapter/In/Web/Controllers/User/SaveCreateController.php b/src/Infrastructure/Adapter/In/Web/Controllers/User/SaveCreateController.php index fa24bae58..5c36a12af 100644 --- a/src/Infrastructure/Adapter/In/Web/Controllers/User/SaveCreateController.php +++ b/src/Infrastructure/Adapter/In/Web/Controllers/User/SaveCreateController.php @@ -60,6 +60,8 @@ public function saveCreateAction(): ActionResponse $itemData = $this->form->getItemData(); + $this->assertProfileIsAssignable($itemData); + $id = $this->userService->create($itemData); $this->eventDispatcher->notify(new Event( diff --git a/src/Infrastructure/Adapter/In/Web/Controllers/User/SaveEditController.php b/src/Infrastructure/Adapter/In/Web/Controllers/User/SaveEditController.php index 8ca8463bd..fa89637b2 100644 --- a/src/Infrastructure/Adapter/In/Web/Controllers/User/SaveEditController.php +++ b/src/Infrastructure/Adapter/In/Web/Controllers/User/SaveEditController.php @@ -64,6 +64,8 @@ public function saveEditAction(int $id): ActionResponse $itemData = $this->form->getItemData(); + $this->assertProfileIsAssignable($itemData); + $this->userService->update($itemData); $this->eventDispatcher->notify(new Event( diff --git a/src/Infrastructure/Adapter/In/Web/Controllers/User/UserSaveBase.php b/src/Infrastructure/Adapter/In/Web/Controllers/User/UserSaveBase.php index f2ed38a26..110536866 100644 --- a/src/Infrastructure/Adapter/In/Web/Controllers/User/UserSaveBase.php +++ b/src/Infrastructure/Adapter/In/Web/Controllers/User/UserSaveBase.php @@ -35,6 +35,7 @@ use SP\Domain\CustomField\Models\CustomFieldData as CustomFieldDataModel; use SP\Domain\User\Models\User; use SP\Application\User\Ports\UserPassRecoverService; +use SP\Application\User\Ports\UserProfileService; use SP\Application\User\Ports\UserService; use SP\Application\User\Services\UserPassRecover; use SP\Infrastructure\Adapter\In\Web\Controllers\ControllerBase; @@ -66,7 +67,8 @@ public function __construct( UserService $userService, CustomFieldDataService $customFieldService, MailService $mailService, - UserPassRecoverService $userPassRecoverService + UserPassRecoverService $userPassRecoverService, + private readonly UserProfileService $userProfileService ) { parent::__construct($application, $webControllerHelper); @@ -79,6 +81,28 @@ public function __construct( $this->form = new UserForm($application, $this->request); } + /** + * A caller may not point a user at a profile stronger than their own. + * + * `UserForm` already gates `isAdminApp` and `isAdminAcc` on the caller holding them, and the + * API's user endpoints do the same — but neither constrained `userProfileId`, and a profile is + * where the other thirty permissions live. Without this, "may manage users" reached every + * permission in the installation by assigning an existing profile that has them. + * + * Called from here rather than from `UserService::create()`, which is also the path the + * installer and LDAP auto-provisioning take, where there is no signed-in caller to constrain + * against. + * + * @throws ServiceException + * @throws ConstraintException + * @throws QueryException + * @throws NoSuchItemException + */ + final protected function assertProfileIsAssignable(User $userData): void + { + $this->userProfileService->assertAssignableBy($userData->getUserProfileId() ?? 0); + } + /** * @param int $userId * @param User $userData diff --git a/tests/Unit/Application/User/Services/UserProfileTest.php b/tests/Unit/Application/User/Services/UserProfileTest.php index 3455f7ec4..b396fd654 100644 --- a/tests/Unit/Application/User/Services/UserProfileTest.php +++ b/tests/Unit/Application/User/Services/UserProfileTest.php @@ -36,7 +36,11 @@ use SP\Application\User\Services\UserProfile; use SP\Domain\Core\Exceptions\DuplicatedItemException; use SP\Domain\Core\Exceptions\NoSuchItemException; +use PHPUnit\Framework\MockObject\Rule\InvocationOrder; use SP\Domain\Common\Dtos\QueryResult; +use SP\Tests\Support\Generators\UserDataGenerator; +use SP\Domain\User\Dtos\UserDto; +use SP\Domain\User\Models\ProfileData; use SP\Tests\Support\Generators\UserProfileDataGenerator; use SP\Tests\Support\UnitaryTestCase; @@ -270,6 +274,105 @@ public function testGetUsersForProfile() $this->assertEquals([$userProfile], $out); } + /** + * A profile granting more than the caller holds cannot be assigned to a user. + * + * `isAdminApp` and `isAdminAcc` were already gated on the caller holding them, at both doors — + * but the profile a user is pointed at was not, and that is where the other thirty permissions + * live. Assigning one hands the user everything on it, so without this "may manage users" was + * "may hold any permission in the installation", in two steps: create or edit a user, point + * them at the administrator profile, sign in as them. + * + * @throws ConstraintException + * @throws NoSuchItemException + * @throws QueryException + * @throws ServiceException + */ + public function testAProfileBeyondTheCallersOwnCannotBeAssigned() + { + $this->givenTheCallerHolds(new ProfileData(['accView' => true])); + $this->givenTheProfileBeingAssignedIs(new ProfileData(['accView' => true, 'mgmUsers' => true])); + + $this->expectException(ServiceException::class); + $this->expectExceptionMessage('You cannot assign a profile with more permissions than your own'); + + $this->userProfile->assertAssignableBy(100); + } + + /** + * One within it can, or the guard would have stopped delegates administering users at all. + * + * @throws ConstraintException + * @throws NoSuchItemException + * @throws QueryException + * @throws ServiceException + */ + public function testAProfileWithinTheCallersOwnIsAssignable() + { + $this->givenTheCallerHolds(new ProfileData(['accView' => true, 'mgmUsers' => true])); + $this->givenTheProfileBeingAssignedIs(new ProfileData(['accView' => true]), self::once()); + + $this->userProfile->assertAssignableBy(100); + + // The assertion is the `once()` above: the profile was actually read and compared, rather + // than the call short-circuiting somewhere before it got that far. + } + + /** + * An application administrator is not constrained — they hold everything by definition, and the + * profile is not even read for them. + * + * @throws ConstraintException + * @throws NoSuchItemException + * @throws QueryException + * @throws ServiceException + */ + public function testAnApplicationAdministratorMayAssignAnything() + { + $this->context->setUserData( + UserDto::fromModel( + UserDataGenerator::factory()->buildUserData()->mutate(['isAdminApp' => true]) + ) + ); + + // The `never()` is the assertion: an administrator's profile is not even read. + $this->userProfileRepository->expects(self::never())->method('getById'); + + $this->userProfile->assertAssignableBy(100); + } + + private function givenTheCallerHolds(ProfileData $profileData): void + { + $this->context->setUserData( + UserDto::fromModel( + UserDataGenerator::factory()->buildUserData()->mutate(['isAdminApp' => false]) + ) + ); + $this->context->setUserProfile($profileData); + } + + /** + * @throws Exception + */ + private function givenTheProfileBeingAssignedIs( + ProfileData $profileData, + ?InvocationOrder $times = null + ): void { + $result = new QueryResult([ + UserProfileDataGenerator::factory() + ->buildUserProfileData() + ->dehydrate($profileData) + ]); + + // Always through expects(): with() on a bare method() is deprecated in PHPUnit 13, and + // any() is too. Both callers read the profile exactly once, so once() is the honest count. + $this->userProfileRepository + ->expects($times ?? self::once()) + ->method('getById') + ->with(100) + ->willReturn($result); + } + protected function setUp(): void { parent::setUp(); diff --git a/tests/Unit/Infrastructure/Adapter/In/Api/Controllers/User/CreateControllerTest.php b/tests/Unit/Infrastructure/Adapter/In/Api/Controllers/User/CreateControllerTest.php index c0a1fbf82..77d31dd94 100644 --- a/tests/Unit/Infrastructure/Adapter/In/Api/Controllers/User/CreateControllerTest.php +++ b/tests/Unit/Infrastructure/Adapter/In/Api/Controllers/User/CreateControllerTest.php @@ -7,6 +7,7 @@ use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\MockObject\MockObject; use SP\Application\Api\Ports\ApiService; +use SP\Application\User\Ports\UserProfileService; use SP\Application\User\Ports\UserService; use SP\Infrastructure\Bootstrap\Router; use SP\Domain\Api\Dtos\ApiResponse; @@ -24,6 +25,7 @@ class CreateControllerTest extends UnitaryTestCase { private MockObject|ApiService $apiService; private MockObject|UserService $userService; + private MockObject|UserProfileService $userProfileService; private CreateController $controller; public function testCreateAction(): void @@ -164,6 +166,7 @@ protected function setUp(): void $this->apiService = $this->createMock(ApiService::class); $this->userService = $this->createMock(UserService::class); + $this->userProfileService = $this->createStub(UserProfileService::class); $router = new Router( new SymfonyRequest(), @@ -177,7 +180,8 @@ protected function setUp(): void $router, $this->apiService, $this->createStub(AclInterface::class), - $this->userService + $this->userService, + $this->userProfileService ); } } diff --git a/tests/Unit/Infrastructure/Adapter/In/Api/Controllers/User/DeleteControllerTest.php b/tests/Unit/Infrastructure/Adapter/In/Api/Controllers/User/DeleteControllerTest.php index d773112c6..ad07b7708 100644 --- a/tests/Unit/Infrastructure/Adapter/In/Api/Controllers/User/DeleteControllerTest.php +++ b/tests/Unit/Infrastructure/Adapter/In/Api/Controllers/User/DeleteControllerTest.php @@ -7,6 +7,7 @@ use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\MockObject\MockObject; use SP\Application\Api\Ports\ApiService; +use SP\Application\User\Ports\UserProfileService; use SP\Application\User\Ports\UserService; use SP\Infrastructure\Bootstrap\Router; use SP\Domain\Api\Dtos\ApiResponse; @@ -25,6 +26,7 @@ class DeleteControllerTest extends UnitaryTestCase { private MockObject|ApiService $apiService; private MockObject|UserService $userService; + private MockObject|UserProfileService $userProfileService; private DeleteController $controller; public function testDeleteAction(): void @@ -93,6 +95,7 @@ protected function setUp(): void $this->apiService = $this->createMock(ApiService::class); $this->userService = $this->createMock(UserService::class); + $this->userProfileService = $this->createStub(UserProfileService::class); $router = new Router( new SymfonyRequest(), @@ -106,7 +109,8 @@ protected function setUp(): void $router, $this->apiService, $this->createStub(AclInterface::class), - $this->userService + $this->userService, + $this->userProfileService ); } } diff --git a/tests/Unit/Infrastructure/Adapter/In/Api/Controllers/User/EditControllerTest.php b/tests/Unit/Infrastructure/Adapter/In/Api/Controllers/User/EditControllerTest.php index 2298bf944..cfecf849a 100644 --- a/tests/Unit/Infrastructure/Adapter/In/Api/Controllers/User/EditControllerTest.php +++ b/tests/Unit/Infrastructure/Adapter/In/Api/Controllers/User/EditControllerTest.php @@ -7,6 +7,7 @@ use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\MockObject\MockObject; use SP\Application\Api\Ports\ApiService; +use SP\Application\User\Ports\UserProfileService; use SP\Application\User\Ports\UserService; use SP\Infrastructure\Bootstrap\Router; use SP\Domain\Api\Dtos\ApiResponse; @@ -24,6 +25,7 @@ class EditControllerTest extends UnitaryTestCase { private MockObject|ApiService $apiService; private MockObject|UserService $userService; + private MockObject|UserProfileService $userProfileService; private EditController $controller; /** @@ -137,6 +139,7 @@ protected function setUp(): void $this->apiService = $this->createMock(ApiService::class); $this->userService = $this->createMock(UserService::class); + $this->userProfileService = $this->createStub(UserProfileService::class); $router = new Router( new SymfonyRequest(), @@ -150,7 +153,8 @@ protected function setUp(): void $router, $this->apiService, $this->createStub(AclInterface::class), - $this->userService + $this->userService, + $this->userProfileService ); } } diff --git a/tests/Unit/Infrastructure/Adapter/In/Api/Controllers/User/SearchControllerTest.php b/tests/Unit/Infrastructure/Adapter/In/Api/Controllers/User/SearchControllerTest.php index ddaf27cbf..f4a76831f 100644 --- a/tests/Unit/Infrastructure/Adapter/In/Api/Controllers/User/SearchControllerTest.php +++ b/tests/Unit/Infrastructure/Adapter/In/Api/Controllers/User/SearchControllerTest.php @@ -7,6 +7,7 @@ use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\MockObject\MockObject; use SP\Application\Api\Ports\ApiService; +use SP\Application\User\Ports\UserProfileService; use SP\Application\User\Ports\UserService; use SP\Infrastructure\Bootstrap\Router; use SP\Domain\Api\Dtos\ApiResponse; @@ -26,6 +27,7 @@ class SearchControllerTest extends UnitaryTestCase { private MockObject|ApiService $apiService; private MockObject|UserService $userService; + private MockObject|UserProfileService $userProfileService; private SearchController $controller; public function testSearchAction(): void @@ -96,6 +98,7 @@ protected function setUp(): void $this->apiService = $this->createMock(ApiService::class); $this->userService = $this->createMock(UserService::class); + $this->userProfileService = $this->createStub(UserProfileService::class); $router = new Router( new SymfonyRequest(), @@ -109,7 +112,8 @@ protected function setUp(): void $router, $this->apiService, $this->createStub(AclInterface::class), - $this->userService + $this->userService, + $this->userProfileService ); } } diff --git a/tests/Unit/Infrastructure/Adapter/In/Api/Controllers/User/ViewControllerTest.php b/tests/Unit/Infrastructure/Adapter/In/Api/Controllers/User/ViewControllerTest.php index 47a4805ea..b07c42f80 100644 --- a/tests/Unit/Infrastructure/Adapter/In/Api/Controllers/User/ViewControllerTest.php +++ b/tests/Unit/Infrastructure/Adapter/In/Api/Controllers/User/ViewControllerTest.php @@ -7,6 +7,7 @@ use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\MockObject\MockObject; use SP\Application\Api\Ports\ApiService; +use SP\Application\User\Ports\UserProfileService; use SP\Application\User\Ports\UserService; use SP\Infrastructure\Bootstrap\Router; use SP\Domain\Api\Dtos\ApiResponse; @@ -26,6 +27,7 @@ class ViewControllerTest extends UnitaryTestCase { private MockObject|ApiService $apiService; private MockObject|UserService $userService; + private MockObject|UserProfileService $userProfileService; private ViewController $controller; public function testViewAction(): void @@ -96,6 +98,7 @@ protected function setUp(): void $this->apiService = $this->createMock(ApiService::class); $this->userService = $this->createMock(UserService::class); + $this->userProfileService = $this->createStub(UserProfileService::class); $router = new Router( new SymfonyRequest(), @@ -109,7 +112,8 @@ protected function setUp(): void $router, $this->apiService, $this->createStub(AclInterface::class), - $this->userService + $this->userService, + $this->userProfileService ); } } diff --git a/tests/Unit/Infrastructure/Adapter/In/Web/Controllers/User/RefusalsTest.php b/tests/Unit/Infrastructure/Adapter/In/Web/Controllers/User/RefusalsTest.php index 95ae3364e..c9b9b970f 100644 --- a/tests/Unit/Infrastructure/Adapter/In/Web/Controllers/User/RefusalsTest.php +++ b/tests/Unit/Infrastructure/Adapter/In/Web/Controllers/User/RefusalsTest.php @@ -71,7 +71,8 @@ public function creatingAUserIsRefusedWhenTheAclDenies(): void $userService, $this->createStub(CustomFieldDataService::class), $this->createStub(MailService::class), - $this->createStub(UserPassRecoverService::class) + $this->createStub(UserPassRecoverService::class), + $this->createStub(UserProfileService::class) ))->saveCreateAction(); self::assertSame(ResponseStatus::ERROR, $response->status); From 03554460e7507b7bdb5b7f8a7f48a1d92868e1b7 Mon Sep 17 00:00:00 2001 From: blaipr Date: Mon, 24 Aug 2026 02:29:31 +0200 Subject: [PATCH 2/2] fix: import the exception the docblock names PHPStan resolved the bare NoSuchItemException in UserSaveBase's new docblock against the controller's own namespace, where there is no such class, so the @throws was not a Throwable. The other three exceptions on the same docblock were already imported. --- .../Adapter/In/Web/Controllers/User/UserSaveBase.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Infrastructure/Adapter/In/Web/Controllers/User/UserSaveBase.php b/src/Infrastructure/Adapter/In/Web/Controllers/User/UserSaveBase.php index 110536866..4d8869d1b 100644 --- a/src/Infrastructure/Adapter/In/Web/Controllers/User/UserSaveBase.php +++ b/src/Infrastructure/Adapter/In/Web/Controllers/User/UserSaveBase.php @@ -30,6 +30,7 @@ use SP\Domain\Common\Services\ServiceException; use SP\Domain\Core\Exceptions\ConstraintException; use SP\Domain\Core\Exceptions\QueryException; +use SP\Domain\Core\Exceptions\NoSuchItemException; use SP\Application\CustomField\Ports\CustomFieldDataService; use SP\Application\Notification\Ports\MailService; use SP\Domain\CustomField\Models\CustomFieldData as CustomFieldDataModel;