1
0
Files
breadcrumb-the-shire/modules/audit/tests/Module/Audit/Service/UserLifecycleAuditServiceTest.php

366 lines
14 KiB
PHP
Raw Normal View History

<?php
namespace MintyPHP\Tests\Module\Audit\Service;
use MintyPHP\Module\Audit\Repository\UserLifecycleAuditRepository;
use MintyPHP\Module\Audit\Service\UserLifecycleAuditService;
use MintyPHP\Support\Crypto;
use PHPUnit\Framework\TestCase;
class UserLifecycleAuditServiceTest extends TestCase
{
private const SNAPSHOT_FIELDS = [
'uuid',
'first_name',
'last_name',
'display_name',
'email',
'profile_description',
'job_title',
'phone',
'mobile',
'short_dial',
'address',
'postal_code',
'city',
'region',
'country',
'hire_date',
'locale',
'theme',
'email_verified_at',
'last_login_at',
'last_login_provider',
'primary_tenant_id',
'current_tenant_id',
'created',
'modified',
'active_changed_at',
];
private static bool $cryptoAvailable = false;
public static function setUpBeforeClass(): void
{
if (!defined('APP_CRYPTO_KEY')) {
define('APP_CRYPTO_KEY', str_repeat('ab', 32));
}
self::$cryptoAvailable = Crypto::isConfigured();
}
// ── logDeactivate ───────────────────────────────────────────────────
public function testLogDeactivateCallsRepoCreateWithCorrectRow(): void
{
$repo = $this->createMock(UserLifecycleAuditRepository::class);
$repo->expects($this->once())
->method('create')
->with($this->callback(static function (array $row): bool {
return $row['action'] === 'deactivate'
&& $row['trigger_type'] === 'manual'
&& $row['status'] === 'success'
&& $row['run_uuid'] === 'run-1'
&& $row['target_user_id'] === 5
&& $row['target_user_uuid'] === 'user-uuid-5'
&& $row['target_user_email'] === 'test@example.com'
&& $row['actor_user_id'] === 99
&& $row['policy_deactivate_days'] === 30
&& $row['policy_delete_days'] === 60
&& $row['snapshot_enc'] === null
&& $row['snapshot_version'] === 1;
}))
->willReturn(1);
$service = new UserLifecycleAuditService($repo);
$result = $service->logDeactivate(
'run-1',
'manual',
['deactivate_days' => 30, 'delete_days' => 60],
99,
['id' => 5, 'uuid' => 'user-uuid-5', 'email' => 'test@example.com'],
'success',
null
);
$this->assertTrue($result);
}
public function testLogDeactivateReturnsFalseOnRepoException(): void
{
$repo = $this->createMock(UserLifecycleAuditRepository::class);
$repo->method('create')->willThrowException(new \RuntimeException('db error'));
$service = new UserLifecycleAuditService($repo);
$result = $service->logDeactivate('run-1', 'manual', [], null, []);
$this->assertFalse($result);
}
public function testLogDeactivateReturnsFalseWhenRepoReturnsFalse(): void
{
$repo = $this->createMock(UserLifecycleAuditRepository::class);
$repo->method('create')->willReturn(false);
$service = new UserLifecycleAuditService($repo);
$result = $service->logDeactivate('run-1', 'manual', [], null, []);
$this->assertFalse($result);
}
// ── logDeleteWithSnapshot ───────────────────────────────────────────
public function testLogDeleteWithSnapshotCreatesEncryptedSnapshot(): void
{
if (!self::$cryptoAvailable) {
$this->markTestSkipped('Crypto not configured');
}
$targetUser = [];
foreach (self::SNAPSHOT_FIELDS as $field) {
$targetUser[$field] = 'val_' . $field;
}
$targetUser['id'] = 5;
$targetUser['extra_field'] = 'should_be_excluded';
$repo = $this->createMock(UserLifecycleAuditRepository::class);
$repo->expects($this->once())
->method('create')
->with($this->callback(static function (array $row): bool {
return $row['action'] === 'delete'
&& $row['status'] === 'success'
&& $row['snapshot_version'] === 1
&& is_string($row['snapshot_enc'])
&& $row['snapshot_enc'] !== '';
}))
->willReturn(7);
$service = new UserLifecycleAuditService($repo);
$result = $service->logDeleteWithSnapshot('run-2', 'cron', ['deactivate_days' => 30, 'delete_days' => 90], null, $targetUser);
$this->assertSame(7, $result);
}
public function testLogDeleteWithSnapshotContainsOnlySnapshotFields(): void
{
if (!self::$cryptoAvailable) {
$this->markTestSkipped('Crypto not configured');
}
$targetUser = ['id' => 5, 'uuid' => 'u5', 'email' => 'e@example.com', 'extra' => 'no'];
$capturedRow = null;
$repo = $this->createMock(UserLifecycleAuditRepository::class);
$repo->expects($this->once())
->method('create')
->with($this->callback(static function (array $row) use (&$capturedRow): bool {
$capturedRow = $row;
return true;
}))
->willReturn(1);
$service = new UserLifecycleAuditService($repo);
$service->logDeleteWithSnapshot('run-3', 'manual', [], null, $targetUser);
$this->assertNotNull($capturedRow);
$decryptedJson = Crypto::decryptString($capturedRow['snapshot_enc']);
$snapshot = json_decode($decryptedJson, true);
$this->assertIsArray($snapshot);
$this->assertSame(self::SNAPSHOT_FIELDS, array_keys($snapshot));
$this->assertArrayNotHasKey('extra', $snapshot);
$this->assertArrayNotHasKey('id', $snapshot);
}
public function testLogDeleteWithSnapshotReturnsFalseOnCryptoFailure(): void
{
// Simulate a scenario where Crypto throws by passing a target user that will
// cause the try/catch to fire. We cannot easily override static Crypto, but we
// know that if APP_CRYPTO_KEY were missing, encryptString would throw.
// Since we defined a valid key, let's test the repo exception path instead.
$repo = $this->createMock(UserLifecycleAuditRepository::class);
$repo->method('create')->willThrowException(new \RuntimeException('db error'));
$service = new UserLifecycleAuditService($repo);
$result = $service->logDeleteWithSnapshot('run-4', 'manual', [], null, ['id' => 1, 'uuid' => 'u']);
$this->assertFalse($result);
}
// ── logRestore ──────────────────────────────────────────────────────
public function testLogRestoreCallsRepoWithRestoreAction(): void
{
$repo = $this->createMock(UserLifecycleAuditRepository::class);
$repo->expects($this->once())
->method('create')
->with($this->callback(static function (array $row): bool {
return $row['action'] === 'restore'
&& $row['trigger_type'] === 'manual'
&& $row['status'] === 'success';
}))
->willReturn(10);
$service = new UserLifecycleAuditService($repo);
$result = $service->logRestore('run-5', 'manual', [], 99, ['id' => 5, 'uuid' => 'u5', 'email' => 'e@x.com']);
$this->assertTrue($result);
}
public function testLogRestoreReturnsFalseOnException(): void
{
$repo = $this->createMock(UserLifecycleAuditRepository::class);
$repo->method('create')->willThrowException(new \RuntimeException('fail'));
$service = new UserLifecycleAuditService($repo);
$result = $service->logRestore('run-5', 'manual', [], null, []);
$this->assertFalse($result);
}
// ── decryptSnapshot ─────────────────────────────────────────────────
public function testDecryptSnapshotWithValidPayload(): void
{
if (!self::$cryptoAvailable) {
$this->markTestSkipped('Crypto not configured');
}
$original = ['uuid' => 'test-uuid', 'email' => 'test@example.com'];
$encrypted = Crypto::encryptString(json_encode($original));
$repo = $this->createMock(UserLifecycleAuditRepository::class);
$service = new UserLifecycleAuditService($repo);
$result = $service->decryptSnapshot(['snapshot_enc' => $encrypted]);
$this->assertSame($original, $result);
}
public function testDecryptSnapshotReturnsNullForEmptyPayload(): void
{
$repo = $this->createMock(UserLifecycleAuditRepository::class);
$service = new UserLifecycleAuditService($repo);
$this->assertNull($service->decryptSnapshot([]));
$this->assertNull($service->decryptSnapshot(['snapshot_enc' => '']));
$this->assertNull($service->decryptSnapshot(['snapshot_enc' => ' ']));
}
public function testDecryptSnapshotReturnsNullForInvalidPayload(): void
{
$repo = $this->createMock(UserLifecycleAuditRepository::class);
$service = new UserLifecycleAuditService($repo);
$result = $service->decryptSnapshot(['snapshot_enc' => 'not-valid-encrypted-data']);
$this->assertNull($result);
}
// ── markDeleteEventRestored ─────────────────────────────────────────
public function testMarkDeleteEventRestoredDelegatesToRepo(): void
{
$repo = $this->createMock(UserLifecycleAuditRepository::class);
$repo->expects($this->once())
->method('markRestored')
->with(1, 99, 200)
->willReturn(true);
$service = new UserLifecycleAuditService($repo);
$this->assertTrue($service->markDeleteEventRestored(1, 99, 200));
}
public function testMarkDeleteEventRestoredReturnsFalseOnException(): void
{
$repo = $this->createMock(UserLifecycleAuditRepository::class);
$repo->method('markRestored')->willThrowException(new \RuntimeException('fail'));
$service = new UserLifecycleAuditService($repo);
$this->assertFalse($service->markDeleteEventRestored(1, 99, 200));
}
// ── purgeExpired ────────────────────────────────────────────────────
public function testPurgeExpiredDelegatesToRepoWith365Days(): void
{
$repo = $this->createMock(UserLifecycleAuditRepository::class);
$repo->expects($this->once())
->method('purgeOlderThanDays')
->with(365)
->willReturn(5);
$service = new UserLifecycleAuditService($repo);
$this->assertSame(5, $service->purgeExpired());
}
// ── buildBaseRow enum normalization ──────────────────────────────────
public function testBuildBaseRowNormalizesInvalidTriggerTypeToDefault(): void
{
$repo = $this->createMock(UserLifecycleAuditRepository::class);
$repo->expects($this->once())
->method('create')
->with($this->callback(static function (array $row): bool {
return $row['trigger_type'] === 'system'; // fallback for invalid trigger
}))
->willReturn(1);
$service = new UserLifecycleAuditService($repo);
$service->logDeactivate('run-x', 'INVALID_TRIGGER', [], null, []);
}
public function testBuildBaseRowNormalizesInvalidStatusToFailed(): void
{
$repo = $this->createMock(UserLifecycleAuditRepository::class);
$repo->expects($this->once())
->method('create')
->with($this->callback(static function (array $row): bool {
return $row['status'] === 'failed'; // fallback for invalid status
}))
->willReturn(1);
$service = new UserLifecycleAuditService($repo);
$service->logDeactivate('run-x', 'manual', [], null, [], 'INVALID_STATUS');
}
public function testBuildBaseRowNormalizesNullActorToNull(): void
{
$repo = $this->createMock(UserLifecycleAuditRepository::class);
$repo->expects($this->once())
->method('create')
->with($this->callback(static function (array $row): bool {
return $row['actor_user_id'] === null;
}))
->willReturn(1);
$service = new UserLifecycleAuditService($repo);
$service->logDeactivate('run-x', 'manual', [], null, []);
}
public function testBuildBaseRowNormalizesZeroActorToNull(): void
{
$repo = $this->createMock(UserLifecycleAuditRepository::class);
$repo->expects($this->once())
->method('create')
->with($this->callback(static function (array $row): bool {
return $row['actor_user_id'] === null;
}))
->willReturn(1);
$service = new UserLifecycleAuditService($repo);
$service->logDeactivate('run-x', 'manual', [], 0, []);
}
public function testBuildBaseRowSetsNegativePolicyDaysToZero(): void
{
$repo = $this->createMock(UserLifecycleAuditRepository::class);
$repo->expects($this->once())
->method('create')
->with($this->callback(static function (array $row): bool {
return $row['policy_deactivate_days'] === 0
&& $row['policy_delete_days'] === 0;
}))
->willReturn(1);
$service = new UserLifecycleAuditService($repo);
$service->logDeactivate('run-x', 'manual', ['deactivate_days' => -10, 'delete_days' => -5], null, []);
}
}