1, 'restored_at' => null, 'snapshot_enc' => 'enc-data', 'policy_deactivate_days' => 30, 'policy_delete_days' => 90, ]; $snapshot = [ 'uuid' => 'restored-uuid', 'email' => 'restored@example.com', 'first_name' => 'Max', 'last_name' => 'Mustermann', 'theme' => 'dark', ]; $auditService = $this->createMock(UserLifecycleAuditService::class); $auditService->expects($this->once()) ->method('findDeleteEventForRestore') ->with(1, true) ->willReturn($event); $auditService->expects($this->once()) ->method('decryptSnapshot') ->with($event) ->willReturn($snapshot); $auditService->expects($this->once()) ->method('markDeleteEventRestored') ->with(1, 99, 77) ->willReturn(true); $auditService->expects($this->once()) ->method('logRestore') ->with( $this->isString(), 'manual', $this->callback(static fn (array $policy): bool => $policy['deactivate_days'] === 30 && $policy['delete_days'] === 90), 99, $this->callback(static fn (array $user): bool => $user['id'] === 77 && $user['uuid'] === 'restored-uuid' && $user['email'] === 'restored@example.com'), 'success', null ); $userReadRepo = $this->createMock(UserReadRepositoryInterface::class); $userReadRepo->expects($this->once())->method('findByUuid')->with('restored-uuid')->willReturn(null); $userReadRepo->expects($this->once())->method('findByEmail')->with('restored@example.com')->willReturn(null); $userWriteRepo = $this->createMock(UserWriteRepositoryInterface::class); $userWriteRepo->expects($this->once()) ->method('create') ->with($this->callback(static function (array $data): bool { return $data['uuid'] === 'restored-uuid' && $data['email'] === 'restored@example.com' && $data['first_name'] === 'Max' && $data['last_name'] === 'Mustermann' && $data['active'] === 0 && $data['created_by'] === 99 && $data['primary_tenant_id'] === null && $data['current_tenant_id'] === null && $data['totp_secret'] === ''; })) ->willReturn(77); $dbSession = $this->createMock(DatabaseSessionRepository::class); $dbSession->expects($this->once())->method('beginTransaction'); $dbSession->expects($this->once())->method('commitTransaction'); $dbSession->expects($this->never())->method('rollbackTransaction'); $settingsGateway = $this->createMock(UserSettingsGateway::class); $settingsGateway->method('normalizeTheme')->willReturn('dark'); $service = $this->newService( userReadRepository: $userReadRepo, userWriteRepository: $userWriteRepo, userLifecycleAuditService: $auditService, databaseSessionRepository: $dbSession, userSettingsGateway: $settingsGateway ); $result = $service->restoreFromLifecycleAudit(1, 99); $this->assertTrue($result['ok']); $this->assertSame(77, $result['restored_user_id']); $this->assertSame('restored-uuid', $result['restored_user_uuid']); $this->assertSame(1, $result['audit_id']); } // ── invalid_request ───────────────────────────────────────────────── public function testRestoreReturnsInvalidRequestWhenAuditIdIsZero(): void { $service = $this->newService(); $result = $service->restoreFromLifecycleAudit(0, 99); $this->assertFalse($result['ok']); $this->assertSame('invalid_request', $result['error']); } public function testRestoreReturnsInvalidRequestWhenAuditIdIsNegative(): void { $service = $this->newService(); $result = $service->restoreFromLifecycleAudit(-1, 99); $this->assertFalse($result['ok']); $this->assertSame('invalid_request', $result['error']); } public function testRestoreReturnsInvalidRequestWhenActorUserIdIsZero(): void { $service = $this->newService(); $result = $service->restoreFromLifecycleAudit(1, 0); $this->assertFalse($result['ok']); $this->assertSame('invalid_request', $result['error']); } public function testRestoreReturnsInvalidRequestWhenActorUserIdIsNegative(): void { $service = $this->newService(); $result = $service->restoreFromLifecycleAudit(1, -5); $this->assertFalse($result['ok']); $this->assertSame('invalid_request', $result['error']); } // ── audit_event_not_found ─────────────────────────────────────────── public function testRestoreReturnsAuditEventNotFoundWhenEventIsNull(): void { $auditService = $this->createMock(UserLifecycleAuditService::class); $auditService->expects($this->once()) ->method('findDeleteEventForRestore') ->with(1, true) ->willReturn(null); $dbSession = $this->createMock(DatabaseSessionRepository::class); $dbSession->expects($this->once())->method('beginTransaction'); $dbSession->expects($this->never())->method('commitTransaction'); $dbSession->expects($this->once())->method('rollbackTransaction'); $service = $this->newService( userLifecycleAuditService: $auditService, databaseSessionRepository: $dbSession ); $result = $service->restoreFromLifecycleAudit(1, 99); $this->assertFalse($result['ok']); $this->assertSame('audit_event_not_found', $result['error']); } // ── audit_event_already_restored ──────────────────────────────────── public function testRestoreReturnsAlreadyRestoredWhenEventHasRestoredAt(): void { $auditService = $this->createMock(UserLifecycleAuditService::class); $auditService->method('findDeleteEventForRestore')->willReturn([ 'id' => 1, 'restored_at' => '2025-01-01 00:00:00', ]); $dbSession = $this->createMock(DatabaseSessionRepository::class); $dbSession->expects($this->once())->method('rollbackTransaction'); $service = $this->newService( userLifecycleAuditService: $auditService, databaseSessionRepository: $dbSession ); $result = $service->restoreFromLifecycleAudit(1, 99); $this->assertFalse($result['ok']); $this->assertSame('audit_event_already_restored', $result['error']); } // ── snapshot_unavailable ──────────────────────────────────────────── public function testRestoreReturnsSnapshotUnavailableWhenDecryptReturnsNull(): void { $auditService = $this->createMock(UserLifecycleAuditService::class); $auditService->method('findDeleteEventForRestore')->willReturn([ 'id' => 1, 'restored_at' => null, ]); $auditService->method('decryptSnapshot')->willReturn(null); $dbSession = $this->createMock(DatabaseSessionRepository::class); $dbSession->expects($this->once())->method('rollbackTransaction'); $service = $this->newService( userLifecycleAuditService: $auditService, databaseSessionRepository: $dbSession ); $result = $service->restoreFromLifecycleAudit(1, 99); $this->assertFalse($result['ok']); $this->assertSame('snapshot_unavailable', $result['error']); } // ── snapshot_invalid ──────────────────────────────────────────────── public function testRestoreReturnsSnapshotInvalidWhenUuidIsMissing(): void { $auditService = $this->createMock(UserLifecycleAuditService::class); $auditService->method('findDeleteEventForRestore')->willReturn(['id' => 1, 'restored_at' => null]); $auditService->method('decryptSnapshot')->willReturn(['uuid' => '', 'email' => 'a@b.com']); $dbSession = $this->createMock(DatabaseSessionRepository::class); $dbSession->expects($this->once())->method('rollbackTransaction'); $service = $this->newService( userLifecycleAuditService: $auditService, databaseSessionRepository: $dbSession ); $result = $service->restoreFromLifecycleAudit(1, 99); $this->assertFalse($result['ok']); $this->assertSame('snapshot_invalid', $result['error']); } public function testRestoreReturnsSnapshotInvalidWhenEmailIsMissing(): void { $auditService = $this->createMock(UserLifecycleAuditService::class); $auditService->method('findDeleteEventForRestore')->willReturn(['id' => 1, 'restored_at' => null]); $auditService->method('decryptSnapshot')->willReturn(['uuid' => 'u1', 'email' => '']); $dbSession = $this->createMock(DatabaseSessionRepository::class); $dbSession->expects($this->once())->method('rollbackTransaction'); $service = $this->newService( userLifecycleAuditService: $auditService, databaseSessionRepository: $dbSession ); $result = $service->restoreFromLifecycleAudit(1, 99); $this->assertFalse($result['ok']); $this->assertSame('snapshot_invalid', $result['error']); } // ── restore_uuid_exists ───────────────────────────────────────────── public function testRestoreReturnsUuidExistsWhenUserWithUuidAlreadyExists(): void { $auditService = $this->createMock(UserLifecycleAuditService::class); $auditService->method('findDeleteEventForRestore')->willReturn(['id' => 1, 'restored_at' => null]); $auditService->method('decryptSnapshot')->willReturn(['uuid' => 'existing-uuid', 'email' => 'new@example.com']); $userReadRepo = $this->createMock(UserReadRepositoryInterface::class); $userReadRepo->expects($this->once())->method('findByUuid')->with('existing-uuid') ->willReturn(['id' => 50, 'uuid' => 'existing-uuid']); $dbSession = $this->createMock(DatabaseSessionRepository::class); $dbSession->expects($this->once())->method('rollbackTransaction'); $service = $this->newService( userReadRepository: $userReadRepo, userLifecycleAuditService: $auditService, databaseSessionRepository: $dbSession ); $result = $service->restoreFromLifecycleAudit(1, 99); $this->assertFalse($result['ok']); $this->assertSame('restore_uuid_exists', $result['error']); } // ── restore_email_exists ──────────────────────────────────────────── public function testRestoreReturnsEmailExistsWhenUserWithEmailAlreadyExists(): void { $auditService = $this->createMock(UserLifecycleAuditService::class); $auditService->method('findDeleteEventForRestore')->willReturn(['id' => 1, 'restored_at' => null]); $auditService->method('decryptSnapshot')->willReturn(['uuid' => 'new-uuid', 'email' => 'existing@example.com']); $userReadRepo = $this->createMock(UserReadRepositoryInterface::class); $userReadRepo->method('findByUuid')->willReturn(null); $userReadRepo->expects($this->once())->method('findByEmail')->with('existing@example.com') ->willReturn(['id' => 60, 'email' => 'existing@example.com']); $dbSession = $this->createMock(DatabaseSessionRepository::class); $dbSession->expects($this->once())->method('rollbackTransaction'); $service = $this->newService( userReadRepository: $userReadRepo, userLifecycleAuditService: $auditService, databaseSessionRepository: $dbSession ); $result = $service->restoreFromLifecycleAudit(1, 99); $this->assertFalse($result['ok']); $this->assertSame('restore_email_exists', $result['error']); } // ── restore_create_failed ─────────────────────────────────────────── public function testRestoreReturnsCreateFailedWhenWriteRepoReturnsZero(): void { $auditService = $this->createMock(UserLifecycleAuditService::class); $auditService->method('findDeleteEventForRestore')->willReturn(['id' => 1, 'restored_at' => null]); $auditService->method('decryptSnapshot')->willReturn(['uuid' => 'u1', 'email' => 'e@x.com']); $userReadRepo = $this->createMock(UserReadRepositoryInterface::class); $userReadRepo->method('findByUuid')->willReturn(null); $userReadRepo->method('findByEmail')->willReturn(null); $userWriteRepo = $this->createMock(UserWriteRepositoryInterface::class); $userWriteRepo->expects($this->once())->method('create')->willReturn(0); $settingsGateway = $this->createMock(UserSettingsGateway::class); $settingsGateway->method('normalizeTheme')->willReturn('light'); $dbSession = $this->createMock(DatabaseSessionRepository::class); $dbSession->expects($this->once())->method('rollbackTransaction'); $service = $this->newService( userReadRepository: $userReadRepo, userWriteRepository: $userWriteRepo, userLifecycleAuditService: $auditService, databaseSessionRepository: $dbSession, userSettingsGateway: $settingsGateway ); $result = $service->restoreFromLifecycleAudit(1, 99); $this->assertFalse($result['ok']); $this->assertSame('restore_create_failed', $result['error']); } public function testRestoreReturnsCreateFailedWhenWriteRepoReturnsFalse(): void { $auditService = $this->createMock(UserLifecycleAuditService::class); $auditService->method('findDeleteEventForRestore')->willReturn(['id' => 1, 'restored_at' => null]); $auditService->method('decryptSnapshot')->willReturn(['uuid' => 'u1', 'email' => 'e@x.com']); $userReadRepo = $this->createMock(UserReadRepositoryInterface::class); $userReadRepo->method('findByUuid')->willReturn(null); $userReadRepo->method('findByEmail')->willReturn(null); $userWriteRepo = $this->createMock(UserWriteRepositoryInterface::class); $userWriteRepo->expects($this->once())->method('create')->willReturn(false); $settingsGateway = $this->createMock(UserSettingsGateway::class); $settingsGateway->method('normalizeTheme')->willReturn('light'); $dbSession = $this->createMock(DatabaseSessionRepository::class); $dbSession->expects($this->once())->method('rollbackTransaction'); $service = $this->newService( userReadRepository: $userReadRepo, userWriteRepository: $userWriteRepo, userLifecycleAuditService: $auditService, databaseSessionRepository: $dbSession, userSettingsGateway: $settingsGateway ); $result = $service->restoreFromLifecycleAudit(1, 99); $this->assertFalse($result['ok']); $this->assertSame('restore_create_failed', $result['error']); } // ── restore_mark_failed ───────────────────────────────────────────── public function testRestoreReturnsMarkFailedWhenMarkRestoredReturnsFalse(): void { $auditService = $this->createMock(UserLifecycleAuditService::class); $auditService->method('findDeleteEventForRestore')->willReturn(['id' => 1, 'restored_at' => null]); $auditService->method('decryptSnapshot')->willReturn(['uuid' => 'u1', 'email' => 'e@x.com']); $auditService->expects($this->once()) ->method('markDeleteEventRestored') ->with(1, 99, 77) ->willReturn(false); $userReadRepo = $this->createMock(UserReadRepositoryInterface::class); $userReadRepo->method('findByUuid')->willReturn(null); $userReadRepo->method('findByEmail')->willReturn(null); $userWriteRepo = $this->createMock(UserWriteRepositoryInterface::class); $userWriteRepo->method('create')->willReturn(77); $settingsGateway = $this->createMock(UserSettingsGateway::class); $settingsGateway->method('normalizeTheme')->willReturn('light'); $dbSession = $this->createMock(DatabaseSessionRepository::class); $dbSession->expects($this->once())->method('rollbackTransaction'); $dbSession->expects($this->never())->method('commitTransaction'); $service = $this->newService( userReadRepository: $userReadRepo, userWriteRepository: $userWriteRepo, userLifecycleAuditService: $auditService, databaseSessionRepository: $dbSession, userSettingsGateway: $settingsGateway ); $result = $service->restoreFromLifecycleAudit(1, 99); $this->assertFalse($result['ok']); $this->assertSame('restore_mark_failed', $result['error']); } // ── Transaction rollback on exception ─────────────────────────────── public function testRestoreRollsBackOnUnexpectedException(): void { $auditService = $this->createMock(UserLifecycleAuditService::class); $auditService->method('findDeleteEventForRestore') ->willThrowException(new \RuntimeException('unexpected')); $dbSession = $this->createMock(DatabaseSessionRepository::class); $dbSession->expects($this->once())->method('beginTransaction'); $dbSession->expects($this->never())->method('commitTransaction'); $dbSession->expects($this->once())->method('rollbackTransaction'); $service = $this->newService( userLifecycleAuditService: $auditService, databaseSessionRepository: $dbSession ); $result = $service->restoreFromLifecycleAudit(1, 99); $this->assertFalse($result['ok']); $this->assertSame('restore_unexpected_error', $result['error']); } public function testRestoreRollbackQuietlySwallowsRollbackException(): void { $auditService = $this->createMock(UserLifecycleAuditService::class); $auditService->method('findDeleteEventForRestore') ->willThrowException(new \RuntimeException('db error')); $dbSession = $this->createMock(DatabaseSessionRepository::class); $dbSession->method('rollbackTransaction') ->willThrowException(new \RuntimeException('rollback also failed')); $service = $this->newService( userLifecycleAuditService: $auditService, databaseSessionRepository: $dbSession ); // Should not throw, even though rollback itself throws $result = $service->restoreFromLifecycleAudit(1, 99); $this->assertFalse($result['ok']); $this->assertSame('restore_unexpected_error', $result['error']); } // ── Helper ────────────────────────────────────────────────────────── private function newService( ?UserReadRepositoryInterface $userReadRepository = null, ?UserWriteRepositoryInterface $userWriteRepository = null, ?UserLifecycleAuditService $userLifecycleAuditService = null, ?DatabaseSessionRepository $databaseSessionRepository = null, ?UserSettingsGateway $userSettingsGateway = null, ): UserLifecycleRestoreService { return new UserLifecycleRestoreService( $userReadRepository ?? $this->createMock(UserReadRepositoryInterface::class), $userWriteRepository ?? $this->createMock(UserWriteRepositoryInterface::class), $userLifecycleAuditService ?? $this->createMock(UserLifecycleAuditService::class), $databaseSessionRepository ?? $this->createMock(DatabaseSessionRepository::class), $userSettingsGateway ?? $this->createMock(UserSettingsGateway::class), ); } }