2026-03-06 00:44:52 +01:00
|
|
|
<?php
|
|
|
|
|
|
|
|
|
|
namespace MintyPHP\Tests\Service\User;
|
|
|
|
|
|
2026-03-09 19:16:26 +01:00
|
|
|
use MintyPHP\Repository\Support\DatabaseSessionRepository;
|
2026-03-06 00:44:52 +01:00
|
|
|
use MintyPHP\Repository\User\UserListQueryRepositoryInterface;
|
|
|
|
|
use MintyPHP\Repository\User\UserReadRepositoryInterface;
|
|
|
|
|
use MintyPHP\Repository\User\UserWriteRepositoryInterface;
|
refactor(audit): extract audit domain into self-contained module
Move the entire audit subsystem (system audit, API audit, import audit,
user lifecycle audit, frontend telemetry) from core into modules/audit/.
Core decoupling via interface-based injection:
- AuditRecorderInterface replaces SystemAuditService in 10+ core services
- UserLifecycleAuditInterface / ImportAuditInterface for specialized flows
- NullAuditRecorder fallback when audit module is disabled
- ApiBootstrap/ApiResponse use null-safe callable resolvers
Module structure (modules/audit/):
- Manifest with routes, permissions, scheduler jobs, authorization policy
- 9 services, 8 repositories, 6 domain enums, 4 job handlers
- 33 page files, 4 JS files, 8 test files, migration scripts, i18n
Core cleanup:
- OperationsAuthorizationPolicy, UiCapabilityMap, PermissionService
surgically cleaned of audit-specific constants
- Sidebar template cleared of hardcoded audit navigation
- AuditModuleIsolationContractTest ensures no future core→module coupling
All quality gates pass: 1346 tests (19,276 assertions), PHPStan level 5
clean, architecture contracts verified.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 21:12:49 +01:00
|
|
|
use MintyPHP\Service\Audit\AuditRecorderInterface;
|
2026-03-13 11:31:33 +01:00
|
|
|
use MintyPHP\Service\Tenant\TenantScopeService;
|
2026-03-06 00:44:52 +01:00
|
|
|
use MintyPHP\Service\User\UserAccountService;
|
|
|
|
|
use MintyPHP\Service\User\UserAssignmentService;
|
|
|
|
|
use MintyPHP\Service\User\UserDirectoryGateway;
|
|
|
|
|
use MintyPHP\Service\User\UserPasswordService;
|
|
|
|
|
use MintyPHP\Service\User\UserSettingsGateway;
|
2026-03-19 20:28:04 +01:00
|
|
|
use PHPUnit\Framework\Attributes\DataProvider;
|
2026-03-06 00:44:52 +01:00
|
|
|
use PHPUnit\Framework\TestCase;
|
|
|
|
|
|
|
|
|
|
class UserAccountServiceTest extends TestCase
|
|
|
|
|
{
|
|
|
|
|
public function testListPagedDropsTenantUserIdForGlobalAccess(): void
|
|
|
|
|
{
|
2026-03-13 11:31:33 +01:00
|
|
|
$scopeGateway = $this->createMock(TenantScopeService::class);
|
2026-03-06 00:44:52 +01:00
|
|
|
$scopeGateway->expects($this->once())
|
|
|
|
|
->method('hasGlobalAccess')
|
|
|
|
|
->with(99)
|
|
|
|
|
->willReturn(true);
|
|
|
|
|
|
|
|
|
|
$listQueryRepository = $this->createMock(UserListQueryRepositoryInterface::class);
|
|
|
|
|
$listQueryRepository->expects($this->once())
|
|
|
|
|
->method('listPaged')
|
|
|
|
|
->with($this->callback(static function (array $options): bool {
|
|
|
|
|
return !isset($options['tenantUserId'])
|
|
|
|
|
&& (int) ($options['page'] ?? 0) === 2;
|
|
|
|
|
}))
|
|
|
|
|
->willReturn(['rows' => [], 'total' => 0]);
|
|
|
|
|
|
|
|
|
|
$service = $this->newService(null, null, $listQueryRepository, null, null, null, $scopeGateway);
|
|
|
|
|
$result = $service->listPaged(['page' => 2, 'tenantUserId' => 99]);
|
|
|
|
|
|
|
|
|
|
$this->assertSame(0, (int) ($result['total'] ?? -1));
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-19 20:28:04 +01:00
|
|
|
#[DataProvider('missingDeleteByUuidInputProvider')]
|
|
|
|
|
public function testDeleteByUuidReturnsNotFoundForMissingInput(string $uuid, ?array $resolvedUser): void
|
2026-03-06 00:44:52 +01:00
|
|
|
{
|
2026-03-19 20:28:04 +01:00
|
|
|
$readRepository = $this->createMock(UserReadRepositoryInterface::class);
|
|
|
|
|
if ($uuid !== '') {
|
|
|
|
|
$readRepository->expects($this->once())
|
|
|
|
|
->method('findByUuid')
|
|
|
|
|
->with($uuid)
|
|
|
|
|
->willReturn($resolvedUser);
|
|
|
|
|
} else {
|
|
|
|
|
$readRepository->expects($this->never())->method('findByUuid');
|
|
|
|
|
}
|
2026-03-06 00:44:52 +01:00
|
|
|
|
2026-03-19 20:28:04 +01:00
|
|
|
$service = $this->newService($readRepository);
|
|
|
|
|
|
|
|
|
|
$result = $service->deleteByUuid($uuid);
|
2026-03-06 00:44:52 +01:00
|
|
|
|
|
|
|
|
$this->assertFalse($result['ok']);
|
|
|
|
|
$this->assertSame(404, (int) ($result['status'] ?? 0));
|
|
|
|
|
$this->assertSame('not_found', (string) ($result['error'] ?? ''));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function testDeleteByUuidPreventsSelfDelete(): void
|
|
|
|
|
{
|
|
|
|
|
$readRepository = $this->createMock(UserReadRepositoryInterface::class);
|
|
|
|
|
$readRepository->expects($this->once())
|
|
|
|
|
->method('findByUuid')
|
|
|
|
|
->with('user-uuid')
|
|
|
|
|
->willReturn(['id' => 10, 'uuid' => 'user-uuid']);
|
|
|
|
|
|
|
|
|
|
$writeRepository = $this->createMock(UserWriteRepositoryInterface::class);
|
|
|
|
|
$writeRepository->expects($this->never())->method('delete');
|
|
|
|
|
|
|
|
|
|
$service = $this->newService($readRepository, $writeRepository);
|
|
|
|
|
$result = $service->deleteByUuid('user-uuid', 10);
|
|
|
|
|
|
|
|
|
|
$this->assertFalse($result['ok']);
|
|
|
|
|
$this->assertSame(400, (int) ($result['status'] ?? 0));
|
|
|
|
|
$this->assertSame('self_delete', (string) ($result['error'] ?? ''));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function testDeleteByUuidDeletesAndRecordsAuditEvent(): void
|
|
|
|
|
{
|
|
|
|
|
$readRepository = $this->createMock(UserReadRepositoryInterface::class);
|
|
|
|
|
$readRepository->expects($this->once())
|
|
|
|
|
->method('findByUuid')
|
|
|
|
|
->with('user-uuid')
|
|
|
|
|
->willReturn(['id' => 11, 'uuid' => 'user-uuid']);
|
|
|
|
|
|
|
|
|
|
$writeRepository = $this->createMock(UserWriteRepositoryInterface::class);
|
|
|
|
|
$writeRepository->expects($this->once())
|
|
|
|
|
->method('delete')
|
|
|
|
|
->with(11)
|
|
|
|
|
->willReturn(true);
|
|
|
|
|
|
refactor(audit): extract audit domain into self-contained module
Move the entire audit subsystem (system audit, API audit, import audit,
user lifecycle audit, frontend telemetry) from core into modules/audit/.
Core decoupling via interface-based injection:
- AuditRecorderInterface replaces SystemAuditService in 10+ core services
- UserLifecycleAuditInterface / ImportAuditInterface for specialized flows
- NullAuditRecorder fallback when audit module is disabled
- ApiBootstrap/ApiResponse use null-safe callable resolvers
Module structure (modules/audit/):
- Manifest with routes, permissions, scheduler jobs, authorization policy
- 9 services, 8 repositories, 6 domain enums, 4 job handlers
- 33 page files, 4 JS files, 8 test files, migration scripts, i18n
Core cleanup:
- OperationsAuthorizationPolicy, UiCapabilityMap, PermissionService
surgically cleaned of audit-specific constants
- Sidebar template cleared of hardcoded audit navigation
- AuditModuleIsolationContractTest ensures no future core→module coupling
All quality gates pass: 1346 tests (19,276 assertions), PHPStan level 5
clean, architecture contracts verified.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 21:12:49 +01:00
|
|
|
$auditService = $this->createMock(AuditRecorderInterface::class);
|
2026-03-06 00:44:52 +01:00
|
|
|
$auditService->expects($this->once())
|
|
|
|
|
->method('record')
|
|
|
|
|
->with(
|
|
|
|
|
'admin.users.delete',
|
|
|
|
|
'success',
|
|
|
|
|
$this->callback(static function (array $context): bool {
|
|
|
|
|
return (int) ($context['actor_user_id'] ?? 0) === 44
|
|
|
|
|
&& (int) ($context['target_id'] ?? 0) === 11
|
|
|
|
|
&& (string) ($context['target_uuid'] ?? '') === 'user-uuid';
|
|
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
$service = $this->newService($readRepository, $writeRepository, null, null, null, null, null, null, $auditService);
|
|
|
|
|
$result = $service->deleteByUuid('user-uuid', 44);
|
|
|
|
|
|
|
|
|
|
$this->assertTrue($result['ok']);
|
|
|
|
|
$this->assertSame(11, (int) (($result['user'] ?? [])['id'] ?? 0));
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-19 20:28:04 +01:00
|
|
|
public function testDeleteByUuidReturnsDeleteFailedWhenRepositoryDeleteFails(): void
|
|
|
|
|
{
|
|
|
|
|
$readRepository = $this->createMock(UserReadRepositoryInterface::class);
|
|
|
|
|
$readRepository->expects($this->once())
|
|
|
|
|
->method('findByUuid')
|
|
|
|
|
->with('user-uuid')
|
|
|
|
|
->willReturn(['id' => 11, 'uuid' => 'user-uuid']);
|
|
|
|
|
|
|
|
|
|
$writeRepository = $this->createMock(UserWriteRepositoryInterface::class);
|
|
|
|
|
$writeRepository->expects($this->once())
|
|
|
|
|
->method('delete')
|
|
|
|
|
->with(11)
|
|
|
|
|
->willReturn(false);
|
|
|
|
|
|
refactor(audit): extract audit domain into self-contained module
Move the entire audit subsystem (system audit, API audit, import audit,
user lifecycle audit, frontend telemetry) from core into modules/audit/.
Core decoupling via interface-based injection:
- AuditRecorderInterface replaces SystemAuditService in 10+ core services
- UserLifecycleAuditInterface / ImportAuditInterface for specialized flows
- NullAuditRecorder fallback when audit module is disabled
- ApiBootstrap/ApiResponse use null-safe callable resolvers
Module structure (modules/audit/):
- Manifest with routes, permissions, scheduler jobs, authorization policy
- 9 services, 8 repositories, 6 domain enums, 4 job handlers
- 33 page files, 4 JS files, 8 test files, migration scripts, i18n
Core cleanup:
- OperationsAuthorizationPolicy, UiCapabilityMap, PermissionService
surgically cleaned of audit-specific constants
- Sidebar template cleared of hardcoded audit navigation
- AuditModuleIsolationContractTest ensures no future core→module coupling
All quality gates pass: 1346 tests (19,276 assertions), PHPStan level 5
clean, architecture contracts verified.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 21:12:49 +01:00
|
|
|
$auditService = $this->createMock(AuditRecorderInterface::class);
|
2026-03-19 20:28:04 +01:00
|
|
|
$auditService->expects($this->never())->method('record');
|
|
|
|
|
|
|
|
|
|
$service = $this->newService($readRepository, $writeRepository, null, null, null, null, null, null, $auditService);
|
|
|
|
|
$result = $service->deleteByUuid('user-uuid', 44);
|
|
|
|
|
|
|
|
|
|
$this->assertFalse($result['ok']);
|
|
|
|
|
$this->assertSame(500, (int) ($result['status'] ?? 0));
|
|
|
|
|
$this->assertSame('delete_failed', (string) ($result['error'] ?? ''));
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-06 00:44:52 +01:00
|
|
|
public function testDeleteByUuidsReturnsPermissionDeniedWhenNothingIsInScope(): void
|
|
|
|
|
{
|
|
|
|
|
$readRepository = $this->createMock(UserReadRepositoryInterface::class);
|
|
|
|
|
$readRepository->method('findByUuid')->willReturn(['id' => 51, 'uuid' => 'other-uuid']);
|
|
|
|
|
|
2026-03-13 11:31:33 +01:00
|
|
|
$scopeGateway = $this->createMock(TenantScopeService::class);
|
2026-03-06 00:44:52 +01:00
|
|
|
$scopeGateway->method('canAccess')->willReturn(false);
|
|
|
|
|
|
|
|
|
|
$writeRepository = $this->createMock(UserWriteRepositoryInterface::class);
|
|
|
|
|
$writeRepository->expects($this->never())->method('deleteByUuids');
|
|
|
|
|
|
|
|
|
|
$service = $this->newService($readRepository, $writeRepository, null, null, null, null, $scopeGateway);
|
|
|
|
|
$result = $service->deleteByUuids(['other-uuid'], 99);
|
|
|
|
|
|
|
|
|
|
$this->assertFalse($result['ok']);
|
|
|
|
|
$this->assertSame('permission_denied', (string) ($result['error'] ?? ''));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function testDeleteByUuidsReturnsSelfDeleteWhenOnlyOwnUuidRemains(): void
|
|
|
|
|
{
|
|
|
|
|
$readRepository = $this->createMock(UserReadRepositoryInterface::class);
|
|
|
|
|
$readRepository->expects($this->once())
|
|
|
|
|
->method('findByUuid')
|
|
|
|
|
->with('self-uuid')
|
|
|
|
|
->willReturn(['id' => 99, 'uuid' => 'self-uuid']);
|
|
|
|
|
$readRepository->expects($this->once())
|
|
|
|
|
->method('find')
|
|
|
|
|
->with(99)
|
|
|
|
|
->willReturn(['id' => 99, 'uuid' => 'self-uuid']);
|
|
|
|
|
|
|
|
|
|
$writeRepository = $this->createMock(UserWriteRepositoryInterface::class);
|
|
|
|
|
$writeRepository->expects($this->never())->method('deleteByUuids');
|
|
|
|
|
|
|
|
|
|
$service = $this->newService($readRepository, $writeRepository);
|
|
|
|
|
$result = $service->deleteByUuids(['self-uuid'], 99);
|
|
|
|
|
|
|
|
|
|
$this->assertFalse($result['ok']);
|
|
|
|
|
$this->assertSame('self_delete', (string) ($result['error'] ?? ''));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function testSetActiveByUuidPreventsSelfDeactivate(): void
|
|
|
|
|
{
|
|
|
|
|
$readRepository = $this->createMock(UserReadRepositoryInterface::class);
|
|
|
|
|
$readRepository->method('findByUuid')->willReturn(['id' => 42, 'uuid' => 'u-42']);
|
|
|
|
|
|
|
|
|
|
$writeRepository = $this->createMock(UserWriteRepositoryInterface::class);
|
|
|
|
|
$writeRepository->expects($this->never())->method('setActive');
|
|
|
|
|
|
|
|
|
|
$service = $this->newService($readRepository, $writeRepository);
|
|
|
|
|
$result = $service->setActiveByUuid('u-42', false, 42);
|
|
|
|
|
|
|
|
|
|
$this->assertFalse($result['ok']);
|
|
|
|
|
$this->assertSame(400, (int) ($result['status'] ?? 0));
|
|
|
|
|
$this->assertSame('self_deactivate', (string) ($result['error'] ?? ''));
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-19 20:28:04 +01:00
|
|
|
public function testSetActiveByUuidReturnsNotFoundForMissingUser(): void
|
|
|
|
|
{
|
|
|
|
|
$readRepository = $this->createMock(UserReadRepositoryInterface::class);
|
|
|
|
|
$readRepository->expects($this->once())
|
|
|
|
|
->method('findByUuid')
|
|
|
|
|
->with('u-missing')
|
|
|
|
|
->willReturn(null);
|
|
|
|
|
|
|
|
|
|
$writeRepository = $this->createMock(UserWriteRepositoryInterface::class);
|
|
|
|
|
$writeRepository->expects($this->never())->method('setActive');
|
|
|
|
|
|
|
|
|
|
$service = $this->newService($readRepository, $writeRepository);
|
|
|
|
|
$result = $service->setActiveByUuid('u-missing', true, 42);
|
|
|
|
|
|
|
|
|
|
$this->assertFalse($result['ok']);
|
|
|
|
|
$this->assertSame(404, (int) ($result['status'] ?? 0));
|
|
|
|
|
$this->assertSame('not_found', (string) ($result['error'] ?? ''));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function testSetActiveByUuidReturnsUpdateFailedWhenRepositoryRejects(): void
|
|
|
|
|
{
|
|
|
|
|
$readRepository = $this->createMock(UserReadRepositoryInterface::class);
|
|
|
|
|
$readRepository->method('findByUuid')->willReturn(['id' => 33, 'uuid' => 'u-33', 'active' => 0]);
|
|
|
|
|
|
|
|
|
|
$writeRepository = $this->createMock(UserWriteRepositoryInterface::class);
|
|
|
|
|
$writeRepository->expects($this->once())
|
|
|
|
|
->method('setActive')
|
|
|
|
|
->with(33, true, 90)
|
|
|
|
|
->willReturn(false);
|
|
|
|
|
|
|
|
|
|
$assignmentService = $this->createMock(UserAssignmentService::class);
|
|
|
|
|
$assignmentService->expects($this->never())->method('bumpAuthzVersion');
|
|
|
|
|
|
refactor(audit): extract audit domain into self-contained module
Move the entire audit subsystem (system audit, API audit, import audit,
user lifecycle audit, frontend telemetry) from core into modules/audit/.
Core decoupling via interface-based injection:
- AuditRecorderInterface replaces SystemAuditService in 10+ core services
- UserLifecycleAuditInterface / ImportAuditInterface for specialized flows
- NullAuditRecorder fallback when audit module is disabled
- ApiBootstrap/ApiResponse use null-safe callable resolvers
Module structure (modules/audit/):
- Manifest with routes, permissions, scheduler jobs, authorization policy
- 9 services, 8 repositories, 6 domain enums, 4 job handlers
- 33 page files, 4 JS files, 8 test files, migration scripts, i18n
Core cleanup:
- OperationsAuthorizationPolicy, UiCapabilityMap, PermissionService
surgically cleaned of audit-specific constants
- Sidebar template cleared of hardcoded audit navigation
- AuditModuleIsolationContractTest ensures no future core→module coupling
All quality gates pass: 1346 tests (19,276 assertions), PHPStan level 5
clean, architecture contracts verified.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 21:12:49 +01:00
|
|
|
$auditService = $this->createMock(AuditRecorderInterface::class);
|
2026-03-19 20:28:04 +01:00
|
|
|
$auditService->expects($this->never())->method('record');
|
|
|
|
|
|
|
|
|
|
$service = $this->newService($readRepository, $writeRepository, null, $assignmentService, null, null, null, null, $auditService);
|
|
|
|
|
$result = $service->setActiveByUuid('u-33', true, 90);
|
|
|
|
|
|
|
|
|
|
$this->assertFalse($result['ok']);
|
|
|
|
|
$this->assertSame(500, (int) ($result['status'] ?? 0));
|
|
|
|
|
$this->assertSame('update_failed', (string) ($result['error'] ?? ''));
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-06 00:44:52 +01:00
|
|
|
public function testSetActiveByUuidUpdatesAssignmentsAndAudit(): void
|
|
|
|
|
{
|
|
|
|
|
$readRepository = $this->createMock(UserReadRepositoryInterface::class);
|
|
|
|
|
$readRepository->method('findByUuid')->willReturn(['id' => 33, 'uuid' => 'u-33', 'active' => 0]);
|
|
|
|
|
|
|
|
|
|
$writeRepository = $this->createMock(UserWriteRepositoryInterface::class);
|
|
|
|
|
$writeRepository->expects($this->once())
|
|
|
|
|
->method('setActive')
|
|
|
|
|
->with(33, true, 90)
|
|
|
|
|
->willReturn(true);
|
|
|
|
|
|
|
|
|
|
$assignmentService = $this->createMock(UserAssignmentService::class);
|
|
|
|
|
$assignmentService->expects($this->once())
|
|
|
|
|
->method('bumpAuthzVersion')
|
|
|
|
|
->with(33);
|
|
|
|
|
|
refactor(audit): extract audit domain into self-contained module
Move the entire audit subsystem (system audit, API audit, import audit,
user lifecycle audit, frontend telemetry) from core into modules/audit/.
Core decoupling via interface-based injection:
- AuditRecorderInterface replaces SystemAuditService in 10+ core services
- UserLifecycleAuditInterface / ImportAuditInterface for specialized flows
- NullAuditRecorder fallback when audit module is disabled
- ApiBootstrap/ApiResponse use null-safe callable resolvers
Module structure (modules/audit/):
- Manifest with routes, permissions, scheduler jobs, authorization policy
- 9 services, 8 repositories, 6 domain enums, 4 job handlers
- 33 page files, 4 JS files, 8 test files, migration scripts, i18n
Core cleanup:
- OperationsAuthorizationPolicy, UiCapabilityMap, PermissionService
surgically cleaned of audit-specific constants
- Sidebar template cleared of hardcoded audit navigation
- AuditModuleIsolationContractTest ensures no future core→module coupling
All quality gates pass: 1346 tests (19,276 assertions), PHPStan level 5
clean, architecture contracts verified.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 21:12:49 +01:00
|
|
|
$auditService = $this->createMock(AuditRecorderInterface::class);
|
2026-03-06 00:44:52 +01:00
|
|
|
$auditService->expects($this->once())
|
|
|
|
|
->method('record')
|
|
|
|
|
->with(
|
|
|
|
|
'admin.users.activate',
|
|
|
|
|
'success',
|
|
|
|
|
$this->callback(static fn ($context): bool => is_array($context))
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
$service = $this->newService($readRepository, $writeRepository, null, $assignmentService, null, null, null, null, $auditService);
|
|
|
|
|
$result = $service->setActiveByUuid('u-33', true, 90);
|
|
|
|
|
|
|
|
|
|
$this->assertTrue($result['ok']);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function testSetActiveByUuidsBumpsAuthzVersionForResolvedUserIds(): void
|
|
|
|
|
{
|
|
|
|
|
$writeRepository = $this->createMock(UserWriteRepositoryInterface::class);
|
|
|
|
|
$writeRepository->expects($this->once())
|
|
|
|
|
->method('setActiveByUuids')
|
|
|
|
|
->with(['u-1', 'u-2'], false, 7)
|
|
|
|
|
->willReturn(true);
|
|
|
|
|
$writeRepository->expects($this->once())
|
|
|
|
|
->method('bumpAuthzVersionByUserIds')
|
|
|
|
|
->with([11, 12])
|
|
|
|
|
->willReturn(2);
|
|
|
|
|
|
|
|
|
|
$readRepository = $this->createMock(UserReadRepositoryInterface::class);
|
|
|
|
|
$readRepository->method('findByUuid')->willReturnMap([
|
|
|
|
|
['u-1', ['id' => 11, 'uuid' => 'u-1']],
|
|
|
|
|
['u-2', ['id' => 12, 'uuid' => 'u-2']],
|
|
|
|
|
]);
|
|
|
|
|
|
2026-03-13 11:31:33 +01:00
|
|
|
$scopeGateway = $this->createMock(TenantScopeService::class);
|
2026-03-06 00:44:52 +01:00
|
|
|
$scopeGateway->method('canAccess')->willReturn(true);
|
|
|
|
|
|
refactor(audit): extract audit domain into self-contained module
Move the entire audit subsystem (system audit, API audit, import audit,
user lifecycle audit, frontend telemetry) from core into modules/audit/.
Core decoupling via interface-based injection:
- AuditRecorderInterface replaces SystemAuditService in 10+ core services
- UserLifecycleAuditInterface / ImportAuditInterface for specialized flows
- NullAuditRecorder fallback when audit module is disabled
- ApiBootstrap/ApiResponse use null-safe callable resolvers
Module structure (modules/audit/):
- Manifest with routes, permissions, scheduler jobs, authorization policy
- 9 services, 8 repositories, 6 domain enums, 4 job handlers
- 33 page files, 4 JS files, 8 test files, migration scripts, i18n
Core cleanup:
- OperationsAuthorizationPolicy, UiCapabilityMap, PermissionService
surgically cleaned of audit-specific constants
- Sidebar template cleared of hardcoded audit navigation
- AuditModuleIsolationContractTest ensures no future core→module coupling
All quality gates pass: 1346 tests (19,276 assertions), PHPStan level 5
clean, architecture contracts verified.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 21:12:49 +01:00
|
|
|
$auditService = $this->createMock(AuditRecorderInterface::class);
|
2026-03-06 00:44:52 +01:00
|
|
|
$auditService->expects($this->once())
|
|
|
|
|
->method('record')
|
|
|
|
|
->with(
|
|
|
|
|
'admin.users.bulk_update',
|
|
|
|
|
'success',
|
|
|
|
|
$this->callback(static fn ($context): bool => is_array($context))
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
$service = $this->newService($readRepository, $writeRepository, null, null, null, null, $scopeGateway, null, $auditService);
|
|
|
|
|
$result = $service->setActiveByUuids(['u-1', 'u-2'], false, 7);
|
|
|
|
|
|
|
|
|
|
$this->assertTrue($result['ok']);
|
|
|
|
|
$this->assertSame(2, (int) ($result['count'] ?? 0));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function testRegisterCreatesUserAndAssignsDefaultEntities(): void
|
|
|
|
|
{
|
|
|
|
|
$passwordService = $this->createMock(UserPasswordService::class);
|
|
|
|
|
$passwordService->method('validatePassword')->willReturn([]);
|
|
|
|
|
|
|
|
|
|
$settingsGateway = $this->createMock(UserSettingsGateway::class);
|
|
|
|
|
$settingsGateway->method('getDefaultTenantId')->willReturn(3);
|
|
|
|
|
$settingsGateway->method('getDefaultRoleId')->willReturn(4);
|
|
|
|
|
$settingsGateway->method('getDefaultDepartmentId')->willReturn(5);
|
2026-03-19 20:28:04 +01:00
|
|
|
$settingsGateway->method('normalizeTheme')
|
|
|
|
|
->willReturnCallback(static fn (?string $theme): string => $theme ?? 'light');
|
2026-03-06 00:44:52 +01:00
|
|
|
|
|
|
|
|
$writeRepository = $this->createMock(UserWriteRepositoryInterface::class);
|
|
|
|
|
$writeRepository->expects($this->once())
|
|
|
|
|
->method('create')
|
|
|
|
|
->with($this->callback(static function (array $data): bool {
|
refactor(settings): remove global appearance settings — tenant is sole source
Removes the global app_theme, app_theme_user and app_primary_color settings
from the admin/settings area and the underlying service/cache/API/i18n/docs
layers. The tenant columns (tenants.primary_color, default_theme,
allow_user_theme) become the single source of truth for appearance.
Branding helpers are tenant-only and fall back to a hardcoded 'light' theme
with no brand color when no tenant context is available (login, error,
pre-session pages). The APP_THEME env var is removed.
Scope:
- UI: Appearance tab gone from /admin/settings; tenant edit form drops the
global-fallback color preview.
- Service: SettingsAppGateway no longer exposes setting-backed accessors
for theme/user-theme/primary-color. Theme catalog utilities (listThemes,
isAllowedTheme, normalizeTheme, resolveDefaultTheme) stay and are used
across Tenant / Auth / User flows; resolveDefaultTheme now hardcodes
'light' with a catalog fallback (no global/env lookup).
- AdminSettingsService: buildPageData, sanitization, audit snapshot, apply
step and cache payload are all stripped of the three keys. Dead helper
applyAppPrimaryColor + normalizePrimaryColor removed.
- Cache: SettingCacheService HOT_PATH_KEYS drops the three keys.
- API: /settings (GET/PUT) and /settings/public (GET) no longer expose
appearance fields; OpenAPI schemas pruned accordingly.
- Audit redaction list shortened.
- DB: db/init/init.sql seed rows removed. No migration for existing
installs — legacy rows stay inert.
- i18n + docs: setting.app_theme, setting.app_theme_user,
setting.app_primary_color removed from de+en locales; reference and
explanation docs updated.
- Tests: covering tests for the removed methods pruned; ThemeResolutionTest
rewritten for tenant-only semantics. One unrelated PHP-CS-Fixer issue in
UserRegistrar.php cleaned up to keep QG-006 green.
Workflow: .agents/runs/SETTINGS-APPEARANCE-TENANT-ONLY/ (gitignored).
Gates: QG-001..003, QG-006, QG-008 all pass (1985 tests, 28764 assertions).
Reviews: code, security, acceptance all pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 22:40:15 +02:00
|
|
|
// New users created via register() get the default 'light' theme.
|
|
|
|
|
// Appearance is tenant-scoped, not globally configurable, so
|
|
|
|
|
// normalizeTheme(null) resolves to 'light'.
|
2026-03-06 00:44:52 +01:00
|
|
|
return (string) ($data['email'] ?? '') === 'user@example.com'
|
refactor(settings): remove global appearance settings — tenant is sole source
Removes the global app_theme, app_theme_user and app_primary_color settings
from the admin/settings area and the underlying service/cache/API/i18n/docs
layers. The tenant columns (tenants.primary_color, default_theme,
allow_user_theme) become the single source of truth for appearance.
Branding helpers are tenant-only and fall back to a hardcoded 'light' theme
with no brand color when no tenant context is available (login, error,
pre-session pages). The APP_THEME env var is removed.
Scope:
- UI: Appearance tab gone from /admin/settings; tenant edit form drops the
global-fallback color preview.
- Service: SettingsAppGateway no longer exposes setting-backed accessors
for theme/user-theme/primary-color. Theme catalog utilities (listThemes,
isAllowedTheme, normalizeTheme, resolveDefaultTheme) stay and are used
across Tenant / Auth / User flows; resolveDefaultTheme now hardcodes
'light' with a catalog fallback (no global/env lookup).
- AdminSettingsService: buildPageData, sanitization, audit snapshot, apply
step and cache payload are all stripped of the three keys. Dead helper
applyAppPrimaryColor + normalizePrimaryColor removed.
- Cache: SettingCacheService HOT_PATH_KEYS drops the three keys.
- API: /settings (GET/PUT) and /settings/public (GET) no longer expose
appearance fields; OpenAPI schemas pruned accordingly.
- Audit redaction list shortened.
- DB: db/init/init.sql seed rows removed. No migration for existing
installs — legacy rows stay inert.
- i18n + docs: setting.app_theme, setting.app_theme_user,
setting.app_primary_color removed from de+en locales; reference and
explanation docs updated.
- Tests: covering tests for the removed methods pruned; ThemeResolutionTest
rewritten for tenant-only semantics. One unrelated PHP-CS-Fixer issue in
UserRegistrar.php cleaned up to keep QG-006 green.
Workflow: .agents/runs/SETTINGS-APPEARANCE-TENANT-ONLY/ (gitignored).
Gates: QG-001..003, QG-006, QG-008 all pass (1985 tests, 28764 assertions).
Reviews: code, security, acceptance all pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 22:40:15 +02:00
|
|
|
&& (string) ($data['theme'] ?? '') === 'light'
|
2026-03-06 00:44:52 +01:00
|
|
|
&& (int) ($data['primary_tenant_id'] ?? 0) === 3
|
|
|
|
|
&& (int) ($data['active'] ?? 0) === 1;
|
|
|
|
|
}))
|
|
|
|
|
->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);
|
2026-03-09 19:54:58 +01:00
|
|
|
$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);
|
2026-03-06 00:44:52 +01:00
|
|
|
|
|
|
|
|
$service = $this->newService(
|
|
|
|
|
$readRepository,
|
|
|
|
|
$writeRepository,
|
|
|
|
|
null,
|
|
|
|
|
$assignmentService,
|
|
|
|
|
$passwordService,
|
|
|
|
|
$settingsGateway
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
$result = $service->register([
|
|
|
|
|
'first_name' => 'Max',
|
|
|
|
|
'last_name' => 'Mustermann',
|
|
|
|
|
'email' => 'user@example.com',
|
|
|
|
|
'password' => 'StrongPass1!',
|
|
|
|
|
'password2' => 'StrongPass1!',
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$this->assertTrue($result['ok']);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-09 19:54:58 +01:00
|
|
|
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);
|
2026-03-19 20:28:04 +01:00
|
|
|
$settingsGateway->method('normalizeTheme')
|
|
|
|
|
->willReturnCallback(static fn (?string $theme): string => $theme ?? 'light');
|
2026-03-09 19:54:58 +01:00
|
|
|
|
|
|
|
|
$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');
|
|
|
|
|
|
refactor(audit): extract audit domain into self-contained module
Move the entire audit subsystem (system audit, API audit, import audit,
user lifecycle audit, frontend telemetry) from core into modules/audit/.
Core decoupling via interface-based injection:
- AuditRecorderInterface replaces SystemAuditService in 10+ core services
- UserLifecycleAuditInterface / ImportAuditInterface for specialized flows
- NullAuditRecorder fallback when audit module is disabled
- ApiBootstrap/ApiResponse use null-safe callable resolvers
Module structure (modules/audit/):
- Manifest with routes, permissions, scheduler jobs, authorization policy
- 9 services, 8 repositories, 6 domain enums, 4 job handlers
- 33 page files, 4 JS files, 8 test files, migration scripts, i18n
Core cleanup:
- OperationsAuthorizationPolicy, UiCapabilityMap, PermissionService
surgically cleaned of audit-specific constants
- Sidebar template cleared of hardcoded audit navigation
- AuditModuleIsolationContractTest ensures no future core→module coupling
All quality gates pass: 1346 tests (19,276 assertions), PHPStan level 5
clean, architecture contracts verified.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 21:12:49 +01:00
|
|
|
$auditService = $this->createMock(AuditRecorderInterface::class);
|
2026-03-09 19:54:58 +01:00
|
|
|
$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('getDefaultTenantId')->willReturn(3);
|
|
|
|
|
$settingsGateway->method('getDefaultRoleId')->willReturn(4);
|
|
|
|
|
$settingsGateway->method('getDefaultDepartmentId')->willReturn(5);
|
2026-03-19 20:28:04 +01:00
|
|
|
$settingsGateway->method('normalizeTheme')
|
|
|
|
|
->willReturnCallback(static fn (?string $theme): string => $theme ?? 'light');
|
2026-03-09 19:54:58 +01:00
|
|
|
|
|
|
|
|
$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'] ?? '')));
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-06 00:44:52 +01:00
|
|
|
public function testUpdateSelfProfileReturnsErrorWhenRequestedTenantIsNotAssigned(): void
|
|
|
|
|
{
|
|
|
|
|
$readRepository = $this->createMock(UserReadRepositoryInterface::class);
|
|
|
|
|
$readRepository->expects($this->once())
|
|
|
|
|
->method('find')
|
|
|
|
|
->with(15)
|
|
|
|
|
->willReturn([
|
|
|
|
|
'id' => 15,
|
|
|
|
|
'first_name' => 'Max',
|
|
|
|
|
'last_name' => 'Mustermann',
|
|
|
|
|
'locale' => 'de',
|
|
|
|
|
'theme' => 'light',
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$directoryGateway = $this->createMock(UserDirectoryGateway::class);
|
|
|
|
|
$directoryGateway->expects($this->once())
|
|
|
|
|
->method('findTenantByUuid')
|
|
|
|
|
->with('tenant-uuid-2')
|
|
|
|
|
->willReturn(['id' => 2, 'uuid' => 'tenant-uuid-2']);
|
|
|
|
|
|
|
|
|
|
$assignmentService = $this->createMock(UserAssignmentService::class);
|
|
|
|
|
$assignmentService->expects($this->once())
|
|
|
|
|
->method('buildAssignmentsForUser')
|
|
|
|
|
->with(15)
|
|
|
|
|
->willReturn([
|
|
|
|
|
'tenants' => [['id' => 1, 'uuid' => 'tenant-uuid-1']],
|
|
|
|
|
'roles' => [],
|
|
|
|
|
'departments' => [],
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$writeRepository = $this->createMock(UserWriteRepositoryInterface::class);
|
|
|
|
|
$writeRepository->expects($this->never())->method('update');
|
|
|
|
|
|
|
|
|
|
$service = $this->newService(
|
|
|
|
|
$readRepository,
|
|
|
|
|
$writeRepository,
|
|
|
|
|
null,
|
|
|
|
|
$assignmentService,
|
|
|
|
|
null,
|
|
|
|
|
null,
|
|
|
|
|
null,
|
|
|
|
|
$directoryGateway
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
$result = $service->updateSelfProfile(15, [
|
|
|
|
|
'first_name' => 'Max',
|
|
|
|
|
'last_name' => 'Mustermann',
|
|
|
|
|
'current_tenant_uuid' => 'tenant-uuid-2',
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$this->assertFalse($result['ok']);
|
|
|
|
|
$this->assertNotEmpty($result['errors']);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function testUpdateSelfProfilePersistsDataAndTenantSwitchWhenAllowed(): void
|
|
|
|
|
{
|
|
|
|
|
$readRepository = $this->createMock(UserReadRepositoryInterface::class);
|
|
|
|
|
$readRepository->expects($this->once())
|
|
|
|
|
->method('find')
|
|
|
|
|
->with(21)
|
|
|
|
|
->willReturn([
|
|
|
|
|
'id' => 21,
|
|
|
|
|
'first_name' => 'Max',
|
|
|
|
|
'last_name' => 'Mustermann',
|
|
|
|
|
'profile_description' => '',
|
|
|
|
|
'job_title' => '',
|
|
|
|
|
'phone' => '',
|
|
|
|
|
'mobile' => '',
|
|
|
|
|
'short_dial' => '',
|
|
|
|
|
'address' => '',
|
|
|
|
|
'postal_code' => '',
|
|
|
|
|
'city' => '',
|
|
|
|
|
'country' => '',
|
|
|
|
|
'region' => '',
|
|
|
|
|
'locale' => 'de',
|
|
|
|
|
'theme' => 'light',
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$directoryGateway = $this->createMock(UserDirectoryGateway::class);
|
|
|
|
|
$directoryGateway->expects($this->once())
|
|
|
|
|
->method('findTenantByUuid')
|
|
|
|
|
->with('tenant-uuid-9')
|
|
|
|
|
->willReturn(['id' => 9, 'uuid' => 'tenant-uuid-9']);
|
|
|
|
|
|
|
|
|
|
$assignmentService = $this->createMock(UserAssignmentService::class);
|
|
|
|
|
$assignmentService->expects($this->once())
|
|
|
|
|
->method('buildAssignmentsForUser')
|
|
|
|
|
->with(21)
|
|
|
|
|
->willReturn([
|
|
|
|
|
'tenants' => [['id' => 9, 'uuid' => 'tenant-uuid-9']],
|
|
|
|
|
'roles' => [],
|
|
|
|
|
'departments' => [],
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$writeRepository = $this->createMock(UserWriteRepositoryInterface::class);
|
|
|
|
|
$writeRepository->expects($this->once())
|
|
|
|
|
->method('update')
|
|
|
|
|
->with(21, $this->callback(static fn ($payload): bool => is_array($payload)))
|
|
|
|
|
->willReturn(true);
|
|
|
|
|
$writeRepository->expects($this->once())
|
|
|
|
|
->method('setCurrentTenant')
|
|
|
|
|
->with(21, 9)
|
|
|
|
|
->willReturn(true);
|
|
|
|
|
|
|
|
|
|
$service = $this->newService(
|
|
|
|
|
$readRepository,
|
|
|
|
|
$writeRepository,
|
|
|
|
|
null,
|
|
|
|
|
$assignmentService,
|
|
|
|
|
null,
|
|
|
|
|
null,
|
|
|
|
|
null,
|
|
|
|
|
$directoryGateway
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
$result = $service->updateSelfProfile(21, [
|
|
|
|
|
'first_name' => 'Max',
|
|
|
|
|
'last_name' => 'Mustermann',
|
|
|
|
|
'current_tenant_uuid' => 'tenant-uuid-9',
|
|
|
|
|
'locale' => 'de',
|
|
|
|
|
'theme' => 'light',
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$this->assertTrue($result['ok']);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private function newService(
|
|
|
|
|
?UserReadRepositoryInterface $readRepository = null,
|
|
|
|
|
?UserWriteRepositoryInterface $writeRepository = null,
|
|
|
|
|
?UserListQueryRepositoryInterface $listQueryRepository = null,
|
|
|
|
|
?UserAssignmentService $assignmentService = null,
|
|
|
|
|
?UserPasswordService $passwordService = null,
|
|
|
|
|
?UserSettingsGateway $settingsGateway = null,
|
2026-03-13 11:31:33 +01:00
|
|
|
?TenantScopeService $scopeGateway = null,
|
2026-03-06 00:44:52 +01:00
|
|
|
?UserDirectoryGateway $directoryGateway = null,
|
refactor(audit): extract audit domain into self-contained module
Move the entire audit subsystem (system audit, API audit, import audit,
user lifecycle audit, frontend telemetry) from core into modules/audit/.
Core decoupling via interface-based injection:
- AuditRecorderInterface replaces SystemAuditService in 10+ core services
- UserLifecycleAuditInterface / ImportAuditInterface for specialized flows
- NullAuditRecorder fallback when audit module is disabled
- ApiBootstrap/ApiResponse use null-safe callable resolvers
Module structure (modules/audit/):
- Manifest with routes, permissions, scheduler jobs, authorization policy
- 9 services, 8 repositories, 6 domain enums, 4 job handlers
- 33 page files, 4 JS files, 8 test files, migration scripts, i18n
Core cleanup:
- OperationsAuthorizationPolicy, UiCapabilityMap, PermissionService
surgically cleaned of audit-specific constants
- Sidebar template cleared of hardcoded audit navigation
- AuditModuleIsolationContractTest ensures no future core→module coupling
All quality gates pass: 1346 tests (19,276 assertions), PHPStan level 5
clean, architecture contracts verified.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 21:12:49 +01:00
|
|
|
?AuditRecorderInterface $auditService = null,
|
2026-03-09 19:16:26 +01:00
|
|
|
?DatabaseSessionRepository $databaseSessionRepository = null
|
2026-03-06 00:44:52 +01:00
|
|
|
): UserAccountService {
|
|
|
|
|
$readRepository = $readRepository ?? $this->createMock(UserReadRepositoryInterface::class);
|
|
|
|
|
$writeRepository = $writeRepository ?? $this->createMock(UserWriteRepositoryInterface::class);
|
|
|
|
|
$listQueryRepository = $listQueryRepository ?? $this->createMock(UserListQueryRepositoryInterface::class);
|
|
|
|
|
|
|
|
|
|
if ($assignmentService === null) {
|
|
|
|
|
$assignmentService = $this->createMock(UserAssignmentService::class);
|
|
|
|
|
$assignmentService->method('normalizeTenantIds')->willReturn([]);
|
|
|
|
|
$assignmentService->method('normalizeIdInput')->willReturn([]);
|
|
|
|
|
$assignmentService->method('buildAssignmentsForUser')->willReturn([
|
|
|
|
|
'tenants' => [],
|
|
|
|
|
'roles' => [],
|
|
|
|
|
'departments' => [],
|
|
|
|
|
]);
|
|
|
|
|
$assignmentService->method('syncTenants')->willReturn(true);
|
|
|
|
|
$assignmentService->method('syncRoles')->willReturn(true);
|
|
|
|
|
$assignmentService->method('syncDepartments')->willReturn(true);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if ($passwordService === null) {
|
|
|
|
|
$passwordService = $this->createMock(UserPasswordService::class);
|
|
|
|
|
$passwordService->method('validatePassword')->willReturn([]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if ($settingsGateway === null) {
|
|
|
|
|
$settingsGateway = $this->createMock(UserSettingsGateway::class);
|
|
|
|
|
$settingsGateway->method('getDefaultTenantId')->willReturn(null);
|
|
|
|
|
$settingsGateway->method('getDefaultRoleId')->willReturn(null);
|
|
|
|
|
$settingsGateway->method('getDefaultDepartmentId')->willReturn(null);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if ($scopeGateway === null) {
|
2026-03-13 11:31:33 +01:00
|
|
|
$scopeGateway = $this->createMock(TenantScopeService::class);
|
2026-03-06 00:44:52 +01:00
|
|
|
$scopeGateway->method('hasGlobalAccess')->willReturn(false);
|
|
|
|
|
$scopeGateway->method('canAccess')->willReturn(false);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if ($directoryGateway === null) {
|
|
|
|
|
$directoryGateway = $this->createMock(UserDirectoryGateway::class);
|
|
|
|
|
$directoryGateway->method('findTenantByUuid')->willReturn(null);
|
|
|
|
|
}
|
|
|
|
|
|
refactor(audit): extract audit domain into self-contained module
Move the entire audit subsystem (system audit, API audit, import audit,
user lifecycle audit, frontend telemetry) from core into modules/audit/.
Core decoupling via interface-based injection:
- AuditRecorderInterface replaces SystemAuditService in 10+ core services
- UserLifecycleAuditInterface / ImportAuditInterface for specialized flows
- NullAuditRecorder fallback when audit module is disabled
- ApiBootstrap/ApiResponse use null-safe callable resolvers
Module structure (modules/audit/):
- Manifest with routes, permissions, scheduler jobs, authorization policy
- 9 services, 8 repositories, 6 domain enums, 4 job handlers
- 33 page files, 4 JS files, 8 test files, migration scripts, i18n
Core cleanup:
- OperationsAuthorizationPolicy, UiCapabilityMap, PermissionService
surgically cleaned of audit-specific constants
- Sidebar template cleared of hardcoded audit navigation
- AuditModuleIsolationContractTest ensures no future core→module coupling
All quality gates pass: 1346 tests (19,276 assertions), PHPStan level 5
clean, architecture contracts verified.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 21:12:49 +01:00
|
|
|
$auditService = $auditService ?? $this->createMock(AuditRecorderInterface::class);
|
2026-03-09 19:16:26 +01:00
|
|
|
$databaseSessionRepository = $databaseSessionRepository ?? $this->createMock(DatabaseSessionRepository::class);
|
2026-03-06 00:44:52 +01:00
|
|
|
|
|
|
|
|
return new UserAccountService(
|
|
|
|
|
$readRepository,
|
|
|
|
|
$writeRepository,
|
|
|
|
|
$listQueryRepository,
|
|
|
|
|
$assignmentService,
|
|
|
|
|
$passwordService,
|
|
|
|
|
$settingsGateway,
|
|
|
|
|
$scopeGateway,
|
|
|
|
|
$directoryGateway,
|
2026-03-09 19:16:26 +01:00
|
|
|
$auditService,
|
|
|
|
|
$databaseSessionRepository
|
2026-03-06 00:44:52 +01:00
|
|
|
);
|
|
|
|
|
}
|
2026-03-19 20:28:04 +01:00
|
|
|
|
|
|
|
|
public static function missingDeleteByUuidInputProvider(): array
|
|
|
|
|
{
|
|
|
|
|
return [
|
|
|
|
|
'empty uuid' => ['', null],
|
|
|
|
|
'missing user' => ['missing-uuid', null],
|
|
|
|
|
];
|
|
|
|
|
}
|
2026-03-06 00:44:52 +01:00
|
|
|
}
|