1
0
Files
breadcrumb-the-shire/tests/Service/User/UserLifecycleRestoreServiceTest.php

486 lines
21 KiB
PHP
Raw Normal View History

<?php
namespace MintyPHP\Tests\Service\User;
use MintyPHP\Repository\Support\DatabaseSessionRepository;
use MintyPHP\Repository\User\UserReadRepositoryInterface;
use MintyPHP\Repository\User\UserWriteRepositoryInterface;
use MintyPHP\Service\Audit\UserLifecycleAuditInterface;
use MintyPHP\Service\User\UserLifecycleRestoreService;
use MintyPHP\Service\User\UserSettingsGateway;
use PHPUnit\Framework\TestCase;
class UserLifecycleRestoreServiceTest extends TestCase
{
// ── Full success path ───────────────────────────────────────────────
public function testRestoreFullSuccessPath(): void
{
$event = [
'id' => 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(UserLifecycleAuditInterface::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(UserLifecycleAuditInterface::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(UserLifecycleAuditInterface::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(UserLifecycleAuditInterface::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(UserLifecycleAuditInterface::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(UserLifecycleAuditInterface::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(UserLifecycleAuditInterface::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(UserLifecycleAuditInterface::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(UserLifecycleAuditInterface::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(UserLifecycleAuditInterface::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(UserLifecycleAuditInterface::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(UserLifecycleAuditInterface::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(UserLifecycleAuditInterface::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,
?UserLifecycleAuditInterface $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(UserLifecycleAuditInterface::class),
$databaseSessionRepository ?? $this->createMock(DatabaseSessionRepository::class),
$userSettingsGateway ?? $this->createMock(UserSettingsGateway::class),
);
}
}