fix(security): enforce atomic role/permission writes and user assignment rollback

This commit is contained in:
2026-03-09 19:54:58 +01:00
parent 11ca546eae
commit 01c05d997f
5 changed files with 228 additions and 73 deletions

View File

@@ -260,9 +260,9 @@ class UserAccountServiceTest extends TestCase
);
$assignmentService = $this->createMock(UserAssignmentService::class);
$assignmentService->expects($this->once())->method('syncTenants')->with(77, [3]);
$assignmentService->expects($this->once())->method('syncRoles')->with(77, [4]);
$assignmentService->expects($this->once())->method('syncDepartments')->with(77, [5]);
$assignmentService->expects($this->once())->method('syncTenants')->with(77, [3])->willReturn(true);
$assignmentService->expects($this->once())->method('syncRoles')->with(77, [4])->willReturn(true);
$assignmentService->expects($this->once())->method('syncDepartments')->with(77, [5])->willReturn(true);
$service = $this->newService(
$readRepository,
@@ -284,6 +284,128 @@ class UserAccountServiceTest extends TestCase
$this->assertTrue($result['ok']);
}
public function testCreateFromAdminRollsBackTransactionWhenTenantSyncFails(): void
{
$passwordService = $this->createMock(UserPasswordService::class);
$passwordService->method('validatePassword')->willReturn([]);
$settingsGateway = $this->createMock(UserSettingsGateway::class);
$settingsGateway->method('getDefaultTenantId')->willReturn(null);
$settingsGateway->method('getDefaultRoleId')->willReturn(null);
$settingsGateway->method('getDefaultDepartmentId')->willReturn(null);
$settingsGateway->method('getAppTheme')->willReturn('light');
$writeRepository = $this->createMock(UserWriteRepositoryInterface::class);
$writeRepository->expects($this->once())->method('create')->willReturn(88);
$readRepository = $this->createMock(UserReadRepositoryInterface::class);
$readRepository->expects($this->exactly(2))
->method('findByEmail')
->with('create@example.com')
->willReturnOnConsecutiveCalls(
null,
['id' => 88, 'uuid' => 'user-uuid-88']
);
$assignmentService = $this->createMock(UserAssignmentService::class);
$assignmentService->expects($this->once())->method('normalizeTenantIds')->with([3])->willReturn([3]);
$assignmentService->expects($this->once())->method('syncTenants')->with(88, [3])->willReturn(false);
$assignmentService->expects($this->never())->method('syncRoles');
$assignmentService->expects($this->never())->method('syncDepartments');
$auditService = $this->createMock(SystemAuditService::class);
$auditService->expects($this->never())->method('record');
$databaseSessionRepository = $this->createMock(DatabaseSessionRepository::class);
$databaseSessionRepository->expects($this->once())->method('beginTransaction');
$databaseSessionRepository->expects($this->never())->method('commitTransaction');
$databaseSessionRepository->expects($this->once())->method('rollbackTransaction');
$service = $this->newService(
$readRepository,
$writeRepository,
null,
$assignmentService,
$passwordService,
$settingsGateway,
null,
null,
$auditService,
$databaseSessionRepository
);
$result = $service->createFromAdmin([
'first_name' => 'Max',
'last_name' => 'Mustermann',
'email' => 'create@example.com',
'password' => 'StrongPass1!',
'password2' => 'StrongPass1!',
'tenant_ids' => [3],
'active' => 1,
], 42);
$this->assertFalse($result['ok']);
$this->assertNotEmpty($result['errors'] ?? []);
}
public function testRegisterRollsBackTransactionWhenRoleSyncFails(): void
{
$passwordService = $this->createMock(UserPasswordService::class);
$passwordService->method('validatePassword')->willReturn([]);
$settingsGateway = $this->createMock(UserSettingsGateway::class);
$settingsGateway->method('getAppTheme')->willReturn('dark');
$settingsGateway->method('getDefaultTenantId')->willReturn(3);
$settingsGateway->method('getDefaultRoleId')->willReturn(4);
$settingsGateway->method('getDefaultDepartmentId')->willReturn(5);
$writeRepository = $this->createMock(UserWriteRepositoryInterface::class);
$writeRepository->expects($this->once())->method('create')->willReturn(77);
$readRepository = $this->createMock(UserReadRepositoryInterface::class);
$readRepository->expects($this->exactly(2))
->method('findByEmail')
->with('user@example.com')
->willReturnOnConsecutiveCalls(
null,
['id' => 77, 'uuid' => 'user-uuid']
);
$assignmentService = $this->createMock(UserAssignmentService::class);
$assignmentService->expects($this->once())->method('syncTenants')->with(77, [3])->willReturn(true);
$assignmentService->expects($this->once())->method('syncRoles')->with(77, [4])->willReturn(false);
$assignmentService->expects($this->never())->method('syncDepartments');
$databaseSessionRepository = $this->createMock(DatabaseSessionRepository::class);
$databaseSessionRepository->expects($this->once())->method('beginTransaction');
$databaseSessionRepository->expects($this->never())->method('commitTransaction');
$databaseSessionRepository->expects($this->once())->method('rollbackTransaction');
$service = $this->newService(
$readRepository,
$writeRepository,
null,
$assignmentService,
$passwordService,
$settingsGateway,
null,
null,
null,
$databaseSessionRepository
);
$result = $service->register([
'first_name' => 'Max',
'last_name' => 'Mustermann',
'email' => 'user@example.com',
'password' => 'StrongPass1!',
'password2' => 'StrongPass1!',
]);
$this->assertFalse($result['ok']);
$this->assertNotSame('', trim((string) ($result['error'] ?? '')));
}
public function testUpdateSelfProfileReturnsErrorWhenRequestedTenantIsNotAssigned(): void
{
$readRepository = $this->createMock(UserReadRepositoryInterface::class);